Repository navigation
fix(think): run WebSocket chat turns on the implicit web channel (#2255) #2330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
threepointone
merged 4 commits into
fix/1876-continuation-same-assistant
from
fix/2255-ws-chat-web-channel
Sep 23, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
cf7fa98
fix(think): run WebSocket chat turns on the implicit web channel (#2255)
threepointone 5165664
fix(think): continue a regenerated web turn on web
threepointone 04e277d
fix(think): extend the channel of the latest turn, continuation or not
threepointone a3fbf75
fix(think): resolve an inherited continuation channel when the turn s…
threepointone File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "@cloudflare/think": patch | ||
| --- | ||
|
|
||
| WebSocket chat turns now run on the implicit `web` channel (#2255). | ||
|
|
||
| A `web` entry in `configureChannels()` (its `instructions`, `tools`, and `maxTurns`) now applies to turns sent from `useAgentChat`, and `this.activeChannel` is set during those turns. Previously these turns ran without a channel context, so the `web` policy was silently ignored. | ||
|
|
||
| The channel is stamped on each new user message and kept when the client re-sends the transcript, so continuations after client tool results and recovered turns keep the `web` channel. A continuation without a `channel` extends the channel of the turn before it, so regenerating on web continues on web and a continuation run with an explicit `channel` is extended on that channel. A channel in client-sent message metadata is still ignored. `runTurn()` and `chat()` calls without a `channel` are unchanged and run without a channel context. | ||
|
|
||
| If you configured a `web` channel, its policy now takes effect for browser chat; check that its `tools` narrowing and `maxTurns` suit that traffic. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| /** | ||
| * Regression coverage for #2255: WebSocket chat turns run on the implicit | ||
| * `web` channel, so a `web` entry in `configureChannels()` applies to them. | ||
| */ | ||
|
|
||
| import { env, exports } from "cloudflare:workers"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { getAgentByName } from "agents"; | ||
| import type { UIMessage } from "ai"; | ||
| import type { ThinkTestAgent } from "./agents/think-session"; | ||
|
|
||
| const MSG_CHAT_REQUEST = "cf_agent_use_chat_request"; | ||
| const MSG_CHAT_RESPONSE = "cf_agent_use_chat_response"; | ||
|
|
||
| async function freshAgent() { | ||
| const room = crypto.randomUUID(); | ||
| const agent = await getAgentByName( | ||
| env.ThinkTestAgent as unknown as DurableObjectNamespace<ThinkTestAgent>, | ||
| room | ||
| ); | ||
| const res = await exports.default.fetch( | ||
| `http://example.com/agents/think-test-agent/${room}`, | ||
| { headers: { Upgrade: "websocket" } } | ||
| ); | ||
| expect(res.status).toBe(101); | ||
| const ws = res.webSocket as WebSocket; | ||
| ws.accept(); | ||
| return { agent, ws }; | ||
| } | ||
|
|
||
| function userMessage( | ||
| text: string, | ||
| metadata?: Record<string, unknown> | ||
| ): UIMessage { | ||
| return { | ||
| id: crypto.randomUUID(), | ||
| role: "user", | ||
| parts: [{ type: "text", text }], | ||
| ...(metadata ? { metadata } : {}) | ||
| }; | ||
| } | ||
|
|
||
| /** Sends a chat request and resolves once its terminal `done` frame arrives. */ | ||
| function sendChat( | ||
| ws: WebSocket, | ||
| messages: UIMessage[], | ||
| trigger?: string | ||
| ): Promise<void> { | ||
| const id = crypto.randomUUID(); | ||
| const done = new Promise<void>((resolve, reject) => { | ||
| const timer = setTimeout( | ||
| () => reject(new Error("Timeout waiting for done")), | ||
| 10_000 | ||
| ); | ||
| const handler = (e: MessageEvent) => { | ||
| const msg = JSON.parse(e.data as string) as Record<string, unknown>; | ||
| if (msg.type === MSG_CHAT_RESPONSE && msg.id === id && msg.done) { | ||
| clearTimeout(timer); | ||
| ws.removeEventListener("message", handler); | ||
| resolve(); | ||
| } | ||
| }; | ||
| ws.addEventListener("message", handler); | ||
| }); | ||
| ws.send( | ||
| JSON.stringify({ | ||
| type: MSG_CHAT_REQUEST, | ||
| id, | ||
| init: { method: "POST", body: JSON.stringify({ messages, trigger }) } | ||
| }) | ||
| ); | ||
| return done; | ||
| } | ||
|
|
||
| function channelOf(message: UIMessage | undefined): unknown { | ||
| return (message?.metadata as { channel?: unknown } | undefined)?.channel; | ||
| } | ||
|
|
||
| describe("Think — WebSocket chat runs on the web channel (#2255)", () => { | ||
| it("applies the web channel policy and exposes activeChannel", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
|
|
||
| await sendChat(ws, [userMessage("hello")]); | ||
|
|
||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual(["web"]); | ||
| const log = await agent.getBeforeTurnLog(); | ||
| expect(log.at(-1)?.system).toContain("WEB MODE"); | ||
| expect(log.at(-1)?.system).not.toContain("VOICE MODE"); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("stamps the web channel on the user message", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
|
|
||
| await sendChat(ws, [userMessage("hello")]); | ||
|
|
||
| const messages = (await agent.getStoredMessages()) as UIMessage[]; | ||
| expect(channelOf(messages.find((m) => m.role === "user"))).toBe("web"); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("keeps the stamp when the client re-sends the transcript", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
| const first = userMessage("first"); | ||
|
|
||
| await sendChat(ws, [first]); | ||
| const afterFirst = (await agent.getStoredMessages()) as UIMessage[]; | ||
| const second = userMessage("second"); | ||
| // A browser client echoes messages back without server-owned metadata. | ||
| await sendChat(ws, [ | ||
| first, | ||
| ...afterFirst.filter((m) => m.role === "assistant"), | ||
| second | ||
| ]); | ||
|
|
||
| const messages = (await agent.getStoredMessages()) as UIMessage[]; | ||
| const users = messages.filter((m) => m.role === "user"); | ||
| expect(users.map((m) => m.id)).toEqual([first.id, second.id]); | ||
| expect(users.map(channelOf)).toEqual(["web", "web"]); | ||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual([ | ||
| "web", | ||
| "web" | ||
| ]); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("ignores a channel forged in client-sent metadata", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
|
|
||
| await sendChat(ws, [userMessage("hello", { channel: "voice" })]); | ||
|
|
||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual(["web"]); | ||
| const log = await agent.getBeforeTurnLog(); | ||
| expect(log.at(-1)?.system).not.toContain("VOICE MODE"); | ||
| const messages = (await agent.getStoredMessages()) as UIMessage[]; | ||
| expect(channelOf(messages.find((m) => m.role === "user"))).toBe("web"); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("re-resolves the web channel when the turn is continued", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
|
|
||
| await sendChat(ws, [userMessage("hello")]); | ||
| await agent.resetCapturedTurnChannelsForTest(); | ||
| await agent.runChannelTurnForTest({ continuation: true }); | ||
|
|
||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual(["web"]); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("continues a regeneration of another channel's message on web", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
| await agent.runChannelTurnForTest({ input: "hello", channel: "voice" }); | ||
| const stored = (await agent.getStoredMessages()) as UIMessage[]; | ||
| const voiceUser = stored.find((m) => m.role === "user"); | ||
| expect(channelOf(voiceUser)).toBe("voice"); | ||
| await agent.resetCapturedTurnChannelsForTest(); | ||
|
|
||
| await sendChat( | ||
| ws, | ||
| [{ ...voiceUser!, metadata: undefined }], | ||
| "regenerate-message" | ||
| ); | ||
|
|
||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual(["web"]); | ||
| // The stored message keeps the channel it was sent on. | ||
| const messages = (await agent.getStoredMessages()) as UIMessage[]; | ||
| expect(channelOf(messages.find((m) => m.id === voiceUser!.id))).toBe( | ||
| "voice" | ||
| ); | ||
| // A tool-result continuation extends the web turn, not the voice one. | ||
| expect(await agent.getAutoContinuationChannelForTest()).toBe("web"); | ||
|
|
||
| await agent.resetCapturedTurnChannelsForTest(); | ||
| await agent.runChannelTurnForTest({ continuation: true }); | ||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual(["web"]); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("extends the channel an explicit-channel continuation ran on", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
| await sendChat(ws, [userMessage("hello")]); | ||
|
|
||
| await agent.runChannelTurnForTest({ continuation: true, channel: "voice" }); | ||
|
|
||
| expect(await agent.getAutoContinuationChannelForTest()).toBe("voice"); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("resolves a queued continuation's channel when it runs, not when it is admitted", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
| await sendChat(ws, [userMessage("hello")]); | ||
| await agent.resetCapturedTurnChannelsForTest(); | ||
|
|
||
| await agent.runQueuedContinuationsForTest(["voice", undefined]); | ||
|
|
||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual([ | ||
| "voice", | ||
| "voice" | ||
| ]); | ||
| ws.close(); | ||
| }); | ||
|
|
||
| it("still runs server-driven turns without a channel by default", async () => { | ||
| const { agent, ws } = await freshAgent(); | ||
|
|
||
| await agent.runChannelTurnForTest({ input: "hello" }); | ||
|
|
||
| expect(await agent.getCapturedTurnChannelsForTest()).toEqual([""]); | ||
| const log = await agent.getBeforeTurnLog(); | ||
| expect(log.at(-1)?.system).not.toContain("WEB MODE"); | ||
| ws.close(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 External documentation sync required
The repository requires this channel documentation change to be ported manually to cloudflare-docs. That external update cannot be verified here.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged. The cloudflare-docs sync happens at release time with the rest of the Think docs.