diff --git a/.changeset/chat-custom-agent-end-and-continue.md b/.changeset/chat-custom-agent-end-and-continue.md new file mode 100644 index 0000000000..6d7deffeee --- /dev/null +++ b/.changeset/chat-custom-agent-end-and-continue.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Add `chat.endAndContinue()` so fully hand-rolled custom chat agents can hand a conversation off to a fresh run on the latest deployed task version while preserving unconsumed Session input. diff --git a/apps/webapp/test/helpers/testChatAgent.ts b/apps/webapp/test/helpers/testChatAgent.ts index 8aebb713a9..29eab17347 100644 --- a/apps/webapp/test/helpers/testChatAgent.ts +++ b/apps/webapp/test/helpers/testChatAgent.ts @@ -234,6 +234,41 @@ export const testUpgradeOnceChatAgent = chat.agent({ }, }); +/** + * Hands an unconsumed Session input record to a continuation run using the + * public custom-agent lifecycle primitive. The continuation echoes the input + * to `.out`, which lets the full-stack Session E2E assert durable delivery. + */ +export const testEndAndContinueCustomAgent = chat.customAgent({ + id: "e2e-test-chat-custom-end-and-continue", + run: async (payload) => { + if (!payload.continuation) { + await chat.endAndContinue(); + return; + } + + const next = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 2, + timeout: "1m", + }); + if (!next.ok) { + throw next.error; + } + + const message = next.output.message as UIMessage | undefined; + const text = message ? firstText(message) : ""; + const { waitUntilComplete } = chat.stream.writer({ + execute: ({ write }) => { + write({ type: "text-start", id: "handoff-result" }); + write({ type: "text-delta", id: "handoff-result", delta: `received:${text}` }); + write({ type: "text-end", id: "handoff-result" }); + }, + }); + await waitUntilComplete(); + await chat.writeTurnComplete(); + }, +}); + /** * A tool with a server-side `execute`: the agent runs it automatically and * feeds the result back to the model, so a single turn covers the whole diff --git a/apps/webapp/test/session-agent.e2e.test.ts b/apps/webapp/test/session-agent.e2e.test.ts index f6a9a2a338..0690dd5b0a 100644 --- a/apps/webapp/test/session-agent.e2e.test.ts +++ b/apps/webapp/test/session-agent.e2e.test.ts @@ -33,6 +33,7 @@ import { testApprovalChatAgent, testChatAgent, testChatModelLocal, + testEndAndContinueCustomAgent, testEndRunChatAgent, testHitlChatAgent, testHitlIdleChatAgent, @@ -123,6 +124,34 @@ async function setupSession(agentId: string = testChatAgent.id) { return { addressingKey, token, apiKey, baseUrl: server.webapp.baseUrl }; } +async function setupStartedSession(agentId: string) { + const { environment, apiKey } = await seedTestEnvironment(server.prisma); + const addressingKey = `chat-${randomBytes(6).toString("hex")}`; + const createRes = await fetch(`${server.webapp.baseUrl}/api/v1/sessions`, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "chat.agent", + externalId: addressingKey, + taskIdentifier: agentId, + triggerConfig: { basePayload: {} }, + }), + }); + + expect(createRes.ok).toBe(true); + const created = (await createRes.json()) as { + runId: string; + publicAccessToken: string; + }; + return { + ...created, + addressingKey, + apiKey, + environment, + baseUrl: server.webapp.baseUrl, + }; +} + function promptText(prompt: unknown): string { if (!Array.isArray(prompt)) return ""; let out = ""; @@ -1533,4 +1562,97 @@ describe("session agent e2e (real chat.agent loop)", () => { await agent.close(); } }); + + it("EA23: custom endAndContinue hands pending input to a fresh run", async () => { + const { addressingKey, publicAccessToken, runId, apiKey, environment, baseUrl } = + await setupStartedSession(testEndAndContinueCustomAgent.id); + const initialRun = await server.prisma.taskRun.findFirstOrThrow({ + where: { friendlyId: runId }, + select: { id: true }, + }); + + const append = await appendInput({ + baseUrl, + addressingKey, + token: publicAccessToken, + partId: "pending-handoff-input", + body: submitBody( + addressingKey, + userMessage("deliver after endAndContinue", "pending-handoff-input") + ), + }); + expect(append.status).toBe(200); + + const oldRun = runRealChatAgent({ + agentId: testEndAndContinueCustomAgent.id, + baseUrl, + addressingKey, + secretKey: apiKey, + model: textModel("unused"), + modelLocal: testChatModelLocal, + runId, + }); + let continuation: ReturnType | undefined; + + try { + await expect(oldRun.done).resolves.toBeUndefined(); + + const session = await server.prisma.session.findFirstOrThrow({ + where: { runtimeEnvironmentId: environment.id, externalId: addressingKey }, + select: { currentRunId: true, currentRunVersion: true }, + }); + expect(session.currentRunId).not.toBe(initialRun.id); + expect(session.currentRunVersion).toBeGreaterThan(1); + + const successor = await server.prisma.taskRun.findFirstOrThrow({ + where: { id: session.currentRunId! }, + select: { friendlyId: true }, + }); + continuation = runRealChatAgent({ + agentId: testEndAndContinueCustomAgent.id, + baseUrl, + addressingKey, + secretKey: apiKey, + model: textModel("unused"), + modelLocal: testChatModelLocal, + runId: successor.friendlyId, + continuation: true, + previousRunId: runId, + }); + + const { parts } = await collectSessionOut({ + baseUrl, + addressingKey, + token: publicAccessToken, + until: (p) => p.some(isTurnComplete), + maxMs: 30_000, + }); + expect(joinChunks(parts)).toContain("received:deliver after endAndContinue"); + await expect(continuation.done).resolves.toBeUndefined(); + } finally { + await continuation?.close(); + await oldRun.close(); + } + }); + + it("EA24: custom endAndContinue rejects when the server rejects the handoff", async () => { + const { addressingKey, apiKey, baseUrl } = await setupStartedSession( + testEndAndContinueCustomAgent.id + ); + const agent = runRealChatAgent({ + agentId: testEndAndContinueCustomAgent.id, + baseUrl, + addressingKey, + secretKey: apiKey, + model: textModel("unused"), + modelLocal: testChatModelLocal, + runId: "run_missing_end_and_continue", + }); + + try { + await expect(agent.done).rejects.toThrow("callingRunId not found in this environment"); + } finally { + await agent.close(); + } + }); }); diff --git a/docs/ai-chat/custom-agents.mdx b/docs/ai-chat/custom-agents.mdx index 197bff6b5e..c7da25eda4 100644 --- a/docs/ai-chat/custom-agents.mdx +++ b/docs/ai-chat/custom-agents.mdx @@ -144,6 +144,29 @@ for await (const turn of session) { Without this, a resumed chat silently loses its history: the model sees only the message that triggered the continuation. In a hand-rolled loop, seed by passing the stored history into the turn-0 `addIncoming` call — shown in the example below. +### Rotating to a new deployment + +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. + +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: + +```ts +// Detach any chat.messages.on() subscriptions you created. +stop.cleanup(); +await persistMessages(conversation.uiMessages); +await chat.writeTurnComplete(); +await chat.endAndContinue(); +return; +``` + +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`. + +If input has been dispatched to the old run but should be processed by 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 that dispatch would make the continuation resume after the input. + + + `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. + + ### turn.complete() vs manual control `turn.complete(result)` is the one-call path — it handles piping, capturing the response, accumulating messages, cleaning up aborted parts on a stop, and writing the turn-complete chunk. @@ -217,6 +240,7 @@ For full control, skip `createSession` and compose the primitives directly: | `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | | `chat.pipeAndCapture(result)` | Pipe a stream and capture the response; returns `{ message, status, error }` | | `chat.writeTurnComplete()` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors | +| `chat.endAndContinue()` | Hand off the Session to a continuation run; call between turns, then return | | `chat.MessageAccumulator` | Accumulates conversation messages across turns | | `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) | | `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response | diff --git a/docs/ai-chat/patterns/version-upgrades.mdx b/docs/ai-chat/patterns/version-upgrades.mdx index 830f673e9a..cd9c857170 100644 --- a/docs/ai-chat/patterns/version-upgrades.mdx +++ b/docs/ai-chat/patterns/version-upgrades.mdx @@ -1,12 +1,12 @@ --- title: "Version upgrades" sidebarTitle: "Version upgrades" -description: "Gracefully migrate suspended chat agents to a new deployment using chat.requestUpgrade() and the continuation mechanism." +description: "Gracefully migrate chat agents to a new deployment using chat.requestUpgrade(), chat.endAndContinue(), and the continuation mechanism." --- 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. -`chat.requestUpgrade()` lets the agent opt out of the current run so the transport triggers a new one on the latest version. +`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. ## How it works @@ -151,14 +151,32 @@ export const myChat = chat This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code. -## Other agent types +## Custom agents -- **`chat.agent()`** and **`chat.createSession()`** — use `chat.requestUpgrade()` as shown above -- **`chat.customAgent()`** — you control the turn loop, so just `return` from `run()` when you want to exit +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: + +```ts +// Detach any chat.messages.on() subscriptions you created. +stop.cleanup(); +await persistMessages(conversation.uiMessages); +await chat.writeTurnComplete(); +await chat.endAndContinue(); +return; +``` + +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`. + +If input has been dispatched to the old run but should be processed by the continuation, 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 dispatch would cause the continuation to resume past the input. + + + `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. + ## Interaction with recovery boot -`chat.requestUpgrade()` is a graceful exit — the old run returns cleanly, never writing a partial assistant. The new continuation run boots with an empty `session.out` tail and the upgrade-trigger message on `session.in`. The trigger message dispatches as turn 1 on the new version via the normal continuation-wait path. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) does NOT fire on this path — the hook is reserved for mid-stream interruptions (cancel / crash / OOM) where a partial assistant exists on the tail. +When `chat.requestUpgrade()` is handled before a turn starts, the SDK immediately hands the Session to a new run, which processes the same input on the latest version. When it is requested during a turn, including through `chat.createSession()`, the current turn finishes and the old run exits; the next input starts the continuation run. + +Both are graceful exits. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) does not fire — the hook is reserved for mid-stream interruptions (cancel, crash, or OOM) where a partial assistant exists on the tail. ## See also diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index da08f9a047..68812b3c21 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -511,6 +511,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `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)`. | | `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()` | | `chat.requestUpgrade()` | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff. | +| `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. | | `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) | | `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) | | `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime | diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 844d506079..c316af94ff 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -2538,6 +2538,8 @@ const chatOnCompactedKey = locals.create<(event: CompactedEvent) => Promise | void>("chat.onCompacted"); /** @internal Full task `ctx` for the active `chat.agent` run (for hooks invoked from nested compaction). */ const chatAgentRunContextKey = locals.create("chat.agentRunContext"); +/** @internal Marks the root run created by `chat.customAgent()`. */ +const chatCustomAgentRunKey = locals.create("chat.customAgentRun"); const chatPrepareMessagesKey = locals.create<(event: PrepareMessagesEvent) => ModelMessage[] | Promise>( "chat.prepareMessages" @@ -5362,6 +5364,7 @@ function chatCustomAgent< locals.set(chatSessionHandleKey, sessions.open(payload.chatId)); locals.set(chatExternalIdKey, payload.chatId); locals.set(chatAgentRunContextKey, runOptions.ctx); + locals.set(chatCustomAgentRunKey, true); // Initialize the turn-complete trim slot so `chat.writeTurnComplete` // trims `session.out` back to the previous turn boundary. Without // this the slot is undefined and the trim never runs, so `.out` @@ -5456,6 +5459,7 @@ function chatAgent< { signal: runSignal, ctx } ) => { locals.set(chatAgentRunContextKey, ctx); + locals.set(chatCustomAgentRunKey, false); // On AI SDK 7, register the `@ai-sdk/otel` integration (once per process) // so `experimental_telemetry` spans flow into the run trace. Awaited here @@ -8705,6 +8709,59 @@ function requestUpgrade(): void { locals.set(chatUpgradeRequestedKey, true); } +/** + * Hand off the current custom agent Session to a fresh run. + * + * This is the low-level handoff for a fully hand-rolled + * `chat.customAgent()` loop. (Use {@link requestUpgrade} with + * `chat.createSession()` instead.) Call only between turns and after detaching + * input listeners for the old run. If the old run completed its current turn, + * persist its state and call {@link chatWriteTurnComplete} before handing off. + * Do not write a new turn boundary after input that the continuation run should + * process has been dispatched: the boundary acknowledges that input. + * + * The server starts the continuation run but does not stop this run, so return + * from the task immediately after awaiting this function. The promise rejects + * if the server cannot complete the handoff. + * + * Pending Session input that the old run has not consumed remains on the + * durable `.in` stream and is delivered to the continuation run. + * + * @example + * ```ts + * // Detach any chat.messages.on() subscriptions you created. + * await persistMessages(); + * await chat.writeTurnComplete(); + * await chat.endAndContinue(); + * return; + * ``` + */ +async function endAndContinue(): Promise { + if (locals.get(chatCustomAgentRunKey) !== true) { + throw new Error( + "chat.endAndContinue() can only be called from inside a chat.customAgent() run" + ); + } + + await performEndAndContinue(); +} + +/** @internal Shared server handoff used by managed and custom agent loops. */ +async function performEndAndContinue(): Promise { + const chatId = locals.get(chatExternalIdKey); + const callingRunId = locals.get(chatAgentRunContextKey)?.run.id; + + if (!chatId || !callingRunId) { + throw new Error("Cannot end and continue without an active chat agent run"); + } + + const apiClient = apiClientManager.clientOrThrow(); + await apiClient.endAndContinueSession(chatId, { + callingRunId, + reason: "upgrade", + }); +} + /** * Exit the run after the current turn completes, without waiting for the * next message. Unlike {@link requestUpgrade}, no upgrade-required signal @@ -10697,6 +10754,8 @@ export const chat = { isStopped, /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */ requestUpgrade, + /** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */ + endAndContinue, /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */ endRun, /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */ @@ -10891,17 +10950,12 @@ async function writeTurnCompleteChunk( * @internal */ async function writeUpgradeRequiredChunk(): Promise { - const ctx = taskContext.ctx; - const chatId = ctx?.run.id ? getChatIdFromContext() : undefined; - const callingRunId = ctx?.run.id; + const chatId = locals.get(chatExternalIdKey); + const callingRunId = locals.get(chatAgentRunContextKey)?.run.id; if (chatId && callingRunId) { - const apiClient = apiClientManager.clientOrThrow(); try { - await apiClient.endAndContinueSession(chatId, { - callingRunId, - reason: "upgrade", - }); + await performEndAndContinue(); } catch (error) { // Non-fatal: the next `.in/append` re-triggers via the probe. // Swallow rather than throw so we still emit the chunk + exit. @@ -10917,17 +10971,6 @@ async function writeUpgradeRequiredChunk(): Promise { return session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED); } -/** - * Resolves the current chat's `chatId` (used as session externalId) from - * the bound session handle. Returns `undefined` if no agent is bound — - * shouldn't happen at the call sites that invoke - * `writeUpgradeRequiredChunk`, but defensive against misuse. - * @internal - */ -function getChatIdFromContext(): string | undefined { - return locals.get(chatSessionHandleKey)?.id; -} - /** * Extracts the text content of the last user message from a UIMessage array. * Returns undefined if no user message is found. diff --git a/packages/trigger-sdk/test/chat-end-and-continue.test.ts b/packages/trigger-sdk/test/chat-end-and-continue.test.ts new file mode 100644 index 0000000000..fcb43dbcc4 --- /dev/null +++ b/packages/trigger-sdk/test/chat-end-and-continue.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; + +describe("chat.endAndContinue", () => { + it("rejects calls outside a custom agent run", async () => { + await expect(chat.endAndContinue()).rejects.toThrow( + "chat.endAndContinue() can only be called from inside a chat.customAgent() run" + ); + }); +});