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
5 changes: 5 additions & 0 deletions .changeset/durable-chat-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/ai-chat": minor
---

Add `durable` and `serverTurnCancellation` options to `useAgentChat`. `durable: true` treats browser/client stream cleanup as local-only while preserving explicit `stop()` as server-side turn cancellation.
30 changes: 19 additions & 11 deletions docs/chat-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,16 +775,18 @@ function Chat() {

### Options

| Option | Type | Default | Description |
| ----------------------------- | ----------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| `agent` | `ReturnType<typeof useAgent>` | Required | Agent connection from `useAgent` |
| `onToolCall` | `({ toolCall, addToolOutput }) => void` | — | Handle client-side tool execution |
| `autoContinueAfterToolResult` | `boolean` | `true` | Auto-continue conversation after client tool results and approvals |
| `resume` | `boolean` | `true` | Enable automatic stream resumption on reconnect |
| `body` | `object \| () => object` | — | Custom data sent with every request |
| `prepareSendMessagesRequest` | `(options) => { body?, headers? }` | — | Advanced per-request customization |
| `tools` | `Record<string, AITool>` | — | Dynamic client-defined tools for SDK/platform use cases. Schemas are sent to the server automatically |
| `getInitialMessages` | `(options) => Promise<ChatMessage[]>` or `null` | — | Custom initial message loader. Set to `null` to skip the HTTP fetch entirely (useful when providing `messages` directly) |
| Option | Type | Default | Description |
| ----------------------------- | ----------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent` | `ReturnType<typeof useAgent>` | Required | Agent connection from `useAgent` |
| `onToolCall` | `({ toolCall, addToolOutput }) => void` | — | Handle client-side tool execution |
| `autoContinueAfterToolResult` | `boolean` | `true` | Auto-continue conversation after client tool results and approvals |
| `resume` | `boolean` | `true` | Enable automatic stream resumption on reconnect |
| `durable` | `boolean` | `false` | Treat the browser as a reconnectable observer: generic client stream abort/cleanup is local-only, while explicit `stop()` still cancels the server turn |
| `serverTurnCancellation` | `"on-client-abort" \| "explicit-only"` | `"on-client-abort"` | Advanced cancellation policy. `durable: true` defaults this to `"explicit-only"` unless explicitly overridden |
| `body` | `object \| () => object` | — | Custom data sent with every request |
| `prepareSendMessagesRequest` | `(options) => { body?, headers? }` | — | Advanced per-request customization |
| `tools` | `Record<string, AITool>` | — | Dynamic client-defined tools for SDK/platform use cases. Schemas are sent to the server automatically |
| `getInitialMessages` | `(options) => Promise<ChatMessage[]>` or `null` | — | Custom initial message loader. Set to `null` to skip the HTTP fetch entirely (useful when providing `messages` directly) |

### Return Values

Expand Down Expand Up @@ -1169,7 +1171,13 @@ When streaming is active:
2. If the client disconnects, the server continues streaming and buffering
3. When the client reconnects, it receives all buffered chunks and resumes live streaming

Disable with `resume: false`:
For durable sessions where the browser should be a reconnectable observer of the turn, set `durable: true`. Generic client stream abort/cleanup stays local to the browser, but an explicit `stop()` still sends server cancellation:

```tsx
const { messages, stop } = useAgentChat({ agent, durable: true });
```

Disable resume with `resume: false`:

```tsx
const { messages } = useAgentChat({ agent, resume: false });
Expand Down
19 changes: 18 additions & 1 deletion docs/resumable-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ When you use `AIChatAgent` with `useAgentChat`:

No extra code is needed -- it just works.

If your app treats the browser as a reconnectable observer of a long-running turn, set `durable: true` on `useAgentChat`. This keeps generic client stream abort/cleanup local to the browser while preserving explicit `stop()` as server-side cancellation.

## Example

### Server
Expand Down Expand Up @@ -49,8 +51,10 @@ function Chat() {
});

const { messages, sendMessage, status } = useAgentChat({
agent
agent,
durable: true
// resume: true is the default - streams automatically resume on reconnect
// durable: true means browser cleanup does not cancel the server turn
});

// ... render your chat UI
Expand Down Expand Up @@ -80,6 +84,19 @@ function Chat() {

Replayed chunks include `replay: true` to distinguish them from live chunks. The client uses this to batch-apply all replayed chunks before rendering, which prevents intermediate states (like reasoning "Thinking..." indicators) from flashing briefly during replay. During a live stream, chunks arrive gradually and React renders each intermediate state naturally.

## Durable client cleanup

`resume: true` controls whether the client tries to reconnect to an active stream. `durable: true` controls cancellation semantics: generic client stream abort/cleanup is local-only, while explicit `stop()` still cancels the server turn.

```tsx
const { messages, stop } = useAgentChat({
agent,
durable: true
});
```

Advanced clients can set `serverTurnCancellation: "explicit-only"` directly. The default remains `"on-client-abort"` for backward compatibility.

## Disabling Resume

If you do not want automatic resume (for example, for short responses), disable it:
Expand Down
28 changes: 18 additions & 10 deletions packages/ai-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,13 @@ Streams automatically resume on disconnect/reconnect. No configuration needed.

When a client disconnects mid-stream, chunks are buffered in SQLite. On reconnect, the client receives all buffered chunks and continues receiving the live stream.

Disable with `resume: false`:
For apps where the browser should be only a reconnectable observer of a long-running server turn, set `durable: true`. Generic client stream abort/cleanup becomes local-only, while an explicit `stop()` still cancels the server turn:

```tsx
const { messages, stop } = useAgentChat({ agent, durable: true });
```

Disable resume with `resume: false`:

```tsx
const { messages } = useAgentChat({ agent, resume: false });
Expand Down Expand Up @@ -429,15 +435,17 @@ React hook for chat interactions. Wraps the AI SDK's `useChat` with WebSocket tr

**Options:**

| Option | Type | Description |
| ----------------------------- | --------------------------------------- | -------------------------------------------------------- |
| `agent` | `ReturnType<typeof useAgent>` | Agent connection (required) |
| `onToolCall` | `({ toolCall, addToolOutput }) => void` | Handle client-side tool execution |
| `autoContinueAfterToolResult` | `boolean` | Auto-continue after client tool results. Default: `true` |
| `resume` | `boolean` | Enable stream resumption. Default: `true` |
| `body` | `object \| () => object` | Custom data sent with every request (see below) |
| `prepareSendMessagesRequest` | `(options) => { body?, headers? }` | Advanced per-request customization |
| `getInitialMessages` | `(options) => Promise<ChatMessage[]>` | Custom initial message loader |
| Option | Type | Description |
| ----------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `agent` | `ReturnType<typeof useAgent>` | Agent connection (required) |
| `onToolCall` | `({ toolCall, addToolOutput }) => void` | Handle client-side tool execution |
| `autoContinueAfterToolResult` | `boolean` | Auto-continue after client tool results. Default: `true` |
| `resume` | `boolean` | Enable stream resumption. Default: `true` |
| `durable` | `boolean` | Treat browser cleanup as local-only; explicit `stop()` still cancels the server turn. Default: `false` |
| `serverTurnCancellation` | `"on-client-abort" \| "explicit-only"` | Advanced cancellation policy. Default: `"on-client-abort"` |
| `body` | `object \| () => object` | Custom data sent with every request (see below) |
| `prepareSendMessagesRequest` | `(options) => { body?, headers? }` | Advanced per-request customization |
| `getInitialMessages` | `(options) => Promise<ChatMessage[]>` | Custom initial message loader |

**Returns:**

Expand Down
74 changes: 74 additions & 0 deletions packages/ai-chat/src/react-tests/use-agent-chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4452,6 +4452,80 @@ describe("useAgentChat tool approval continuations (issue #1108)", () => {
});
});

describe("useAgentChat durable cancellation", () => {
function createAgentWithTarget({ name, url }: { name: string; url: string }) {
const target = new EventTarget();
const sentMessages: string[] = [];
const agent = createAgent({
name,
url,
send: (data: string) => sentMessages.push(data)
});

(agent as unknown as Record<string, unknown>).addEventListener =
target.addEventListener.bind(target);
(agent as unknown as Record<string, unknown>).removeEventListener =
target.removeEventListener.bind(target);

return { agent, sentMessages };
}

it("durable mode keeps explicit stop() as server cancellation", async () => {
const { agent, sentMessages } = createAgentWithTarget({
name: "durable-explicit-stop",
url: "ws://localhost:3000/agents/chat/durable-explicit-stop?_pk=abc"
});

let chatInstance: ReturnType<typeof useAgentChat> | null = null;

const TestComponent = () => {
const chat = useAgentChat({
agent,
durable: true,
getInitialMessages: null,
messages: [] as UIMessage[],
resume: false
});
chatInstance = chat;
return <div data-testid="status">{chat.status}</div>;
};

await act(async () => {
render(<TestComponent />, {
wrapper: ({ children }) => (
<StrictMode>
<Suspense fallback="Loading...">{children}</Suspense>
</StrictMode>
)
});
await sleep(10);
});

await act(async () => {
void chatInstance!.sendMessage({ text: "Hello" });
await sleep(10);
});

const requestId = sentMessages
.map((message) => JSON.parse(message) as Record<string, unknown>)
.find((message) => message.type === "cf_agent_use_chat_request")?.id;
expect(requestId).toBeDefined();

await act(async () => {
await chatInstance!.stop();
await sleep(10);
});

const cancelMessage = sentMessages
.map((message) => JSON.parse(message) as Record<string, unknown>)
.find((message) => message.type === "cf_agent_chat_request_cancel");
expect(cancelMessage).toEqual({
type: "cf_agent_chat_request_cancel",
id: requestId
});
});
});

describe("useAgentChat overlapping submits (issue #1231)", () => {
function createAgentWithTarget({ name, url }: { name: string; url: string }) {
const target = new EventTarget();
Expand Down
31 changes: 30 additions & 1 deletion packages/ai-chat/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { MessageType } from "./types";
import { broadcastTransition, type BroadcastStreamState } from "agents/chat";
import {
WebSocketChatTransport,
type AgentConnection
type AgentConnection,
type ServerTurnCancellationPolicy
} from "./ws-chat-transport";

/**
Expand Down Expand Up @@ -460,6 +461,25 @@ type UseAgentChatOptions<
* @default true
*/
resume?: boolean;
/**
* Treat the browser as a reconnectable observer of a durable server turn.
* Generic client-side stream abort/cleanup becomes local-only, while
* explicit stop() still cancels the server turn.
*
* This is an intent-level alias for
* serverTurnCancellation: "explicit-only".
*
* @default false
*/
durable?: boolean;
/**
* Controls whether generic client-side abort/cancel lifecycle propagates
* to the durable server turn. Use "explicit-only" when the browser should
* be able to detach and later resume without stopping server work.
*
* @default "on-client-abort"
*/
serverTurnCancellation?: ServerTurnCancellationPolicy;
/**
* Custom data to include in every chat request body.
* Accepts a static object or a function that returns one (for dynamic values).
Expand Down Expand Up @@ -615,11 +635,17 @@ export function useAgentChat<
autoContinueAfterToolResult = true, // Server auto-continues after tool results/approvals
autoSendAfterAllConfirmationsResolved = true, // Legacy option for client-side batching
resume = true, // Enable stream resumption by default
durable = false,
serverTurnCancellation: serverTurnCancellationOption,
body: bodyOption,
prepareSendMessagesRequest,
...rest
} = options;

const serverTurnCancellation: ServerTurnCancellationPolicy =
serverTurnCancellationOption ??
(durable ? "explicit-only" : "on-client-abort");

// Emit deprecation warnings for deprecated options (once per session)
if (manualToolsRequiringConfirmation) {
warnDeprecated(
Expand Down Expand Up @@ -887,6 +913,7 @@ export function useAgentChat<
customTransportRef.current = new WebSocketChatTransport<ChatMessage>({
agent: agentRef.current,
activeRequestIds: localRequestIdsRef.current,
serverTurnCancellation,
prepareBody: async ({ messages: msgs, trigger, messageId }) => {
// Start with the top-level body option (static or dynamic)
let extraBody: Record<string, unknown> = {};
Expand Down Expand Up @@ -928,6 +955,7 @@ export function useAgentChat<
// Always point the transport at the latest socket so sends/listeners
// go through the current connection after _pk changes.
customTransportRef.current.agent = agentRef.current;
customTransportRef.current.setServerTurnCancellation(serverTurnCancellation);
const customTransport = customTransportRef.current;

// Use a stable Chat ID that doesn't change when _pk changes.
Expand Down Expand Up @@ -1020,6 +1048,7 @@ export function useAgentChat<

const stopWithToolContinuationAbort: typeof stop = useCallback(async () => {
try {
customTransport.cancelActiveServerTurn();
await stop();
} finally {
customTransport.abortActiveToolContinuation();
Expand Down
Loading
Loading