Skip to content

Commit da59647

Browse files
committed
docs(chat): clarify endAndContinue lifecycle
1 parent fcefcf5 commit da59647

6 files changed

Lines changed: 91 additions & 31 deletions

File tree

.changeset/chat-custom-agent-end-and-continue.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@trigger.dev/sdk": patch
33
---
44

5-
Allow custom chat agents to hand a conversation off to a fresh run on the latest deployed task version while preserving pending Session input.
5+
Allow custom chat agents to hand a conversation off to a fresh run on the latest deployed task version while preserving unconsumed Session input.

docs/ai-chat/custom-agents.mdx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,25 @@ Without this, a resumed chat silently loses its history: the model sees only the
146146

147147
### Rotating to a new deployment
148148

149-
`chat.createSession()` consumes `chat.requestUpgrade()` through its managed iterator. In a fully hand-rolled custom agent, hand the Session to a fresh run with `chat.endAndContinue()`. Finish the current turn and persist its state first, then make the handoff the last operation in `run()`:
149+
With `chat.createSession()`, use `chat.requestUpgrade()` to leave the current run after the turn. In a fully hand-rolled custom agent, use `chat.endAndContinue()` to immediately hand the Session to a fresh run.
150+
151+
Call it between turns, after detaching the old run's input listeners. If the old run completed its current turn, persist its state and write the turn-complete boundary before the handoff:
150152

151153
```ts
152-
await chat.writeTurnComplete();
154+
messageSubscription.off();
155+
stop.cleanup();
153156
await persistMessages(conversation.uiMessages);
154-
155-
if (shouldRotateToLatestVersion()) {
156-
return chat.endAndContinue();
157-
}
157+
await chat.writeTurnComplete();
158+
await chat.endAndContinue();
159+
return;
158160
```
159161

160162
The server starts a continuation run using the Session's existing trigger configuration and atomically makes it the current run. The Session and its streams stay open, so input that the old run has not consumed remains on `.in` for the continuation run. The new run uses the latest deployed task version unless the Session's trigger configuration sets `lockToVersion`.
161163

164+
If input arrives that the old run should leave for the continuation, detach the listeners and do not write another turn-complete boundary before handing off. `chat.writeTurnComplete()` acknowledges the latest input dispatched to the old run; writing it after receiving the deferred input would make the continuation resume after that input.
165+
162166
<Warning>
163-
Call `chat.endAndContinue()` only at a completed turn boundary, after `chat.writeTurnComplete()` and after detaching the old run's input listeners. The operation starts the new run but does not stop the caller. Await it and return from `run()` immediately; continuing to read or write can race the new run on the same Session.
167+
`chat.endAndContinue()` starts the new run but does not stop the caller. Await it and return from `run()` immediately; continuing to read or write can race the new run on the same Session. If the handoff fails, the promise rejects.
164168
</Warning>
165169

166170
### turn.complete() vs manual control
@@ -236,7 +240,7 @@ For full control, skip `createSession` and compose the primitives directly:
236240
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
237241
| `chat.pipeAndCapture(result)` | Pipe a stream and capture the response; returns `{ message, status, error }` |
238242
| `chat.writeTurnComplete()` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
239-
| `chat.endAndContinue()` | Hand off the Session to a continuation run; call at a completed turn boundary, then return |
243+
| `chat.endAndContinue()` | Hand off the Session to a continuation run; call between turns, then return |
240244
| `chat.MessageAccumulator` | Accumulates conversation messages across turns |
241245
| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
242246
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |

docs/ai-chat/patterns/version-upgrades.mdx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: "Gracefully migrate chat agents to a new deployment using chat.requ
66

77
Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the **old** code. If your deploy includes breaking changes (new tools, changed schemas, updated API contracts), this can cause issues.
88

9-
`chat.requestUpgrade()` lets `chat.agent()` and the `chat.createSession()` iterator opt out of the current run so the transport triggers a new one on the latest version. Fully hand-rolled custom agents use `chat.endAndContinue()` at a completed turn boundary for the same Session handoff.
9+
`chat.requestUpgrade()` is the managed upgrade signal for `chat.agent()` and the `chat.createSession()` iterator. Fully hand-rolled custom agents use `chat.endAndContinue()` between turns to immediately hand the Session to a new run.
1010

1111
## How it works
1212

@@ -153,18 +153,23 @@ This upgrades on **every** deploy, not just breaking changes. Good for fast-movi
153153

154154
## Custom agents
155155

156-
`chat.requestUpgrade()` is consumed by both `chat.agent()` and the `chat.createSession()` iterator. In a fully hand-rolled `chat.customAgent()` task, finish the turn, persist any application state, then call `chat.endAndContinue()` and return immediately:
156+
Use `chat.requestUpgrade()` with `chat.agent()` and the `chat.createSession()` iterator. In a fully hand-rolled `chat.customAgent()` task, detach input listeners, persist the completed turn, write its boundary, then call `chat.endAndContinue()` and return immediately:
157157

158158
```ts
159-
await chat.writeTurnComplete();
159+
messageSubscription.off();
160+
stop.cleanup();
160161
await persistMessages(conversation.uiMessages);
161-
return chat.endAndContinue();
162+
await chat.writeTurnComplete();
163+
await chat.endAndContinue();
164+
return;
162165
```
163166

164167
The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version unless the Session's trigger configuration sets `lockToVersion`.
165168

169+
If input arrives that the continuation should process, detach the old listeners and skip the final `chat.writeTurnComplete()`. A turn-complete boundary acknowledges the latest input dispatched to the old run, so writing one after that input would cause the continuation to resume past it.
170+
166171
<Warning>
167-
`chat.endAndContinue()` starts the successor but does not stop the calling run. Call it only after `chat.writeTurnComplete()` and after detaching the old run's input listeners, then perform no more Session reads or writes and return from the task.
172+
`chat.endAndContinue()` starts the successor but does not stop the calling run. Perform no more Session reads or writes after calling it, and return from the task. If the handoff fails, the promise rejects.
168173
</Warning>
169174

170175
## Interaction with recovery boot

docs/ai-chat/reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
511511
| `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`. |
512512
| `chat.waitForHandover(options)` | Wait for a [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` |
513513
| `chat.requestUpgrade()` | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff. |
514-
| `chat.endAndContinue()` | In a hand-rolled custom agent, hand off the Session to a fresh continuation run. Finish the turn, detach input listeners, call this method, then return immediately. |
514+
| `chat.endAndContinue()` | In a hand-rolled custom agent, hand off the Session to a fresh continuation run. Call between turns after detaching input listeners, then return immediately. The promise rejects if the handoff fails. |
515515
| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
516516
| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |
517517
| `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime |

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8713,22 +8713,27 @@ function requestUpgrade(): void {
87138713
* Hand off the current custom agent Session to a fresh run.
87148714
*
87158715
* This is the low-level handoff for a fully hand-rolled
8716-
* `chat.customAgent()` loop. (`chat.createSession()` consumes
8717-
* {@link requestUpgrade} instead.) Call only after {@link chatWriteTurnComplete}
8718-
* and after detaching input listeners for the old run. The server starts the
8719-
* continuation run but does not stop this run, so return from the task
8720-
* immediately after awaiting this function.
8716+
* `chat.customAgent()` loop. (Use {@link requestUpgrade} with
8717+
* `chat.createSession()` instead.) Call only between turns and after detaching
8718+
* input listeners for the old run. If the old run completed its current turn,
8719+
* persist its state and call {@link chatWriteTurnComplete} before handing off.
8720+
* Do not write a new turn boundary after receiving input that the continuation
8721+
* run should process: the boundary acknowledges the latest dispatched input.
8722+
*
8723+
* The server starts the continuation run but does not stop this run, so return
8724+
* from the task immediately after awaiting this function. The promise rejects
8725+
* if the server cannot complete the handoff.
87218726
*
87228727
* Pending Session input that the old run has not consumed remains on the
87238728
* durable `.in` stream and is delivered to the continuation run.
87248729
*
87258730
* @example
87268731
* ```ts
8732+
* messageSubscription.off();
8733+
* await persistMessages();
87278734
* await chat.writeTurnComplete();
8728-
*
8729-
* if (shouldUpgrade) {
8730-
* return chat.endAndContinue();
8731-
* }
8735+
* await chat.endAndContinue();
8736+
* return;
87328737
* ```
87338738
*/
87348739
async function endAndContinue(): Promise<void> {

packages/trigger-sdk/test/chat-end-and-continue.test.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ const CHAT_ID = "chat-end-and-continue";
1010
const CALLING_RUN_ID = "run_before_handoff";
1111
const CONTINUATION_RUN_ID = "run_after_handoff";
1212

13+
type CustomAgentRun = (
14+
payload: Record<string, unknown>,
15+
options: { ctx: unknown; signal: AbortSignal }
16+
) => Promise<unknown>;
17+
18+
function getCustomAgentRun(id: string): CustomAgentRun {
19+
const taskEntry = resourceCatalog.getTask(id);
20+
if (!taskEntry) {
21+
throw new Error(`Task ${id} was not registered`);
22+
}
23+
24+
return taskEntry.fns.run as CustomAgentRun;
25+
}
26+
1327
class DurableTestSessionStreamManager extends TestSessionStreamManager {
1428
override reset(): void {
1529
// The Session stream outlives either task run. Drop run-local listeners,
@@ -27,7 +41,7 @@ describe("chat.endAndContinue", () => {
2741
vi.restoreAllMocks();
2842
});
2943

30-
it("ends cleanly and leaves pending input for the continuation run", async () => {
44+
it("ends cleanly and leaves unconsumed input for the continuation run", async () => {
3145
let continuationMessage: unknown;
3246

3347
const agent = chat.customAgent({
@@ -49,12 +63,7 @@ describe("chat.endAndContinue", () => {
4963
},
5064
});
5165

52-
const taskEntry = resourceCatalog.getTask(agent.id);
53-
expect(taskEntry).toBeDefined();
54-
const runFn = taskEntry!.fns.run as (
55-
payload: Record<string, unknown>,
56-
options: { ctx: unknown; signal: AbortSignal }
57-
) => Promise<unknown>;
66+
const runFn = getCustomAgentRun(agent.id);
5867

5968
const readSessionStreamRecords = vi.fn(async () => ({ records: [] }));
6069
const endAndContinueSession = vi.fn(async () => ({
@@ -127,6 +136,43 @@ describe("chat.endAndContinue", () => {
127136
}
128137
});
129138

139+
it("rejects when the server handoff fails", async () => {
140+
const agent = chat.customAgent({
141+
id: "end-and-continue-failure-agent",
142+
run: async () => {
143+
return chat.endAndContinue();
144+
},
145+
});
146+
147+
const runFn = getCustomAgentRun(agent.id);
148+
149+
const readSessionStreamRecords = vi.fn(async () => ({ records: [] }));
150+
const endAndContinueSession = vi.fn(async () => {
151+
throw new Error("handoff failed");
152+
});
153+
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
154+
readSessionStreamRecords,
155+
endAndContinueSession,
156+
} as never);
157+
158+
await runInMockTaskContext(
159+
async (drivers) => {
160+
await expect(
161+
runFn(
162+
{ chatId: CHAT_ID, trigger: "preload", metadata: {} },
163+
{ ctx: drivers.ctx, signal: new AbortController().signal }
164+
)
165+
).rejects.toThrow("handoff failed");
166+
},
167+
{ ctx: { run: { id: CALLING_RUN_ID } } }
168+
);
169+
170+
expect(endAndContinueSession).toHaveBeenCalledWith(CHAT_ID, {
171+
callingRunId: CALLING_RUN_ID,
172+
reason: "upgrade",
173+
});
174+
});
175+
130176
it("rejects calls outside a custom agent run", async () => {
131177
const endAndContinueSession = vi.fn();
132178
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({

0 commit comments

Comments
 (0)