Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/think-channel-speaker-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cloudflare/think": minor
---

feat(think): add channelSpeakerLabel option to MessengerDefinition for configurable channel speaker prefixing

Channel (non-DM) messages and action events are prefixed with the speaker label
so the model can attribute multi-user traffic; direct messages never get a
prefix. Previously action events were labelled even in DMs — they now follow the
same channel-only rule as regular messages.
34 changes: 34 additions & 0 deletions docs/think/messengers.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,38 @@ source message id, and initiating user. Use `getMessengerContext()?.action`
inside hooks or tools when you need provider-specific action details. Actions
are opt-in so interactive cards do not accidentally trigger model turns.

## Channel Speaker Labels

In multi-user channels (group chats, rooms, threads that are not direct
messages), Think prefixes the speaker onto the model-facing text so the model
can attribute traffic from several people:

```text
Ada Lovelace: summarize this
```

The default label is the author cascade `fullName || userName || userId`.
**Direct messages never get a speaker prefix**, even if a label would resolve —
a DM is already a one-to-one conversation, so the extra name is noise.

Customize this with `channelSpeakerLabel` on the messenger definition. The option
accepts a formatter only; return `null` or an empty string to suppress the prefix
for that author.

```typescript
telegramMessenger({
token: this.env.TELEGRAM_BOT_TOKEN,
userName: "support_bot",
secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,
channelSpeakerLabel: (author) => author.userName ?? author.userId
});
```

Action events (button presses) are labelled the same way as regular messages:
they get a speaker prefix in channels so the model can attribute interactive
clicks, and no prefix in DMs. Returning `null` or an empty string from
`channelSpeakerLabel` suppresses the channel label as well.

## Conversation Targets

The default conversation mode is one Think sub-agent per Chat SDK thread. This
Expand Down Expand Up @@ -176,6 +208,8 @@ chatSdkMessenger({
adapter,
provider: "custom",
userName: "custom_bot",
// Optional: control how channel speakers are labelled for the model.
// channelSpeakerLabel: (author) => string | null | undefined
verifyWebhook(request) {
return request.headers.get("x-custom-signature") === expectedSignature;
}
Expand Down
17 changes: 9 additions & 8 deletions packages/think/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,15 @@ contract from their own subpaths.

Common messenger options:

| Option | Default | Description |
| -------------------- | ------------------------------- | ------------------------------------------------------------------------------- |
| `path` | `/messengers/{id}/webhook` | Webhook path handled before user `onRequest` |
| `respondTo` | `["direct-message", "mention"]` | Event kinds that should start a Think reply |
| `subscribeOnMention` | `true` | Subscribe Chat SDK threads after a new mention |
| `conversation` | `"thread"` | Use one Think sub-agent per Chat SDK thread; set `"self"` to use the root agent |
| `verifyWebhook` | required | Verification function, or `false` to opt out explicitly |
| `delivery` | provider defaults | Streaming limits, text splitting, and safe user-facing failure messages |
| Option | Default | Description |
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `path` | `/messengers/{id}/webhook` | Webhook path handled before user `onRequest` |
| `respondTo` | `["direct-message", "mention"]` | Event kinds that should start a Think reply |
| `subscribeOnMention` | `true` | Subscribe Chat SDK threads after a new mention |
| `conversation` | `"thread"` | Use one Think sub-agent per Chat SDK thread; set `"self"` to use the root agent |
| `channelSpeakerLabel` | `fullName \|\| userName \|\| userId` | Channel (non-DM) messages are rendered as `SpeakerName: text`. Pass a formatter to customize or suppress labels. DMs never get a prefix. |
| `verifyWebhook` | required | Verification function, or `false` to opt out explicitly |
| `delivery` | provider defaults | Streaming limits, text splitting, and safe user-facing failure messages |

## Agent tools

Expand Down
16 changes: 14 additions & 2 deletions packages/think/src/messengers/chat-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import type {
MessengerMessage,
MessengerThread
} from "./events";
import { serializableMessengerEvent, toMessengerUserMessage } from "./events";
import {
serializableMessengerEvent,
toMessengerUserMessage,
type ChannelSpeakerLabel
} from "./events";
import {
deliverMessengerReply,
MESSENGER_REPLY_FIBER_NAME,
Expand Down Expand Up @@ -70,6 +74,14 @@ export interface MessengerDefinition {
adapter: Adapter;
adapterName: string;
capabilities?: MessengerCapabilities;
/**
* Customizes how non-DM channel messages are labelled for the model.
*
* Channel (group) messages use `fullName || userName || userId` by default and
* are rendered as `SpeakerName: text` so the model can attribute multi-user
* traffic. Direct messages never get a prefix.
*/
channelSpeakerLabel?: ChannelSpeakerLabel;
conversation?: MessengerConversationMode | MessengerConversationResolver;
delivery?: MessengerDeliveryPolicy;
keyShard?: ChatSdkStateAdapterOptions["keyShard"];
Expand Down Expand Up @@ -463,7 +475,7 @@ export class ThinkMessengerRuntime {
snapshotThread: thread.toJSON(),
surface: thread satisfies MessengerDeliverySurface,
target,
userMessage: toMessengerUserMessage(event)
userMessage: toMessengerUserMessage(event, definition.channelSpeakerLabel)
});
}

Expand Down
41 changes: 37 additions & 4 deletions packages/think/src/messengers/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,42 @@ export function serializableMessengerEvent(
};
}

export function toMessengerUserMessage(event: MessengerEvent): UIMessage {
/**
* Customizes how channel (non-DM) speaker names are prefixed onto model-facing
* text. By default, speaker labels use `fullName || userName || userId`.
* Direct messages never get a speaker prefix, regardless of this setting.
* Return `null`/empty to suppress the prefix for that author.
*/
export type ChannelSpeakerLabel = (
author: MessengerAuthor
) => string | null | undefined;

export function resolveChannelSpeakerLabel(
author: MessengerAuthor | undefined,
channelSpeakerLabel?: ChannelSpeakerLabel
): string | undefined {
if (!author) {
return undefined;
}

if (channelSpeakerLabel) {
const label = channelSpeakerLabel(author);
return label ? label : undefined;
}

return author.fullName || author.userName || author.userId || undefined;
}

export function toMessengerUserMessage(
event: MessengerEvent,
channelSpeakerLabel?: ChannelSpeakerLabel
): UIMessage {
const message = event.message;
if (event.action) {
const user = event.action.user;
const displayName = user?.fullName || user?.userName || user?.userId;
const displayName = event.thread.isDirectMessage
? undefined
: resolveChannelSpeakerLabel(user, channelSpeakerLabel);
const details = [
`Action selected: ${event.action.actionId}`,
event.action.value ? `Value: ${event.action.value}` : undefined,
Expand Down Expand Up @@ -182,8 +213,10 @@ export function toMessengerUserMessage(event: MessengerEvent): UIMessage {
}

const text = message.text.trim();
const displayName =
message.author.fullName || message.author.userName || message.author.userId;
const displayName = resolveChannelSpeakerLabel(
message.author,
channelSpeakerLabel
);
const content =
event.thread.isDirectMessage || !displayName
? text
Expand Down
2 changes: 2 additions & 0 deletions packages/think/src/messengers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,13 @@ export type {

export {
messengerContextFromEvent,
resolveChannelSpeakerLabel,
serializableMessengerEvent,
toMessengerUserMessage
} from "./events";

export type {
ChannelSpeakerLabel,
Comment thread
cjol marked this conversation as resolved.
MessengerAction,
MessengerAttachment,
MessengerAuthor,
Expand Down
176 changes: 176 additions & 0 deletions packages/think/src/tests/messengers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,182 @@ describe("think messengers core", () => {
]);
});

it("prefixes channel messages with the default fullName cascade", () => {
const event: MessengerEvent = {
...baseEvent,
message: {
...baseEvent.message!,
attachments: [],
author: {
fullName: "Ada Lovelace",
userId: "telegram:user",
userName: "ada"
},
text: "hello channel"
},
thread: { ...baseEvent.thread, isDirectMessage: false }
};

expect(toMessengerUserMessage(event).parts).toEqual([
{ type: "text", text: "Ada Lovelace: hello channel" }
]);
});

it("falls back through fullName || userName || userId for the default label", () => {
const noFullName: MessengerEvent = {
...baseEvent,
message: {
...baseEvent.message!,
attachments: [],
author: { userId: "telegram:user", userName: "ada" },
text: "hello"
}
};
expect(toMessengerUserMessage(noFullName).parts).toEqual([
{ type: "text", text: "ada: hello" }
]);

const idOnly: MessengerEvent = {
...baseEvent,
message: {
...baseEvent.message!,
attachments: [],
author: { userId: "telegram:user" },
text: "hello"
}
};
expect(toMessengerUserMessage(idOnly).parts).toEqual([
{ type: "text", text: "telegram:user: hello" }
]);
});

it("accepts a custom channelSpeakerLabel formatter", () => {
const event: MessengerEvent = {
...baseEvent,
message: {
...baseEvent.message!,
attachments: [],
author: {
fullName: "Ada Lovelace",
userId: "telegram:user",
userName: "ada"
},
text: "hello"
}
};

expect(
toMessengerUserMessage(event, (author) => `@${author.userName}`).parts
).toEqual([{ type: "text", text: "@ada: hello" }]);

// Returning null/empty suppresses the prefix for that author.
expect(toMessengerUserMessage(event, () => null).parts).toEqual([
{ type: "text", text: "hello" }
]);
});

it("never prefixes direct messages regardless of channelSpeakerLabel", () => {
const dmEvent: MessengerEvent = {
...baseEvent,
message: {
...baseEvent.message!,
attachments: [],
text: "hello dm"
},
thread: { ...baseEvent.thread, isDirectMessage: true }
};

expect(toMessengerUserMessage(dmEvent).parts).toEqual([
{ type: "text", text: "hello dm" }
]);
expect(
toMessengerUserMessage(dmEvent, (author) => author.fullName ?? null).parts
).toEqual([{ type: "text", text: "hello dm" }]);
});

it("prefixes channel actions with the resolved speaker label", () => {
const channelActionEvent: MessengerEvent = {
...baseEvent,
action: {
actionId: "approve",
messageId: "source-message",
user: {
fullName: "Ada Lovelace",
userId: "telegram:user",
userName: "ada"
},
value: "ship-it"
},
kind: "action",
message: undefined,
thread: { ...baseEvent.thread, isDirectMessage: false }
};

expect(toMessengerUserMessage(channelActionEvent).parts).toEqual([
{
type: "text",
text: [
"Ada Lovelace: Action selected: approve",
"Value: ship-it",
"Source message: source-message"
].join("\n")
}
]);

expect(
toMessengerUserMessage(
channelActionEvent,
(author) => `@${author.userName}`
).parts
).toEqual([
{
type: "text",
text: [
"@ada: Action selected: approve",
"Value: ship-it",
"Source message: source-message"
].join("\n")
}
]);
});

it("never prefixes direct message actions", () => {
const dmActionEvent: MessengerEvent = {
...baseEvent,
action: {
actionId: "approve",
messageId: "source-message",
user: {
fullName: "Ada Lovelace",
userId: "telegram:user",
userName: "ada"
},
value: "ship-it"
},
kind: "action",
message: undefined,
thread: { ...baseEvent.thread, isDirectMessage: true }
};

const expected = [
{
type: "text",
text: [
"Action selected: approve",
"Value: ship-it",
"Source message: source-message"
].join("\n")
}
];

expect(toMessengerUserMessage(dmActionEvent).parts).toEqual(expected);
// A custom label cannot re-introduce a prefix in DMs.
expect(
toMessengerUserMessage(dmActionEvent, (author) => author.fullName ?? null)
.parts
).toEqual(expected);
});

it("converts messenger actions to Think user messages", () => {
const event = defaultChatSdkEvent(
normalizeMessengers({
Expand Down
Loading