From c6c35d59a7608bf0f4d3a982e688080b0a7eb3a8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 20 Aug 2026 09:47:53 +0100 Subject: [PATCH 01/37] test: cover the head-start accumulator seed without hydrateMessages The seed from payload.headStartMessages had no coverage for agents that do not register hydrateMessages, and it reads unreachable: it sits inside if (!hydrateMessages && couldHavePriorState), and couldHavePriorState is false on a head-start run. It does fire, and this pins that. Records the shape a persisting app has to handle, which is the part that actually bites: by onTurnStart the accumulator is already ['user','assistant'], because the warm route's partial is spliced in before the hook, so the incoming user message is not the last one. --- .../trigger-sdk/test/chatHandover.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/trigger-sdk/test/chatHandover.test.ts b/packages/trigger-sdk/test/chatHandover.test.ts index a101b91494f..b1aab99f076 100644 --- a/packages/trigger-sdk/test/chatHandover.test.ts +++ b/packages/trigger-sdk/test/chatHandover.test.ts @@ -632,4 +632,66 @@ describe("chat.handover", () => { await harness.close(); } }); + + it("seeds the accumulator from headStartMessages without hydrateMessages", async () => { + // The hydrate variant above gets the head-start user message through + // `incomingMessages`. Without `hydrateMessages` it arrives only via the + // boot-time seed from `payload.headStartMessages`, so this is the path + // that keeps an app with a display-only transcript from storing an + // answer with no question above it. + // + // Note the shape a persisting app has to handle: by `onTurnStart` the + // accumulator is already ["user", "assistant"], because the warm route's + // partial is spliced in before the hook fires. "The incoming message is + // the last one" is therefore false on this path. + let captured: { roles: string[]; texts: string[] } | undefined; + + const agent = chat.agent({ + id: "test-handover-seed-no-hydrate", + onTurnComplete: async ({ uiMessages }) => { + captured = { + roles: uiMessages.map((m) => m.role), + texts: uiMessages.map((m) => + m.parts + .map((p) => (p.type === "text" ? p.text : "")) + .join("") + ), + }; + }, + run: async ({ messages, signal }) => + streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("should-not-run") }), + }), + messages, + abortSignal: signal, + }), + }); + + const harness = mockChatAgent(agent, { + chatId: "test-handover-seed-no-hydrate", + mode: "handover-prepare", + headStartMessages: [ + { id: "hs-user-1", role: "user", parts: [{ type: "text", text: "say hi" }] }, + ], + }); + + try { + await harness.sendHandover({ + partialAssistantMessage: [ + { role: "assistant", content: [{ type: "text", text: "Hi there." }] }, + ], + messageId: "asst-seed-1", + isFinal: true, + }); + await new Promise((r) => setTimeout(r, 30)); + + expect(captured).toBeDefined(); + expect(captured!.roles).toEqual(["user", "assistant"]); + expect(captured!.texts[0]).toBe("say hi"); + } finally { + await harness.close(); + } + }); + }); From 5fef13fc1f9bfe9d28eaa13f59438aa3fe78929b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 20 Aug 2026 09:48:17 +0100 Subject: [PATCH 02/37] fix(chat): put injected steering messages into the accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drainSteeringQueue used the injected uiMessage for span attributes, the injection-confirmation chunk, the injected-ids set and onInjected — never the accumulator. So the message reached the model and the browser, appeared in neither uiMessages nor newUIMessages, and an app persisting from onTurnComplete never learned it existed. The user steers, the agent obeys, the user reloads, and their instruction is gone from the transcript and from every later turn's context. The asymmetry is the tell: a message that finds no step boundary falls back to becoming its own turn and is accumulated normally. Only the path that worked lost data. Appended at injection time rather than turn end, so the order matches what happened: after the message that started the turn, before the response that answers it. Deduplicated by id, since a boundary can drain more than once. The injection path had no test coverage at all — shouldInject appeared only in ai.ts — because the harness had no way to deliver a message mid-turn. Adds harness.sendPendingMessage() for that, which is also what a customer needs to test steering in their own suite. --- .changeset/steering-messages-accumulator.md | 5 + packages/trigger-sdk/src/v3/ai.ts | 34 +++++ .../src/v3/test/mock-chat-agent.ts | 46 +++++- .../test/steering-accumulator.test.ts | 133 ++++++++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .changeset/steering-messages-accumulator.md create mode 100644 packages/trigger-sdk/test/steering-accumulator.test.ts diff --git a/.changeset/steering-messages-accumulator.md b/.changeset/steering-messages-accumulator.md new file mode 100644 index 00000000000..bc3a4919cf3 --- /dev/null +++ b/.changeset/steering-messages-accumulator.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Steering messages injected mid-answer are now part of the conversation your hooks see. Previously they reached the model and the browser but not `onTurnComplete`, so an app storing its own transcript lost the instruction the answer was shaped by — it vanished from the conversation on reload, and later turns had no record of it. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index bcc70fa9ce0..ff3f68e2422 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -3521,6 +3521,16 @@ type SteeringQueueEntry = { const chatPendingMessagesKey = locals.create("chat.pendingMessages"); /** @internal */ const chatSteeringQueueKey = locals.create("chat.steeringQueue"); + +/** + * This turn's new messages, as `onTurnComplete.newUIMessages` will see them. + * + * Held in locals because `drainSteeringQueue` runs outside the turn closure and + * has to append the messages it injects. Without that, an injected message + * reaches the model and the browser but no hook, so an app persisting from + * `onTurnComplete` never learns it existed. + */ +const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages"); /** @internal — IDs of messages that were successfully injected via prepareStep */ const chatInjectedMessageIdsKey = locals.create>("chat.injectedMessageIds"); /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */ @@ -4224,6 +4234,29 @@ async function drainSteeringQueue( for (const m of claimedUIMessages) injectedIds.add(m.id); } + // Record them as part of the conversation. + // + // The model has them and the browser has them; without this the + // accumulator does not, so they reach neither `uiMessages` nor + // `newUIMessages` on `onTurnComplete` and an app that persists from there + // silently loses the instruction the answer was shaped by. Appending here + // rather than at turn end keeps them in the order they happened: after the + // message that started the turn, before the response that answers it. + // + // De-duplicated by id because a step boundary can drain more than once per + // turn, and because a message that failed to inject falls back to becoming + // its own turn, where it is accumulated the normal way. + const currentUIMessages = locals.get(chatCurrentUIMessagesKey); + const turnNew = locals.get(chatTurnNewUIMessagesKey); + for (const m of uiMessages) { + if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) { + currentUIMessages.push(m); + } + if (turnNew && !turnNew.some((existing) => existing.id === m.id)) { + turnNew.push(m); + } + } + // Write injection confirmation chunk to the stream so the frontend // knows which messages were injected and where in the response. if (injected.length > 0) { @@ -7490,6 +7523,7 @@ function chatAgent< // Track new messages for this turn (user input + assistant response). const turnNewModelMessages: ModelMessage[] = []; const turnNewUIMessages: TUIMessage[] = []; + locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages); // ── Action handling ────────────────────────────────────── // Actions arrive on the same input stream but with diff --git a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts index 63768b9b3f2..e50df390fb5 100644 --- a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts +++ b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts @@ -1,5 +1,5 @@ import type { UIMessage, UIMessageChunk } from "ai"; -import { resourceCatalog } from "@trigger.dev/core/v3"; +import { resourceCatalog, sessionStreams } from "@trigger.dev/core/v3"; import type { LocalsKey } from "@trigger.dev/core/v3"; import { runInMockTaskContext, type MockTaskContextOptions } from "@trigger.dev/core/v3/test"; import { __setSessionOpenImplForTests, __setSessionStartImplForTests } from "../sessions.js"; @@ -186,6 +186,17 @@ export type MockChatAgentHarness = { /** Send a custom action and wait for the next turn-complete. */ sendAction(action: unknown): Promise; + /** + * Deliver a message mid-turn without waiting for it, the way the browser's + * steering path does. With a `pendingMessages` config the agent routes it into + * the steering queue for injection at the next step boundary; without one it + * buffers as the next turn. + * + * Send it while a turn is in flight — start the turn without awaiting it, then + * call this. Awaiting the turn first leaves nothing to steer. + */ + sendPendingMessage(message: UIMessage): Promise; + /** Fire a stop signal. Does not wait for the turn — the task keeps running. */ sendStop(message?: string): Promise; @@ -618,6 +629,39 @@ export function mockChatAgent( }); }, + async sendPendingMessage(message) { + await harnessReady; + + const seqBefore = sessionStreams.lastSeqNum(chatId, "in") ?? -1; + + await sendSessionInput(sessionId, { + kind: "message", + payload: { + message, + chatId, + trigger: "submit-message", + metadata: clientData, + }, + }); + + /** + * Wait for the record to be observable on the channel, not merely for the + * send call to return. A test that continues on the send alone is racing the + * append: the message can still be in flight when the step boundary runs, so + * the injection it was meant to trigger silently does not happen and the test + * passes while proving nothing. + */ + const deadline = Date.now() + 5_000; + while ((sessionStreams.lastSeqNum(chatId, "in") ?? -1) <= seqBefore) { + if (Date.now() > deadline) { + throw new Error( + `sendPendingMessage: append for ${message.id} never landed on session.in` + ); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }, + async sendStop(message) { await harnessReady; await sendSessionInput(sessionId, { kind: "stop", message }); diff --git a/packages/trigger-sdk/test/steering-accumulator.test.ts b/packages/trigger-sdk/test/steering-accumulator.test.ts new file mode 100644 index 00000000000..aec36a14b39 --- /dev/null +++ b/packages/trigger-sdk/test/steering-accumulator.test.ts @@ -0,0 +1,133 @@ +// Import the test harness FIRST — installs the resource catalog so +// `chat.agent()` below registers its task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { simulateReadableStream, stepCountIs, streamText, tool } from "ai"; +import type { UIMessage } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { z } from "zod"; + +const usage = { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, +}; + +function userMessage(text: string, id: string): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +function textOf(message: UIMessage): string { + return message.parts.map((part) => (part.type === "text" ? part.text : "")).join(""); +} + +/** + * Two steps with a tool call in between, so there is a step boundary for the + * steering queue to drain at. Step 1 calls the tool, step 2 answers. + */ +function twoStepModel(onFirstStep: () => Promise) { + let call = 0; + return new MockLanguageModelV3({ + doStream: async () => { + call += 1; + if (call === 1) { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "tool-input-start", id: "c1", toolName: "lookup" }, + { type: "tool-input-delta", id: "c1", delta: "{}" }, + { type: "tool-input-end", id: "c1" }, + { type: "tool-call", toolCallId: "c1", toolName: "lookup", input: "{}" }, + { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage }, + ]; + // Land the steering message while step 1 is streaming, so it is queued + // before the boundary that drains it. + await onFirstStep(); + return { stream: simulateReadableStream({ chunks }) }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "done" }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }, + ], + }), + }; + }, + }); +} + +describe("injected steering messages (TRI-13388)", () => { + it("enter the accumulator, so onTurnComplete can see them", async () => { + let captured: { ui: string[]; newUi: string[] } | undefined; + let injectedCount = 0; + + const send = { fn: async () => {} }; + + const agent = chat.agent({ + id: "steering-accumulator", + tools: { + lookup: tool({ + description: "look something up", + inputSchema: z.object({}), + execute: async () => ({ ok: true }), + }), + }, + pendingMessages: { + shouldInject: ({ steps }) => steps.length > 0, + onInjected: ({ messages }) => { + injectedCount = messages.length; + }, + }, + onTurnComplete: async ({ uiMessages, newUIMessages }) => { + captured = { + ui: uiMessages.map(textOf), + newUi: newUIMessages.map(textOf), + }; + }, + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), + model: twoStepModel(() => send.fn()), + messages, + abortSignal: signal, + stopWhen: stepCountIs(5), + }), + }); + + const harness = mockChatAgent(agent, { chatId: "steering-accumulator" }); + + send.fn = async () => { + await harness.sendPendingMessage(userMessage("actually, only the platform one", "steer-1")); + }; + + try { + await harness.sendMessage(userMessage("summarise every project", "u1")); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // The injection happened — this is the SDK's own bookkeeping. + expect(injectedCount).toBe(1); + + expect(captured).toBeDefined(); + + /** + * The steering message reached the model and the browser. Before this fix it + * reached neither `uiMessages` nor `newUIMessages`, so an app persisting from + * `onTurnComplete` stored an answer shaped by an instruction it never saw, and + * rebuilt the next turn's context without it. + */ + expect(captured!.ui).toContain("actually, only the platform one"); + expect(captured!.newUi).toContain("actually, only the platform one"); + + // And in the order it happened: after the question, before the answer. + expect(captured!.ui.indexOf("actually, only the platform one")).toBeGreaterThan( + captured!.ui.indexOf("summarise every project") + ); + expect(captured!.ui.at(-1)).toBe("done"); + } finally { + await harness.close(); + } + }); +}); From 4b0f52a3114a97157f4b1503ff1e940360087c0f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 20 Aug 2026 09:49:36 +0100 Subject: [PATCH 03/37] fix(chat): persist history an action rolled back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot is written on the turn-complete path, and an action is not a turn — the block literally ends 'if (!isAction)'. So a chat.history mutation from onAction lived only in the running worker's memory. Undo worked while that worker stayed warm, then the next continuation booted from a snapshot still holding the undone exchange and the messages came back. onAction is exactly where the docs tell you to call rollbackTo, so this is the documented path silently not persisting. Writes the snapshot right after the action's override is applied, awaited for the same reason as the turn-complete write: the agent may suspend straight after, and in-flight promises do not reliably survive that. An action has no turn cursor, so the write reuses the last one rather than writing undefined — that would drop the resume point and make the next boot replay from further back to rebuild what it could have read. --- .../persist-action-history-mutations.md | 5 ++ packages/trigger-sdk/src/v3/ai.ts | 73 ++++++++++++++++- .../test/action-snapshot-cursor.test.ts | 80 +++++++++++++++++++ .../trigger-sdk/test/action-snapshot.test.ts | 80 +++++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 .changeset/persist-action-history-mutations.md create mode 100644 packages/trigger-sdk/test/action-snapshot-cursor.test.ts create mode 100644 packages/trigger-sdk/test/action-snapshot.test.ts diff --git a/.changeset/persist-action-history-mutations.md b/.changeset/persist-action-history-mutations.md new file mode 100644 index 00000000000..d29338ba6a0 --- /dev/null +++ b/.changeset/persist-action-history-mutations.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Undo, edit and regenerate now survive a run ending. History rolled back from `onAction` was only kept in the running worker's memory, so the rollback held while that worker stayed warm and then reverted on the next continuation — the undone messages came back, minutes later, with no error. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index ff3f68e2422..f5d932e4f6c 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -6551,6 +6551,16 @@ function chatAgent< // swallow errors internally; the agent stays available either way. const sessionIdForSnapshot = payload.sessionId ?? payload.chatId; let bootSnapshot: ChatSnapshotV1 | undefined; + + /** + * The `lastOutEventId` the most recent snapshot carried. + * + * A snapshot written outside a turn — after an action mutates history — has + * no turn cursor of its own, and writing `undefined` there would drop the + * resume point and make the next boot replay from further back. Retaining it + * keeps an action's write cursor-neutral. + */ + let lastSnapshotOutEventId: string | undefined; let replayedSettled: TUIMessage[] = []; let replayedPartial: TUIMessage | undefined; let replayedPartialRaw: TUIMessage | undefined; @@ -6601,6 +6611,8 @@ function chatAgent< // Without seeding, the new worker would emit no trim on its first // turn (chain self-bootstraps from turn 2), so this is purely an // optimization to keep continuation runs bounded from the first turn. + lastSnapshotOutEventId = bootSnapshot?.lastOutEventId; + if (bootSnapshot?.lastOutEventId !== undefined) { const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10); if (Number.isFinite(seeded)) { @@ -7607,6 +7619,63 @@ function chatAgent< accumulatedUIMessages = [...actionOverride] as TUIMessage[]; accumulatedMessages = await toModelMessages(actionOverride); locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + + /** + * Persist it. An action is not a turn, so it never reaches the + * turn-complete path below where the snapshot is normally + * written — and `onAction` is exactly where `chat.history` + * rollbacks are meant to happen. + * + * Without this the rollback lives only in this worker's + * memory: undo works while the worker stays warm, and the + * next continuation boots from a snapshot that still holds + * the undone exchange, so the undo silently reverts minutes + * later with no error. Awaited for the same reason as the + * turn-complete write — the agent may suspend immediately + * after, and in-flight promises do not reliably survive that. + */ + if (!hydrateMessages) { + try { + await tracer.startActiveSpan( + "snapshot.write", + async () => { + // The resume floor, not the dispatched high-water. An + // action can run while records are still queued, and the + // floor is held back below the earliest of those — writing + // the high-water instead would advance the cursor past + // records a replay still has to recover, losing them. + const snapshotInCursor = chatInputRouter().resumeFloor(); + await writeChatSnapshot(sessionIdForSnapshot, { + version: 1, + savedAt: Date.now(), + messages: accumulatedUIMessages, + lastOutEventId: lastSnapshotOutEventId, + lastInEventId: + snapshotInCursor !== undefined + ? String(snapshotInCursor) + : undefined, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.snapshot.reason": "action", + "chat.messages.count": accumulatedUIMessages.length, + }, + } + ); + } catch (error) { + logger.warn( + "chat.agent: snapshot write after action failed; the mutation may not survive a continuation", + { + error: error instanceof Error ? error.message : String(error), + sessionId: sessionIdForSnapshot, + } + ); + } + } } } else { warnMissingOnActionOnce(); @@ -8683,11 +8752,13 @@ function chatAgent< "snapshot.write", async () => { const snapshotInCursor = chatInputRouter().resumeFloor(); + lastSnapshotOutEventId = + turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId; await writeChatSnapshot(sessionIdForSnapshot, { version: 1, savedAt: Date.now(), messages: accumulatedUIMessages, - lastOutEventId: turnCompleteResult?.lastEventId, + lastOutEventId: lastSnapshotOutEventId, lastInEventId: snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined, }); diff --git a/packages/trigger-sdk/test/action-snapshot-cursor.test.ts b/packages/trigger-sdk/test/action-snapshot-cursor.test.ts new file mode 100644 index 00000000000..8f07db20f68 --- /dev/null +++ b/packages/trigger-sdk/test/action-snapshot-cursor.test.ts @@ -0,0 +1,80 @@ +// Import the test harness FIRST — installs the resource catalog so +// `chat.agent()` below registers its task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { z } from "zod"; + +function textStream(text: string): ReadableStream { + return simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ], + }); +} + +describe("the snapshot an action writes", () => { + it("keeps the resume cursor the last turn established", async () => { + const agent = chat.agent({ + id: "action-snapshot-cursor", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("undo") })]), + onAction: async ({ action }) => { + if (action.type === "undo") chat.history.slice(0, -2); + }, + run: async ({ messages, signal }) => + streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("answer") }), + }), + messages, + abortSignal: signal, + }), + }); + + const harness = mockChatAgent(agent, { chatId: "action-snapshot-cursor" }); + + try { + await harness.sendMessage({ + id: "u1", + role: "user", + parts: [{ type: "text", text: "first" }], + }); + await new Promise((r) => setTimeout(r, 30)); + + const afterTurn = harness.getSnapshot(); + expect(afterTurn?.lastOutEventId).toBeDefined(); + + await harness.sendAction({ type: "undo" }); + await new Promise((r) => setTimeout(r, 30)); + + const afterAction = harness.getSnapshot(); + + /** + * An action has no turn cursor of its own. Writing the snapshot with + * `lastOutEventId: undefined` would drop the resume point the last turn + * established, and the next boot would replay from further back to rebuild + * what it could have read — so an action's write has to be cursor-neutral. + */ + expect(afterAction?.lastOutEventId).toBe(afterTurn?.lastOutEventId); + + // And the mutation itself landed, which is the point of writing at all. + expect(afterAction?.messages ?? []).toEqual([]); + } finally { + await harness.close(); + } + }); +}); diff --git a/packages/trigger-sdk/test/action-snapshot.test.ts b/packages/trigger-sdk/test/action-snapshot.test.ts new file mode 100644 index 00000000000..2445875e816 --- /dev/null +++ b/packages/trigger-sdk/test/action-snapshot.test.ts @@ -0,0 +1,80 @@ +// Import the test harness FIRST — installs the resource catalog so +// `chat.agent()` calls below register their task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { z } from "zod"; + +function textStream(text: string): ReadableStream { + return simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ], + }); +} + +function agentWithUndo(id: string) { + return chat.agent({ + id, + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("undo") })]), + onAction: async ({ action }) => { + if (action.type === "undo") { + // The documented way to roll history back — see /ai-chat/actions. + chat.history.slice(0, -2); + } + }, + run: async ({ messages, signal }) => + streamText({ + model: new MockLanguageModelV3({ doStream: async () => ({ stream: textStream("answer") }) }), + messages, + abortSignal: signal, + }), + }); +} + +describe("snapshot durability of history mutated by an action", () => { + it("persists an undo, so a continuation does not resurrect the undone turn", async () => { + const harness = mockChatAgent(agentWithUndo("action-snapshot-undo"), { + chatId: "action-snapshot-undo", + }); + + try { + await harness.sendMessage({ + id: "u1", + role: "user", + parts: [{ type: "text", text: "first" }], + }); + await new Promise((r) => setTimeout(r, 30)); + + // After a turn the snapshot holds the exchange. + expect(harness.getSnapshot()?.messages.map((m) => m.role)).toEqual(["user", "assistant"]); + + await harness.sendAction({ type: "undo" }); + await new Promise((r) => setTimeout(r, 30)); + + /** + * An action is not a turn, so it never reaches the turn-complete path where + * the snapshot is written. The rollback lives in the accumulator only, and + * the next continuation boots from a snapshot that still holds the undone + * exchange — the user's undo silently reverts, minutes later, with no error. + */ + expect(harness.getSnapshot()?.messages ?? []).toEqual([]); + } finally { + await harness.close(); + } + }); +}); From e399b60e123237043e1e72c1c98ca08c2026973b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 20 Aug 2026 15:25:50 +0100 Subject: [PATCH 04/37] fix(chat): make a response streamed from onAction part of the conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning a StreamTextResult from onAction piped it to the browser and stopped there. The accumulator never saw it, no snapshot recorded it, and actions fire no onTurnComplete — so the user read a good answer that the model had no memory of, and the next turn carried on from the answer regenerate had just replaced. The disagreement between the screen and the conversation was invisible until that next turn contradicted it. The action branch now captures what it pipes, using the pipeChatAndCapture that already existed for exactly this, and appends the message to the accumulator. Persistence beyond the snapshot is still the app's job, since an action fires no turn hook — pipeAndCapture hands back the same message for that. Also folds the snapshot write added for rolled-back history into one helper used by both action paths, so a regenerate that both rolls back and answers writes once rather than twice, and the cursor-preservation rule lives in one place. The two fixes needed each other: with the rollback persisted but the response dropped, a regenerate left the snapshot empty rather than stale — still wrong, just differently. --- .changeset/action-stream-into-conversation.md | 5 + packages/trigger-sdk/src/v3/ai.ts | 160 ++++++++++-------- .../test/action-stream-accumulator.test.ts | 98 +++++++++++ 3 files changed, 196 insertions(+), 67 deletions(-) create mode 100644 .changeset/action-stream-into-conversation.md create mode 100644 packages/trigger-sdk/test/action-stream-accumulator.test.ts diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md new file mode 100644 index 00000000000..a3e6b50ce1c --- /dev/null +++ b/.changeset/action-stream-into-conversation.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of — the next turn carried on from the answer that had just been replaced. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index f5d932e4f6c..e0ed4a36161 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -6561,6 +6561,56 @@ function chatAgent< * keeps an action's write cursor-neutral. */ let lastSnapshotOutEventId: string | undefined; + + /** + * Persist the accumulator outside a turn. + * + * An action is not a turn, so it never reaches the turn-complete path where + * the snapshot is normally written — but it can change the conversation in + * two ways: a `chat.history` mutation, and a response streamed back from + * `onAction`. Both have to survive, and one write at the end of the action + * covers both rather than writing twice for a regenerate that does both. + * + * Cursor-neutral: an action has no turn cursor of its own, and writing + * `undefined` would drop the resume point the last turn established and make + * the next boot replay from further back. + */ + const writeSnapshotOutsideTurn = async (reason: string) => { + if (hydrateMessages) return; + try { + await tracer.startActiveSpan( + "snapshot.write", + async () => { + const snapshotInCursor = chatInputRouter().resumeFloor(); + await writeChatSnapshot(sessionIdForSnapshot, { + version: 1, + savedAt: Date.now(), + messages: accumulatedUIMessages, + lastOutEventId: lastSnapshotOutEventId, + lastInEventId: + snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.snapshot.reason": reason, + "chat.messages.count": accumulatedUIMessages.length, + }, + } + ); + } catch (error) { + logger.warn( + "chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", + { + error: error instanceof Error ? error.message : String(error), + sessionId: sessionIdForSnapshot, + reason, + } + ); + } + }; let replayedSettled: TUIMessage[] = []; let replayedPartial: TUIMessage | undefined; let replayedPartialRaw: TUIMessage | undefined; @@ -7548,6 +7598,13 @@ function chatAgent< // string, or UIMessage from `onAction`. Turn counter // does not advance. let actionStreamResult: unknown = undefined; + /** + * Whether this action changed the conversation, by rolling history + * back or by streaming a response. Drives the single snapshot write + * at the end — an action never reaches the turn-complete path that + * normally does it. + */ + let actionChangedHistory = false; if (isAction) { // Parse and validate the action payload const parsedAction = parseAction @@ -7620,62 +7677,7 @@ function chatAgent< accumulatedMessages = await toModelMessages(actionOverride); locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); - /** - * Persist it. An action is not a turn, so it never reaches the - * turn-complete path below where the snapshot is normally - * written — and `onAction` is exactly where `chat.history` - * rollbacks are meant to happen. - * - * Without this the rollback lives only in this worker's - * memory: undo works while the worker stays warm, and the - * next continuation boots from a snapshot that still holds - * the undone exchange, so the undo silently reverts minutes - * later with no error. Awaited for the same reason as the - * turn-complete write — the agent may suspend immediately - * after, and in-flight promises do not reliably survive that. - */ - if (!hydrateMessages) { - try { - await tracer.startActiveSpan( - "snapshot.write", - async () => { - // The resume floor, not the dispatched high-water. An - // action can run while records are still queued, and the - // floor is held back below the earliest of those — writing - // the high-water instead would advance the cursor past - // records a replay still has to recover, losing them. - const snapshotInCursor = chatInputRouter().resumeFloor(); - await writeChatSnapshot(sessionIdForSnapshot, { - version: 1, - savedAt: Date.now(), - messages: accumulatedUIMessages, - lastOutEventId: lastSnapshotOutEventId, - lastInEventId: - snapshotInCursor !== undefined - ? String(snapshotInCursor) - : undefined, - }); - }, - { - attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", - [SemanticInternalAttributes.COLLAPSED]: true, - "chat.id": currentWirePayload.chatId, - "chat.snapshot.reason": "action", - "chat.messages.count": accumulatedUIMessages.length, - }, - } - ); - } catch (error) { - logger.warn( - "chat.agent: snapshot write after action failed; the mutation may not survive a continuation", - { - error: error instanceof Error ? error.message : String(error), - sessionId: sessionIdForSnapshot, - } - ); - } - } + actionChangedHistory = true; } } else { warnMissingOnActionOnce(); @@ -7959,17 +7961,37 @@ function chatAgent< isUIMessageStreamable(actionStreamResult) ) { try { - const resolvedOptions = resolveUIMessageStreamOptions(); - const uiStream = ( - actionStreamResult as UIMessageStreamable - ).toUIMessageStream({ - ...resolvedOptions, - generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId, - }); - await pipeChat(uiStream, { - signal: combinedSignal, - spanName: "stream response", - }); + /** + * Captured, not just piped. The stream reaching the browser was + * never the problem — the problem was that it stopped there, so + * the user read an answer the accumulator had no record of and + * the next turn contradicted the screen. Worst on regenerate, + * which removes the old answer and used to leave nothing in its + * place. + * + * Persistence beyond the snapshot is still the app's job: an + * action fires no `onTurnComplete`, so an app owning its own + * store has to write the row itself — `chat.pipeAndCapture` + * hands back the same message for that. + */ + const { message: actionResponse } = await pipeChatAndCapture( + actionStreamResult as UIMessageStreamable, + { signal: combinedSignal, spanName: "stream response" } + ); + + if (actionResponse) { + const existingIdx = actionResponse.id + ? accumulatedUIMessages.findIndex((m) => m.id === actionResponse.id) + : -1; + if (existingIdx !== -1) { + accumulatedUIMessages[existingIdx] = actionResponse as TUIMessage; + } else { + accumulatedUIMessages.push(actionResponse as TUIMessage); + } + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + actionChangedHistory = true; + } } catch (error) { if ( error instanceof Error && @@ -7982,6 +8004,10 @@ function chatAgent< } } + if (actionChangedHistory) { + await writeSnapshotOutsideTurn("action"); + } + await writeTurnCompleteChunk(currentWirePayload.chatId); // Don't consume a turn iteration — actions aren't turns. diff --git a/packages/trigger-sdk/test/action-stream-accumulator.test.ts b/packages/trigger-sdk/test/action-stream-accumulator.test.ts new file mode 100644 index 00000000000..b10d830d292 --- /dev/null +++ b/packages/trigger-sdk/test/action-stream-accumulator.test.ts @@ -0,0 +1,98 @@ +// Import the test harness FIRST — installs the resource catalog so +// `chat.agent()` below registers its task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { simulateReadableStream, streamText } from "ai"; +import type { UIMessage } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { z } from "zod"; + +function textStream(text: string): ReadableStream { + return simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ], + }); +} + +function textOf(message: UIMessage): string { + return message.parts.map((part) => (part.type === "text" ? part.text : "")).join(""); +} + +describe("a StreamTextResult returned from onAction (TRI-13378)", () => { + it("becomes part of the conversation, not just something the browser saw", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("regenerated answer") }), + }); + + const agent = chat.agent({ + id: "action-stream-accumulator", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]), + + /** + * The bare shape the docs show: return the stream and let the runtime pipe + * it. The alternative — consuming it with `chat.pipeAndCapture` — is the + * workaround, so testing that instead would prove nothing about this path. + */ + onAction: async ({ action, messages }) => { + if (action.type !== "regenerate") return; + chat.history.slice(0, -1); + return streamText({ model, messages }); + }, + + run: async ({ messages, signal }) => + streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("first answer") }), + }), + messages, + abortSignal: signal, + }), + }); + + const harness = mockChatAgent(agent, { chatId: "action-stream-accumulator" }); + + try { + await harness.sendMessage({ + id: "u1", + role: "user", + parts: [{ type: "text", text: "ask" }], + }); + await new Promise((r) => setTimeout(r, 30)); + + const turn = await harness.sendAction({ type: "regenerate" }); + await new Promise((r) => setTimeout(r, 50)); + + // The browser did see it — that part was never broken. + const streamed = turn.chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); + expect(streamed).toBe("regenerated answer"); + + /** + * And the conversation agrees with the screen. Before the fix the response + * was piped and dropped: absent from the accumulator, absent from the + * snapshot, so the next turn's model context contained the question and the + * *old* answer that regenerate had just removed. + */ + const snapshot = harness.getSnapshot(); + expect(snapshot?.messages.map(textOf)).toEqual(["ask", "regenerated answer"]); + } finally { + await harness.close(); + } + }); +}); From 52bd98d342b303fcc0ea0e56eff00371c10eab51 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 20 Aug 2026 16:42:12 +0100 Subject: [PATCH 05/37] fix(chat): route a system-role injection to the instructions lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat.inject with role 'system' put the message into the conversation, which ai@7 rejects for every provider: standardizePrompt throws before any provider is called. The next turn died with an error chunk reading 'An error occurred.' and persisted an assistant message with no parts, so from the app's side the agent had simply stopped answering. The error message names the fix — use the instructions option — and Instructions is string | SystemModelMessage | Array, so an injected system block has a correct home. It is appended after the base prompt, which keeps the prompt's position for caching and reads as a later amendment. This makes the documented examples right rather than rewriting them to a workaround. It also answers whether trusted mid-conversation context is supportable: it is, and only this way. A message injected as 'user' is untrusted by construction, and a well-aligned model says so and re-derives the answer from tools instead. The docs now state which lane to use for facts and which for directives. A new instruction block changes the cached prefix, so the first call carrying it misses the prompt cache. Only turns that actually injected pay it. --- .changeset/inject-instructions-shape.md | 5 + .changeset/inject-system-to-instructions.md | 5 + docs/ai-chat/background-injection.mdx | 43 ++++- packages/trigger-sdk/src/v3/ai.ts | 91 +++++++++- .../test/inject-system-instructions.test.ts | 171 ++++++++++++++++++ 5 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 .changeset/inject-instructions-shape.md create mode 100644 .changeset/inject-system-to-instructions.md create mode 100644 packages/trigger-sdk/test/inject-system-instructions.test.ts diff --git a/.changeset/inject-instructions-shape.md b/.changeset/inject-instructions-shape.md new file mode 100644 index 00000000000..85129f9cb57 --- /dev/null +++ b/.changeset/inject-instructions-shape.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Injected system context is merged into a single instruction block, so it works on every supported AI SDK version. Note that a cached system prompt gives up its cache entry for as long as an injection is live, since the cached prefix has changed. diff --git a/.changeset/inject-system-to-instructions.md b/.changeset/inject-system-to-instructions.md new file mode 100644 index 00000000000..c28af52c8ce --- /dev/null +++ b/.changeset/inject-system-to-instructions.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +`chat.inject()` with `role: "system"` now works. It previously put the system message into the conversation, which AI SDK 7 rejects for every provider — the next turn died with a generic "An error occurred." and persisted an empty assistant message, so the agent looked like it had simply stopped answering. System-role context is now appended to the model's instructions, which is also the only way to inject context the agent will treat as trusted. diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index f84336ff4de..86e7c227f0f 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -189,9 +189,50 @@ export const myChat = chat.agent({ | **Source** | Backend task code | Frontend user input | | **Triggered by** | Your code (e.g. `onTurnComplete` + `chat.defer()`) | User sending a message during streaming | | **Injection point** | Start of next turn, or next `prepareStep` boundary | Next `prepareStep` boundary only | -| **Message role** | Any (`system`, `user`, `assistant`) | Typically `user` | +| **Message role** | Any — `system` becomes an instruction, others join the conversation (see below) | Typically `user` | | **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook | +## Two lanes: trusted and untrusted + +The role you inject with decides more than position — it decides whether the model +treats the content as trustworthy. + +**`role: "system"` goes to the instructions lane.** The block is appended to the +system instructions for subsequent inference calls, so it carries the same standing +as your system prompt. This is the lane for context the agent should simply believe: +entitlements, plan changes, operational notices. + +It has to work this way. On AI SDK 7 a system message inside `messages` is rejected +for every provider — `standardizePrompt` throws before any provider is called, and +its own advice is to use the instructions option. `Instructions` accepts +`Array`, so the injected block is appended there rather than +smuggled into the transcript. + +Two things worth knowing: + +- A new instruction block changes the cached prefix, so the first call carrying it + misses the prompt cache. Only the turns where something was actually injected pay + that. +- The injected text is merged into a single instruction rather than added as a + second block, because AI SDK 5 rejects an array of system blocks while accepting + one structured block. That means a cached system prompt loses its cache entry for + as long as an injection is live — the prefix changed, so there is nothing to hit. + If you rely on prompt caching, inject sparingly and prefer facts that go stale, so + the injection clears. + +**Any other role joins the conversation, and is untrusted by construction.** A +message injected as `user` is indistinguishable from something the user typed, and a +well-aligned model treats it accordingly — it may say so and re-derive the answer +from tools instead of taking it at face value: + +> "that text arrived embedded in your message, not from a tool I called, so I +> verified it myself rather than trusting it" + +That is correct behaviour, not a bug. So inject **checkable facts** in the +conversational lane and put **directives** in the instructions lane. A conclusion +injected as a user message is the worst of both: the model neither trusts it nor +ignores it, and may contradict it in front of the user. + ## API reference ### chat.inject() diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index e0ed4a36161..2f063a6c9f8 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -46,6 +46,7 @@ import type { FinishReason, LanguageModelUsage, ModelMessage, + SystemModelMessage, ProviderMetadata, Tool, ToolSet, @@ -2695,6 +2696,24 @@ function spliceHandoverPartial( */ const chatBackgroundQueueKey = locals.create("chat.backgroundQueue"); +/** + * System-role context injected mid-conversation, held for the instructions lane. + * + * Kept apart from the message queue because ai@7 rejects a system message inside + * `messages` for every provider — `standardizePrompt` throws upstream of any + * provider call, and its own advice is to use the instructions option. Instructions + * accept `Array`, so a system-role injection has a correct + * home: appended as another system block rather than smuggled into the transcript. + * + * This is also the only way to inject *trusted* context. A message injected as + * `user` is untrusted by construction, and a well-aligned model treats it that + * way — it will say so, and re-derive the answer from tools instead. + */ +const chatInjectedInstructionsKey = locals.create( + "chat.injectedInstructions" +); + + /** * Run-scoped pipe counter. Stored in locals so concurrent runs in the * same worker don't share state. @@ -4691,6 +4710,59 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record`. This package's peer + * range still spans all three, so emitting an array unconditionally would break + * v5 consumers — for whom a system-role injection used to work, since v5 accepted + * a system message inside `messages` that v7 rejects. + * + * So: concatenate into one string when the base is a plain string, which every + * version accepts and which loses nothing (separate blocks only matter for + * per-block `providerOptions`). Use the array form only when the base is already + * a structured message — that path requires v6+ regardless, because it is how + * prompt caching marks the system block, and flattening it would silently throw + * the cache away. + * + * Either way the injected text goes last: the base prompt keeps its position for + * caching, and the addition reads as a later amendment. A changed prefix does + * cost the first call its cache hit, on turns that actually injected. + */ + const injectedInstructions = locals.get(chatInjectedInstructionsKey); + if (injectedInstructions && injectedInstructions.length > 0) { + const injectedText = injectedInstructions + .map((block) => (typeof block.content === "string" ? block.content : "")) + .filter(Boolean) + .join("\n\n"); + + const base = result.system; + + if (base === undefined) { + result.system = injectedText; + } else if (typeof base === "string") { + result.system = [base, injectedText].filter(Boolean).join("\n\n"); + } else { + // Merged into the existing block rather than added as a second one. An array + // of system blocks would keep the base block's cache entry, but ai@5 rejects + // it outright ("Invalid prompt: system must be a string") while accepting a + // single structured block, and this package's peer range still spans v5. + // Choosing per version would mean resolving the installed version at runtime, + // which is not something to build on: `import.meta.url` is illegal in this + // package's CommonJS output, and a bundled task may have no resolvable `ai` + // to read. One shape that works everywhere beats a cache hit. + const baseBlock = base as SystemModelMessage; + result.system = { + ...baseBlock, + content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText] + .filter(Boolean) + .join("\n\n"), + }; + } + } + // Prompt-related options (only if chat.prompt.set() was called) if (prompt) { // Resolve model via registry if both are present @@ -9944,9 +10016,22 @@ function chatDefer(promiseOrFn: Promise | (() => Promise)): vo * ``` */ function injectBackgroundContext(messages: ModelMessage[]): void { - const queue = locals.get(chatBackgroundQueueKey) ?? []; - queue.push(...messages); - locals.set(chatBackgroundQueueKey, queue); + const systemBlocks = messages.filter( + (message): message is SystemModelMessage => message.role === "system" + ); + const conversational = messages.filter((message) => message.role !== "system"); + + if (systemBlocks.length > 0) { + const instructions = locals.get(chatInjectedInstructionsKey) ?? []; + instructions.push(...systemBlocks); + locals.set(chatInjectedInstructionsKey, instructions); + } + + if (conversational.length > 0) { + const queue = locals.get(chatBackgroundQueueKey) ?? []; + queue.push(...conversational); + locals.set(chatBackgroundQueueKey, queue); + } } // --------------------------------------------------------------------------- diff --git a/packages/trigger-sdk/test/inject-system-instructions.test.ts b/packages/trigger-sdk/test/inject-system-instructions.test.ts new file mode 100644 index 00000000000..618055c8055 --- /dev/null +++ b/packages/trigger-sdk/test/inject-system-instructions.test.ts @@ -0,0 +1,171 @@ +// Import the test harness FIRST — installs the resource catalog so +// `chat.agent()` below registers its task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; + +function textStream(text: string): ReadableStream { + return simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ], + }); +} + +describe("chat.inject with a system role (TRI-13380)", () => { + it("goes to the instructions lane instead of poisoning the prompt", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + let injected = false; + + const agent = chat.agent({ + id: "inject-system-instructions", + onBoot: async () => { + chat.prompt.set({ + promptId: "base", + version: 1, + labels: ["local"], + text: "You are a helpful assistant.", + model: undefined, + config: undefined, + toAISDKTelemetry: () => ({ experimental_telemetry: { isEnabled: true, metadata: {} } }), + }); + }, + onTurnComplete: async () => { + if (injected) return; + injected = true; + /** + * The shape every docs example uses. On ai@7 a system message inside + * `messages` is rejected by `standardizePrompt` for every provider, so + * this used to kill the next turn — an error chunk reading "An error + * occurred." and an assistant message with no parts. + */ + chat.inject([{ role: "system", content: "The user just upgraded to Pro." }]); + }, + run: async ({ messages, signal }) => + streamText({ + ...chat.toStreamTextOptions(), + model, + messages, + abortSignal: signal, + }), + }); + + const harness = mockChatAgent(agent, { chatId: "inject-system-instructions" }); + + try { + await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "one" }] }); + await new Promise((r) => setTimeout(r, 40)); + + const turn = await harness.sendMessage({ + id: "u2", + role: "user", + parts: [{ type: "text", text: "two" }], + }); + await new Promise((r) => setTimeout(r, 40)); + + // The turn survives. + const errors = turn.rawChunks.filter((c) => (c as { type?: string })?.type === "error"); + expect(errors).toEqual([]); + + // The injected context arrives as a system block, alongside the base prompt, + // and never as a system message inside `messages`. + const prompt = model.doStreamCalls.at(-1)!.prompt; + const systemBlocks = prompt.filter((m) => m.role === "system"); + const asText = JSON.stringify(systemBlocks); + + expect(asText).toContain("You are a helpful assistant."); + expect(asText).toContain("The user just upgraded to Pro."); + + const nonSystem = prompt.filter((m) => m.role !== "system"); + expect(JSON.stringify(nonSystem)).not.toContain("upgraded to Pro"); + } finally { + await harness.close(); + } + }); + it("emits one system value whether or not the base block is cached", async () => { + /** + * Never an array. ai@6+ accepts `Array` and would let a + * cached base block keep its cache entry, but ai@5 rejects an array outright + * ("Invalid prompt: system must be a string") while accepting a single + * structured block — and the peer range still spans v5. So a plain base + * concatenates into a string, and a cached base absorbs the injection into its + * own content, keeping its provider options. + */ + const shapes: unknown[] = []; + + function agentFor(id: string, cacheControl: boolean) { + let injected = false; + return chat.agent({ + id, + onBoot: async () => { + chat.prompt.set({ + promptId: "base", + version: 1, + labels: ["local"], + text: "Base instructions.", + model: undefined, + config: undefined, + toAISDKTelemetry: () => ({ + experimental_telemetry: { isEnabled: true, metadata: {} }, + }), + }); + }, + onTurnComplete: async () => { + if (injected) return; + injected = true; + chat.inject([{ role: "system", content: "Amendment." }]); + }, + run: async ({ messages, signal }) => { + const options = cacheControl + ? chat.toStreamTextOptions({ cacheControl: { type: "ephemeral" } }) + : chat.toStreamTextOptions(); + shapes.push(Array.isArray(options.system) ? "array" : typeof options.system); + return streamText({ + ...options, + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + } + + for (const [id, cacheControl] of [ + ["shape-plain", false], + ["shape-cached", true], + ] as const) { + const harness = mockChatAgent(agentFor(id, cacheControl), { chatId: id }); + try { + await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "one" }] }); + await new Promise((r) => setTimeout(r, 40)); + await harness.sendMessage({ id: "u2", role: "user", parts: [{ type: "text", text: "two" }] }); + await new Promise((r) => setTimeout(r, 40)); + } finally { + await harness.close(); + } + } + + // [plain turn 1, plain turn 2 (injected), cached turn 1, cached turn 2 (injected)] + expect(shapes).toEqual(["string", "string", "object", "object"]); + }); + +}); From 47f2f8f2322aa9267d14596b5003f8ec58c4bb69 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 15:06:51 +0100 Subject: [PATCH 06/37] fix(chat): address review on the accumulator, instructions and action paths Record only the messages a steering drain actually claimed. The loop used the offered batch, so a record another consumer took while shouldInject() awaited was written into the accumulator for a turn it was never part of. Drain the injected instructions once applied, matching the conversational lane. Left in place they were re-applied by every later toStreamTextOptions() call in the run, growing the prompt and changing its cached prefix each turn. Clean a stopped action's partial response before it is committed, and skip committing at all once the run is cancelled. --- packages/trigger-sdk/src/v3/ai.ts | 18 ++++- .../trigger-sdk/test/action-snapshot.test.ts | 4 +- .../test/action-stream-accumulator.test.ts | 2 +- .../trigger-sdk/test/chatHandover.test.ts | 5 +- .../test/inject-system-instructions.test.ts | 70 ++++++++++++++++++- .../test/steering-accumulator.test.ts | 2 +- 6 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 2f063a6c9f8..5b65d61e2b7 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -2713,7 +2713,6 @@ const chatInjectedInstructionsKey = locals.create( "chat.injectedInstructions" ); - /** * Run-scoped pipe counter. Stored in locals so concurrent runs in the * same worker don't share state. @@ -4267,7 +4266,7 @@ async function drainSteeringQueue( // its own turn, where it is accumulated the normal way. const currentUIMessages = locals.get(chatCurrentUIMessagesKey); const turnNew = locals.get(chatTurnNewUIMessagesKey); - for (const m of uiMessages) { + for (const m of claimedUIMessages) { if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) { currentUIMessages.push(m); } @@ -4734,6 +4733,7 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record 0) { const injectedText = injectedInstructions + .splice(0) .map((block) => (typeof block.content === "string" ? block.content : "")) .filter(Boolean) .join("\n\n"); @@ -8046,11 +8046,23 @@ function chatAgent< * store has to write the row itself — `chat.pipeAndCapture` * hands back the same message for that. */ - const { message: actionResponse } = await pipeChatAndCapture( + const captured = await pipeChatAndCapture( actionStreamResult as UIMessageStreamable, { signal: combinedSignal, spanName: "stream response" } ); + if (runSignal.aborted) return "exit"; + + /** + * A stopped action still commits what streamed, cleaned: + * incomplete tool and text parts left mid-flight are what + * strand the UI on a spinner forever once persisted. + */ + const actionResponse = + captured.status === "complete" || !captured.message + ? captured.message + : cleanupAbortedParts(captured.message); + if (actionResponse) { const existingIdx = actionResponse.id ? accumulatedUIMessages.findIndex((m) => m.id === actionResponse.id) diff --git a/packages/trigger-sdk/test/action-snapshot.test.ts b/packages/trigger-sdk/test/action-snapshot.test.ts index 2445875e816..39e2ecfe280 100644 --- a/packages/trigger-sdk/test/action-snapshot.test.ts +++ b/packages/trigger-sdk/test/action-snapshot.test.ts @@ -39,7 +39,9 @@ function agentWithUndo(id: string) { }, run: async ({ messages, signal }) => streamText({ - model: new MockLanguageModelV3({ doStream: async () => ({ stream: textStream("answer") }) }), + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("answer") }), + }), messages, abortSignal: signal, }), diff --git a/packages/trigger-sdk/test/action-stream-accumulator.test.ts b/packages/trigger-sdk/test/action-stream-accumulator.test.ts index b10d830d292..b57b7de8022 100644 --- a/packages/trigger-sdk/test/action-stream-accumulator.test.ts +++ b/packages/trigger-sdk/test/action-stream-accumulator.test.ts @@ -32,7 +32,7 @@ function textOf(message: UIMessage): string { return message.parts.map((part) => (part.type === "text" ? part.text : "")).join(""); } -describe("a StreamTextResult returned from onAction (TRI-13378)", () => { +describe("a StreamTextResult returned from onAction", () => { it("becomes part of the conversation, not just something the browser saw", async () => { const model = new MockLanguageModelV3({ doStream: async () => ({ stream: textStream("regenerated answer") }), diff --git a/packages/trigger-sdk/test/chatHandover.test.ts b/packages/trigger-sdk/test/chatHandover.test.ts index b1aab99f076..65dd802d26f 100644 --- a/packages/trigger-sdk/test/chatHandover.test.ts +++ b/packages/trigger-sdk/test/chatHandover.test.ts @@ -652,9 +652,7 @@ describe("chat.handover", () => { captured = { roles: uiMessages.map((m) => m.role), texts: uiMessages.map((m) => - m.parts - .map((p) => (p.type === "text" ? p.text : "")) - .join("") + m.parts.map((p) => (p.type === "text" ? p.text : "")).join("") ), }; }, @@ -693,5 +691,4 @@ describe("chat.handover", () => { await harness.close(); } }); - }); diff --git a/packages/trigger-sdk/test/inject-system-instructions.test.ts b/packages/trigger-sdk/test/inject-system-instructions.test.ts index 618055c8055..296b13fb168 100644 --- a/packages/trigger-sdk/test/inject-system-instructions.test.ts +++ b/packages/trigger-sdk/test/inject-system-instructions.test.ts @@ -26,7 +26,7 @@ function textStream(text: string): ReadableStream { }); } -describe("chat.inject with a system role (TRI-13380)", () => { +describe("chat.inject with a system role", () => { it("goes to the instructions lane instead of poisoning the prompt", async () => { const model = new MockLanguageModelV3({ doStream: async () => ({ stream: textStream("ok") }), @@ -155,9 +155,17 @@ describe("chat.inject with a system role (TRI-13380)", () => { ] as const) { const harness = mockChatAgent(agentFor(id, cacheControl), { chatId: id }); try { - await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "one" }] }); + await harness.sendMessage({ + id: "u1", + role: "user", + parts: [{ type: "text", text: "one" }], + }); await new Promise((r) => setTimeout(r, 40)); - await harness.sendMessage({ id: "u2", role: "user", parts: [{ type: "text", text: "two" }] }); + await harness.sendMessage({ + id: "u2", + role: "user", + parts: [{ type: "text", text: "two" }], + }); await new Promise((r) => setTimeout(r, 40)); } finally { await harness.close(); @@ -168,4 +176,60 @@ describe("chat.inject with a system role (TRI-13380)", () => { expect(shapes).toEqual(["string", "string", "object", "object"]); }); + it("applies an injection to the next turn only, not to every later turn", async () => { + /** + * `chat.inject()` is a queue consumed at the next injection opportunity, so + * the instructions lane has to drain like the conversational one does. Left + * undrained, every later turn in the run repeats every earlier injection — + * the prompt grows without bound and its cached prefix changes each turn. + */ + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + let injectedOnce = false; + + const agent = chat.agent({ + id: "inject-system-drains", + onTurnComplete: async () => { + if (injectedOnce) return; + injectedOnce = true; + chat.inject([{ role: "system", content: "SENTINEL-ONE-SHOT" }]); + }, + run: async ({ messages, signal }) => + streamText({ + ...chat.toStreamTextOptions(), + model, + messages, + abortSignal: signal, + }), + }); + + const harness = mockChatAgent(agent, { chatId: "inject-system-drains" }); + + try { + await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "one" }] }); + await new Promise((r) => setTimeout(r, 40)); + + await harness.sendMessage({ id: "u2", role: "user", parts: [{ type: "text", text: "two" }] }); + await new Promise((r) => setTimeout(r, 40)); + + await harness.sendMessage({ + id: "u3", + role: "user", + parts: [{ type: "text", text: "three" }], + }); + await new Promise((r) => setTimeout(r, 40)); + + const systemOf = (i: number) => + JSON.stringify(model.doStreamCalls[i]!.prompt.filter((m) => m.role === "system")); + + // Turn 1 injected nothing yet, turn 2 carries it, turn 3 must not repeat it. + expect(systemOf(0)).not.toContain("SENTINEL-ONE-SHOT"); + expect(systemOf(1)).toContain("SENTINEL-ONE-SHOT"); + expect(systemOf(2)).not.toContain("SENTINEL-ONE-SHOT"); + } finally { + await harness.close(); + } + }); }); diff --git a/packages/trigger-sdk/test/steering-accumulator.test.ts b/packages/trigger-sdk/test/steering-accumulator.test.ts index aec36a14b39..41f0e734068 100644 --- a/packages/trigger-sdk/test/steering-accumulator.test.ts +++ b/packages/trigger-sdk/test/steering-accumulator.test.ts @@ -59,7 +59,7 @@ function twoStepModel(onFirstStep: () => Promise) { }); } -describe("injected steering messages (TRI-13388)", () => { +describe("injected steering messages", () => { it("enter the accumulator, so onTurnComplete can see them", async () => { let captured: { ui: string[]; newUi: string[] } | undefined; let injectedCount = 0; From 52772b5ea5f0014bda7ea2caabff14faf5d434b4 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 15:45:20 +0100 Subject: [PATCH 07/37] fix(chat): report a failed action stream instead of committing it as finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pipeChatAndCapture returns a stream failure rather than throwing it, so a mid-stream failure in a response returned from onAction was committed as a complete answer, snapshotted, and followed by a normal turn-complete with no error — the browser saw the stream stop and the next turn built on the truncated text. The partial is still kept; the failure is now surfaced with it. Document that the instructions lane is delivered by chat.toStreamTextOptions(), and that an injection applies to the next inference call only. --- docs/ai-chat/background-injection.mdx | 12 +++ packages/trigger-sdk/src/v3/ai.ts | 9 +++ .../test/action-stream-accumulator.test.ts | 74 +++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index 86e7c227f0f..e2760b80dd1 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -208,8 +208,20 @@ its own advice is to use the instructions option. `Instructions` accepts `Array`, so the injected block is appended there rather than smuggled into the transcript. + + The instructions lane is delivered by `chat.toStreamTextOptions()`, because that + is the only place the SDK can set `streamText`'s instructions for you. If your + `run()` calls `streamText({ model, messages, abortSignal })` without spreading + `chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the + model. The conversational lane has no such requirement — it arrives through + `messages` either way. + + Two things worth knowing: +- An injection applies to the next inference call only. The lane is drained once + applied, so a block injected in `onTurnComplete` shapes the following turn and is + not repeated on every turn after it. - A new instruction block changes the cached prefix, so the first call carrying it misses the prompt cache. Only the turns where something was actually injected pay that. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 5b65d61e2b7..20d360e844c 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -8076,6 +8076,15 @@ function chatAgent< locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); actionChangedHistory = true; } + + /** + * Reported after the partial is committed, not instead of it. + * `pipeChatAndCapture` returns a stream failure rather than + * throwing, so without this a mid-stream failure writes a + * normal turn-complete and the truncated answer is persisted + * as if it were finished — the next turn then builds on it. + */ + if (captured.status === "error") throw captured.error; } catch (error) { if ( error instanceof Error && diff --git a/packages/trigger-sdk/test/action-stream-accumulator.test.ts b/packages/trigger-sdk/test/action-stream-accumulator.test.ts index b57b7de8022..f3a6234883f 100644 --- a/packages/trigger-sdk/test/action-stream-accumulator.test.ts +++ b/packages/trigger-sdk/test/action-stream-accumulator.test.ts @@ -95,4 +95,78 @@ describe("a StreamTextResult returned from onAction", () => { await harness.close(); } }); + + it("reports a mid-stream failure instead of committing a truncated answer as finished", async () => { + /** + * `pipeChatAndCapture` returns a stream failure as `status: "error"` rather + * than throwing it. Unchecked, the action commits whatever streamed, writes a + * normal turn-complete, and the browser just sees the stream stop — so the + * user reads a half-finished answer presented as complete and the next turn + * builds on it. The partial is still kept, as on the turn path; what changes + * is that the failure is surfaced alongside it. + */ + let stage = 0; + const failsMidStream = new MockLanguageModelV3({ + doStream: async () => ({ + stream: new ReadableStream({ + async pull(controller) { + await new Promise((r) => setTimeout(r, 25)); + if (stage === 0) { + controller.enqueue({ type: "text-start", id: "t1" }); + stage++; + return; + } + if (stage === 1) { + controller.enqueue({ type: "text-delta", id: "t1", delta: "half an answer" }); + stage++; + return; + } + controller.error(new Error("provider exploded mid-stream")); + }, + }), + }), + }); + + const agent = chat.agent({ + id: "action-stream-error", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]), + onAction: async ({ action, messages }) => { + if (action.type !== "regenerate") return; + chat.history.slice(0, -1); + return streamText({ model: failsMidStream, messages }); + }, + run: async ({ messages, signal }) => + streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("first answer") }), + }), + messages, + abortSignal: signal, + }), + }); + + const harness = mockChatAgent(agent, { chatId: "action-stream-error" }); + + try { + await harness.sendMessage({ + id: "u1", + role: "user", + parts: [{ type: "text", text: "ask" }], + }); + await new Promise((r) => setTimeout(r, 40)); + + await harness.sendAction({ type: "regenerate" }).catch(() => {}); + await new Promise((r) => setTimeout(r, 300)); + + const errors = (harness.allRawChunks as { type?: string }[]).filter( + (c) => c.type === "error" + ); + expect(errors.length).toBeGreaterThan(0); + + // The partial is still kept rather than discarded. + expect(harness.getSnapshot()?.messages.map(textOf).at(-1)).toContain("half an answer"); + } finally { + await harness.close(); + } + }); }); From c6b0dbd0f4e2231c6bcdbb56d9cbb050934686d8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 09:00:24 +0100 Subject: [PATCH 08/37] docs(chat): note the instructions delivery path and one-shot injection in the changesets --- .changeset/action-stream-into-conversation.md | 2 ++ .changeset/inject-system-to-instructions.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md index a3e6b50ce1c..621a2cb83fb 100644 --- a/.changeset/action-stream-into-conversation.md +++ b/.changeset/action-stream-into-conversation.md @@ -3,3 +3,5 @@ --- A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of — the next turn carried on from the answer that had just been replaced. + +A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. diff --git a/.changeset/inject-system-to-instructions.md b/.changeset/inject-system-to-instructions.md index c28af52c8ce..fe4e4ff1ce9 100644 --- a/.changeset/inject-system-to-instructions.md +++ b/.changeset/inject-system-to-instructions.md @@ -3,3 +3,5 @@ --- `chat.inject()` with `role: "system"` now works. It previously put the system message into the conversation, which AI SDK 7 rejects for every provider — the next turn died with a generic "An error occurred." and persisted an empty assistant message, so the agent looked like it had simply stopped answering. System-role context is now appended to the model's instructions, which is also the only way to inject context the agent will treat as trusted. + +Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it will not receive a system-role injection — the conversational lane has no such requirement. And an injection applies to the next inference call only, rather than repeating on every turn that follows it. From e3318cf4fa286e4b0abe89908e67bc711c23526b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 09:20:47 +0100 Subject: [PATCH 09/37] docs(ai-chat): say what an action persists under each persistence model The actions page said only that persistence was your responsibility inside onAction, which is now wrong for platform-managed agents (the runtime writes the snapshot) and too vague for app-owned ones, where a rollback and a streamed replacement both need storing and there is no onTurnComplete to do it in. --- docs/ai-chat/actions.mdx | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/ai-chat/actions.mdx b/docs/ai-chat/actions.mdx index 956e5090aef..27ea4310414 100644 --- a/docs/ai-chat/actions.mdx +++ b/docs/ai-chat/actions.mdx @@ -70,7 +70,34 @@ onAction: async ({ action, messages }) => { } ``` -This is useful for actions that both mutate state and want a fresh model response (regenerate-from-here, retry-with-different-style). Persistence is your responsibility inside `onAction` itself; you have access to the streamed response object. +This is useful for actions that both mutate state and want a fresh model response (regenerate-from-here, retry-with-different-style). + +### Actions and persistence + +An action is not a turn, so `onTurnComplete` never fires — and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use. + +**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation — a `chat.history` mutation, a response returned from `onAction`, or both — the runtime writes the snapshot, so the change survives the run ending. + +**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history mutation and a returned response both live only in the running worker until you persist them, and a continuation rehydrates from your store, not from what the worker had in memory. `chat.pipeAndCapture` hands you the same assistant message the runtime would have captured: + +```ts +onAction: async ({ action, messages }) => { + if (action.type === "undo") { + chat.history.slice(0, -2); + await db.deleteLastExchange(chatId); // the rollback is yours to persist + } + + if (action.type === "regenerate") { + chat.history.slice(0, -1); + const { message } = await chat.pipeAndCapture( + streamText({ model: anthropic("claude-sonnet-4-5"), messages }) + ); + if (message) await db.saveMessage(message); // and so is the replacement + } +}, +``` + +Returning the stream instead of piping it yourself still works and still reaches the browser — you just have no message to store, so the next run will not know about it. ## Gating actions on HITL state From 02e0a70dca5e083a5abad6c97dd0f5736707da89 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 3 Sep 2026 11:33:12 +0100 Subject: [PATCH 10/37] docs(ai-chat): delete the replaced answer in the regenerate example The example saved the regenerated message without removing the one it replaced, so a linear store would keep both and the next hydration would return the pair. The undo branch already deleted; the regenerate branch now does too, with a note that a history mutation is invisible to your database. --- docs/ai-chat/actions.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/ai-chat/actions.mdx b/docs/ai-chat/actions.mdx index 27ea4310414..ec61ecf8642 100644 --- a/docs/ai-chat/actions.mdx +++ b/docs/ai-chat/actions.mdx @@ -89,14 +89,17 @@ onAction: async ({ action, messages }) => { if (action.type === "regenerate") { chat.history.slice(0, -1); + await db.deleteLastAssistant(chatId); // drop the answer being replaced const { message } = await chat.pipeAndCapture( streamText({ model: anthropic("claude-sonnet-4-5"), messages }) ); - if (message) await db.saveMessage(message); // and so is the replacement + if (message) await db.saveMessage(message); // then store the new one } }, ``` +Mirror each mutation in your store, not just the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert — saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.) + Returning the stream instead of piping it yourself still works and still reaches the browser — you just have no message to store, so the next run will not know about it. ## Gating actions on HITL state From e6618cfa06efe4d7ef1023c40149f50e83facf31 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 3 Sep 2026 11:48:16 +0100 Subject: [PATCH 11/37] docs(ai-chat): style-guide pass on the injection and action sections Drops the banned trivializing words, replaces future tense and "there is" throat-clearing, and removes a "two things" lead-in that sat above three bullets. Merges the two bullets that stated the same prompt-cache fact, and stops claiming the injected block is appended as an array when it is merged into a single instruction. --- docs/ai-chat/actions.mdx | 4 ++-- docs/ai-chat/background-injection.mdx | 19 ++++++------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/docs/ai-chat/actions.mdx b/docs/ai-chat/actions.mdx index ec61ecf8642..c3de65240f5 100644 --- a/docs/ai-chat/actions.mdx +++ b/docs/ai-chat/actions.mdx @@ -98,9 +98,9 @@ onAction: async ({ action, messages }) => { }, ``` -Mirror each mutation in your store, not just the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert — saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.) +Mirror each mutation in your store, not only the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert — saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.) -Returning the stream instead of piping it yourself still works and still reaches the browser — you just have no message to store, so the next run will not know about it. +Returning the stream instead of piping it yourself still works and still reaches the browser — but you have no message to store, so the next run does not know about it. ## Gating actions on HITL state diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index e2760b80dd1..23767529b2f 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -199,14 +199,13 @@ treats the content as trustworthy. **`role: "system"` goes to the instructions lane.** The block is appended to the system instructions for subsequent inference calls, so it carries the same standing -as your system prompt. This is the lane for context the agent should simply believe: +as your system prompt. This is the lane for context the agent should believe: entitlements, plan changes, operational notices. It has to work this way. On AI SDK 7 a system message inside `messages` is rejected for every provider — `standardizePrompt` throws before any provider is called, and -its own advice is to use the instructions option. `Instructions` accepts -`Array`, so the injected block is appended there rather than -smuggled into the transcript. +its own advice is to use the instructions option, so the injected block goes there +rather than into the transcript. The instructions lane is delivered by `chat.toStreamTextOptions()`, because that @@ -217,20 +216,14 @@ smuggled into the transcript. `messages` either way. -Two things worth knowing: - - An injection applies to the next inference call only. The lane is drained once applied, so a block injected in `onTurnComplete` shapes the following turn and is not repeated on every turn after it. -- A new instruction block changes the cached prefix, so the first call carrying it - misses the prompt cache. Only the turns where something was actually injected pay - that. - The injected text is merged into a single instruction rather than added as a second block, because AI SDK 5 rejects an array of system blocks while accepting - one structured block. That means a cached system prompt loses its cache entry for - as long as an injection is live — the prefix changed, so there is nothing to hit. - If you rely on prompt caching, inject sparingly and prefer facts that go stale, so - the injection clears. + one structured block. Merging changes the cached prefix, so a cached system prompt + gets no cache hit for as long as an injection is live. If you rely on prompt + caching, inject sparingly and prefer facts that go stale, so the injection clears. **Any other role joins the conversation, and is untrusted by construction.** A message injected as `user` is indistinguishable from something the user typed, and a From 1f7fb5e21e2a21a500047343320f8287ab28d007 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 3 Sep 2026 11:50:58 +0100 Subject: [PATCH 12/37] docs(ai-chat): drop em dashes from the docs and changesets Recast each one as a comma, colon, parentheses, or two sentences rather than swapping in a hyphen. Also removes a stray "simply", a future tense, and a "had just been replaced" the previous pass missed in the changesets. --- .changeset/action-stream-into-conversation.md | 2 +- .changeset/inject-system-to-instructions.md | 4 ++-- .changeset/persist-action-history-mutations.md | 2 +- .changeset/steering-messages-accumulator.md | 2 +- docs/ai-chat/actions.mdx | 8 ++++---- docs/ai-chat/background-injection.mdx | 8 ++++---- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md index 621a2cb83fb..a1e4a8987c9 100644 --- a/.changeset/action-stream-into-conversation.md +++ b/.changeset/action-stream-into-conversation.md @@ -2,6 +2,6 @@ "@trigger.dev/sdk": patch --- -A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of — the next turn carried on from the answer that had just been replaced. +A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of, and the next turn carried on from the answer it had replaced. A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. diff --git a/.changeset/inject-system-to-instructions.md b/.changeset/inject-system-to-instructions.md index fe4e4ff1ce9..272f8018331 100644 --- a/.changeset/inject-system-to-instructions.md +++ b/.changeset/inject-system-to-instructions.md @@ -2,6 +2,6 @@ "@trigger.dev/sdk": patch --- -`chat.inject()` with `role: "system"` now works. It previously put the system message into the conversation, which AI SDK 7 rejects for every provider — the next turn died with a generic "An error occurred." and persisted an empty assistant message, so the agent looked like it had simply stopped answering. System-role context is now appended to the model's instructions, which is also the only way to inject context the agent will treat as trusted. +`chat.inject()` with `role: "system"` now works. It previously put the system message into the conversation, which AI SDK 7 rejects for every provider: the next turn died with a generic "An error occurred." and persisted an empty assistant message, so the agent looked like it had stopped answering. System-role context is now appended to the model's instructions, which is also the only way to inject context the agent treats as trusted. -Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it will not receive a system-role injection — the conversational lane has no such requirement. And an injection applies to the next inference call only, rather than repeating on every turn that follows it. +Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it does not receive a system-role injection. The conversational lane has no such requirement. And an injection applies to the next inference call only, rather than repeating on every turn that follows it. diff --git a/.changeset/persist-action-history-mutations.md b/.changeset/persist-action-history-mutations.md index d29338ba6a0..c02c644d0bc 100644 --- a/.changeset/persist-action-history-mutations.md +++ b/.changeset/persist-action-history-mutations.md @@ -2,4 +2,4 @@ "@trigger.dev/sdk": patch --- -Undo, edit and regenerate now survive a run ending. History rolled back from `onAction` was only kept in the running worker's memory, so the rollback held while that worker stayed warm and then reverted on the next continuation — the undone messages came back, minutes later, with no error. +Undo, edit and regenerate now survive a run ending. History rolled back from `onAction` was only kept in the running worker's memory, so the rollback held while that worker stayed warm and then reverted on the next continuation. The undone messages came back, minutes later, with no error. diff --git a/.changeset/steering-messages-accumulator.md b/.changeset/steering-messages-accumulator.md index bc3a4919cf3..7ece403dcb8 100644 --- a/.changeset/steering-messages-accumulator.md +++ b/.changeset/steering-messages-accumulator.md @@ -2,4 +2,4 @@ "@trigger.dev/sdk": patch --- -Steering messages injected mid-answer are now part of the conversation your hooks see. Previously they reached the model and the browser but not `onTurnComplete`, so an app storing its own transcript lost the instruction the answer was shaped by — it vanished from the conversation on reload, and later turns had no record of it. +Steering messages injected mid-answer are now part of the conversation your hooks see. Previously they reached the model and the browser but not `onTurnComplete`, so an app storing its own transcript lost the instruction the answer was shaped by. It vanished from the conversation on reload, and later turns had no record of it. diff --git a/docs/ai-chat/actions.mdx b/docs/ai-chat/actions.mdx index c3de65240f5..91e8ab6805c 100644 --- a/docs/ai-chat/actions.mdx +++ b/docs/ai-chat/actions.mdx @@ -74,9 +74,9 @@ This is useful for actions that both mutate state and want a fresh model respons ### Actions and persistence -An action is not a turn, so `onTurnComplete` never fires — and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use. +An action is not a turn, so `onTurnComplete` never fires, and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use. -**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation — a `chat.history` mutation, a response returned from `onAction`, or both — the runtime writes the snapshot, so the change survives the run ending. +**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation (a `chat.history` mutation, a response returned from `onAction`, or both), the runtime writes the snapshot, so the change survives the run ending. **Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history mutation and a returned response both live only in the running worker until you persist them, and a continuation rehydrates from your store, not from what the worker had in memory. `chat.pipeAndCapture` hands you the same assistant message the runtime would have captured: @@ -98,9 +98,9 @@ onAction: async ({ action, messages }) => { }, ``` -Mirror each mutation in your store, not only the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert — saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.) +Mirror each mutation in your store, not only the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert. Saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.) -Returning the stream instead of piping it yourself still works and still reaches the browser — but you have no message to store, so the next run does not know about it. +Returning the stream instead of piping it yourself still works and still reaches the browser, but you have no message to store, so the next run does not know about it. ## Gating actions on HITL state diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index 23767529b2f..1e146a47c6a 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -194,7 +194,7 @@ export const myChat = chat.agent({ ## Two lanes: trusted and untrusted -The role you inject with decides more than position — it decides whether the model +The role you inject with decides more than position. It decides whether the model treats the content as trustworthy. **`role: "system"` goes to the instructions lane.** The block is appended to the @@ -203,7 +203,7 @@ as your system prompt. This is the lane for context the agent should believe: entitlements, plan changes, operational notices. It has to work this way. On AI SDK 7 a system message inside `messages` is rejected -for every provider — `standardizePrompt` throws before any provider is called, and +for every provider. `standardizePrompt` throws before any provider is called, and its own advice is to use the instructions option, so the injected block goes there rather than into the transcript. @@ -212,7 +212,7 @@ rather than into the transcript. is the only place the SDK can set `streamText`'s instructions for you. If your `run()` calls `streamText({ model, messages, abortSignal })` without spreading `chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the - model. The conversational lane has no such requirement — it arrives through + model. The conversational lane has no such requirement: it arrives through `messages` either way. @@ -227,7 +227,7 @@ rather than into the transcript. **Any other role joins the conversation, and is untrusted by construction.** A message injected as `user` is indistinguishable from something the user typed, and a -well-aligned model treats it accordingly — it may say so and re-derive the answer +well-aligned model treats it accordingly, and may say so and re-derive the answer from tools instead of taking it at face value: > "that text arrived embedded in your message, not from a tool I called, so I From 3246d12efdc1ae645e284ac20f03ac1500028489 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 3 Sep 2026 11:52:54 +0100 Subject: [PATCH 13/37] docs(ai-chat): drop the remaining em dashes from the actions and injection pages Covers the prose these pages already had, not only the new sections: the frontmatter descriptions, code comments, the message-role table cell, the injection-point list, and the see-also link descriptions. Each recast as a colon, comma, parentheses, or two sentences. --- docs/ai-chat/actions.mdx | 16 ++++++++-------- docs/ai-chat/background-injection.mdx | 26 +++++++++++++------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/ai-chat/actions.mdx b/docs/ai-chat/actions.mdx index 91e8ab6805c..a3b5787b71e 100644 --- a/docs/ai-chat/actions.mdx +++ b/docs/ai-chat/actions.mdx @@ -1,7 +1,7 @@ --- title: "Actions" sidebarTitle: "Actions" -description: "Custom commands sent from the frontend that mutate chat state without consuming a turn — undo, rollback, edit, regenerate." +description: "Custom commands sent from the frontend that mutate chat state without consuming a turn: undo, rollback, edit, regenerate." --- ## Overview @@ -119,10 +119,10 @@ onAction: async ({ action, messages, signal }) => { ## Sending actions from the frontend ```ts -// Browser — TriggerChatTransport +// Browser: TriggerChatTransport const stream = await transport.sendAction(chatId, { type: "undo" }); -// Server — AgentChat +// Server: AgentChat const stream = await agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" }); ``` @@ -134,8 +134,8 @@ The action payload is validated against `actionSchema` on the backend; invalid a ## See also -- [`chat.history`](/ai-chat/backend#chat-history) — the imperative API actions use to mutate state -- [Sending actions from the frontend](/ai-chat/frontend#sending-actions) — `transport.sendAction` ergonomics -- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — fires before `onAction` when set -- [Branching conversations](/ai-chat/patterns/branching-conversations) — pairs action handlers with backend-controlled history -- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) — gating fresh actions while a tool is waiting +- [`chat.history`](/ai-chat/backend#chat-history): the imperative API actions use to mutate state +- [Sending actions from the frontend](/ai-chat/frontend#sending-actions): `transport.sendAction` ergonomics +- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages): fires before `onAction` when set +- [Branching conversations](/ai-chat/patterns/branching-conversations): pairs action handlers with backend-controlled history +- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): gating fresh actions while a tool is waiting diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index 1e146a47c6a..8db814032f8 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -1,14 +1,14 @@ --- title: "Background injection" sidebarTitle: "Background injection" -description: "Inject context from background work into the agent's conversation — self-review, RAG augmentation, or any async analysis." +description: "Inject context from background work into the agent's conversation: self-review, RAG augmentation, or any async analysis." --- ## Overview `chat.inject()` queues model messages for injection into the conversation. Messages are picked up at the start of the next turn or at the next `prepareStep` boundary (between tool-call steps). -This is the backend counterpart to [pending messages](/ai-chat/pending-messages) — pending messages come from the user via the frontend, while `chat.inject()` comes from your task code. +This is the backend counterpart to [pending messages](/ai-chat/pending-messages). Pending messages come from the user via the frontend, while `chat.inject()` comes from your task code. ## Basic usage @@ -34,7 +34,7 @@ The most powerful pattern combines `chat.defer()` (background work) with `chat.i export const myChat = chat.agent({ id: "my-chat", onTurnComplete: async ({ messages }) => { - // Kick off background analysis — doesn't block the turn + // Kick off background analysis, doesn't block the turn chat.defer( (async () => { const analysis = await analyzeConversation(messages); @@ -150,7 +150,7 @@ export const myChat = chat.agent({ }); ``` -The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected — `chat.inject()` persists across the idle wait. +The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected, because `chat.inject()` persists across the idle wait. ## Other use cases @@ -161,13 +161,13 @@ The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If t ## `chat.defer` standalone -`chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication — analytics, audit logs, search-index writes, cache warming — can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires. +`chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication (analytics, audit logs, search-index writes, cache warming) can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires. ```ts export const myChat = chat.agent({ id: "my-chat", onTurnStart: async ({ chatId, runId }) => { - // Analytics — fire-and-forget, irrelevant to resume. + // Analytics: fire-and-forget, irrelevant to resume. chat.defer(analytics.track("turn_started", { chatId, runId })); }, run: async ({ messages, signal }) => { @@ -176,10 +176,10 @@ export const myChat = chat.agent({ }); ``` -`chat.defer()` can be called from anywhere during a turn — hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`. +`chat.defer()` can be called from anywhere during a turn: hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`. -**Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence — `onTurnStart`](/ai-chat/patterns/database-persistence#onturnstart). Reserve `chat.defer` for writes whose timing has no resume implication. +**Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence: `onTurnStart`](/ai-chat/patterns/database-persistence#onturnstart). Reserve `chat.defer` for writes whose timing has no resume implication. ## How it differs from pending messages @@ -189,7 +189,7 @@ export const myChat = chat.agent({ | **Source** | Backend task code | Frontend user input | | **Triggered by** | Your code (e.g. `onTurnComplete` + `chat.defer()`) | User sending a message during streaming | | **Injection point** | Start of next turn, or next `prepareStep` boundary | Next `prepareStep` boundary only | -| **Message role** | Any — `system` becomes an instruction, others join the conversation (see below) | Typically `user` | +| **Message role** | Any. `system` becomes an instruction, others join the conversation (see below) | Typically `user` | | **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook | ## Two lanes: trusted and untrusted @@ -246,7 +246,7 @@ ignores it, and may contradict it in front of the user. chat.inject(messages: ModelMessage[]): void ``` -Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns — they are not reset when a new turn starts. +Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns, and are not reset when a new turn starts. **Parameters:** @@ -255,9 +255,9 @@ Queue model messages for injection at the next opportunity. Messages persist acr | `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) | Messages are drained (consumed) when: -1. A new turn starts — before `run()` executes -2. A `prepareStep` boundary is reached — between tool-call steps during streaming +1. A new turn starts, before `run()` executes +2. A `prepareStep` boundary is reached, between tool-call steps during streaming - `chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task — lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs. + `chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task: lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs. From 86d67fa4c1e27ae0a77d5efed8d8ddf2d0ad281c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 3 Sep 2026 12:03:27 +0100 Subject: [PATCH 14/37] docs(chat): warn about the two upgrade hazards in the changesets Both stay patch. The double write only bites code that worked around a lost message, and the silent action completion was the bug it now reports, so neither is new functionality or an API break. The version cannot carry either signal, so the changelog entries name them instead. Also documents sendPendingMessage in the testing harness table, which listed every other send method. --- .changeset/action-stream-into-conversation.md | 2 +- .changeset/steering-messages-accumulator.md | 2 ++ docs/ai-chat/testing.mdx | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md index a1e4a8987c9..3561b2e25a6 100644 --- a/.changeset/action-stream-into-conversation.md +++ b/.changeset/action-stream-into-conversation.md @@ -4,4 +4,4 @@ A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of, and the next turn carried on from the answer it had replaced. -A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. +A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. An action that used to end quietly on a mid-stream failure now surfaces an error to the frontend, so handle it the way you handle a failed turn. diff --git a/.changeset/steering-messages-accumulator.md b/.changeset/steering-messages-accumulator.md index 7ece403dcb8..1769ad00d59 100644 --- a/.changeset/steering-messages-accumulator.md +++ b/.changeset/steering-messages-accumulator.md @@ -3,3 +3,5 @@ --- Steering messages injected mid-answer are now part of the conversation your hooks see. Previously they reached the model and the browser but not `onTurnComplete`, so an app storing its own transcript lost the instruction the answer was shaped by. It vanished from the conversation on reload, and later turns had no record of it. + +If you worked around this by saving steering messages as they arrive, in `pendingMessages.onReceived` for example, that write now duplicates the one you get from `newUIMessages`. Drop it, or skip messages you have already stored. diff --git a/docs/ai-chat/testing.mdx b/docs/ai-chat/testing.mdx index 65e094ed530..a7d0a425d18 100644 --- a/docs/ai-chat/testing.mdx +++ b/docs/ai-chat/testing.mdx @@ -634,6 +634,7 @@ The harness's initial wire payload depends on `mode`: | `sendHandover({ partialAssistantMessage, isFinal?, messageId? })` | Dispatch a `handover` signal — only meaningful when started with `mode: "handover-prepare"`. The agent picks up partial assistant messages and continues the turn. | | `sendHandoverSkip()` | Dispatch a `handover-skip` signal — only meaningful when started with `mode: "handover-prepare"`. The agent exits cleanly without firing turn hooks. | | `sendAction(action)` | Route a custom action through `actionSchema` + `onAction`. | +| `sendPendingMessage(message)` | Append a user message mid-turn without waiting for a turn to complete, so it reaches the running turn as a steering message. Resolves once the record has landed on `session.in`. | | `sendStop(message?)` | Fire a stop signal. Does not wait for the turn — the run's `signal.aborted` becomes `true`. | | `seedSnapshot(snapshot)` | Pre-seed the snapshot read for the next boot. Effective on the next run boot only. | | `seedSessionOutTail(chunks?)` | Pre-seed `session.out` chunks for the next boot's replay. Reduces to settled assistant turns. | From e2737f6cf69a39e2015e9fc9f53c66305357b6a5 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 3 Sep 2026 12:23:09 +0100 Subject: [PATCH 15/37] fix(chat): consume injected instructions per turn, not per options build Draining the lane on read handed the injection to whichever chat.toStreamTextOptions() call ran first and dropped it from the rest. A run() that builds options twice, a classifier pass and then the answer, sent the instruction to nobody if it passed the second one to streamText, with no error anywhere. Consumption is now keyed on the turn, so every build in the turn carries the same instructions and the turn after it carries none. A hand-rolled loop with no turn context still drains on read. --- .changeset/inject-system-to-instructions.md | 2 +- docs/ai-chat/background-injection.mdx | 8 ++- packages/trigger-sdk/src/v3/ai.ts | 28 ++++++++- .../test/inject-system-instructions.test.ts | 57 +++++++++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/.changeset/inject-system-to-instructions.md b/.changeset/inject-system-to-instructions.md index 272f8018331..f5c2e5993b4 100644 --- a/.changeset/inject-system-to-instructions.md +++ b/.changeset/inject-system-to-instructions.md @@ -4,4 +4,4 @@ `chat.inject()` with `role: "system"` now works. It previously put the system message into the conversation, which AI SDK 7 rejects for every provider: the next turn died with a generic "An error occurred." and persisted an empty assistant message, so the agent looked like it had stopped answering. System-role context is now appended to the model's instructions, which is also the only way to inject context the agent treats as trusted. -Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it does not receive a system-role injection. The conversational lane has no such requirement. And an injection applies to the next inference call only, rather than repeating on every turn that follows it. +Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it does not receive a system-role injection. The conversational lane has no such requirement. And an injection applies to the next turn only, rather than repeating on every turn that follows it. Every inference call in that turn sees it, so a `run()` that builds options more than once gets the same instructions each time. diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index 8db814032f8..92fa3dd1336 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -216,9 +216,11 @@ rather than into the transcript. `messages` either way. -- An injection applies to the next inference call only. The lane is drained once - applied, so a block injected in `onTurnComplete` shapes the following turn and is - not repeated on every turn after it. +- An injection applies to the next turn only. A block injected in `onTurnComplete` + shapes the following turn and is cleared after it, so it is not repeated on every + turn from then on. Within that turn it is consumed once rather than once per read, + so a `run()` that builds options more than once sees the same instructions in + every build. - The injected text is merged into a single instruction rather than added as a second block, because AI SDK 5 rejects an array of system blocks while accepting one structured block. Merging changes the cached prefix, so a cached system prompt diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 20d360e844c..1fa3816e6d1 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -2712,6 +2712,10 @@ const chatBackgroundQueueKey = locals.create("chat.backgroundQue const chatInjectedInstructionsKey = locals.create( "chat.injectedInstructions" ); +/** The turn that consumed the instructions lane, so a second read in the same turn still sees it. */ +const chatInstructionsConsumedTurnKey = locals.create( + "chat.injectedInstructionsConsumedTurn" +); /** * Run-scoped pipe counter. Stored in locals so concurrent runs in the @@ -4732,8 +4736,28 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record 0) { - const injectedText = injectedInstructions - .splice(0) + /** + * Consumed once per turn, not once per read. A `run()` that builds options + * twice, a cheap classifier pass and then the answer, has to see the + * injection in both: draining on read hands it to whichever call ran first + * and drops it from the rest without saying so. Outside a turn there is no + * turn to scope that to, so the lane drains on read there instead. + */ + const currentTurn = locals.get(chatTurnContextKey)?.turn; + const consumedTurn = locals.get(chatInstructionsConsumedTurnKey); + + let blocks: SystemModelMessage[]; + if (currentTurn === undefined) { + blocks = injectedInstructions.splice(0); + } else if (consumedTurn !== undefined && consumedTurn !== currentTurn) { + injectedInstructions.length = 0; + blocks = []; + } else { + locals.set(chatInstructionsConsumedTurnKey, currentTurn); + blocks = injectedInstructions; + } + + const injectedText = blocks .map((block) => (typeof block.content === "string" ? block.content : "")) .filter(Boolean) .join("\n\n"); diff --git a/packages/trigger-sdk/test/inject-system-instructions.test.ts b/packages/trigger-sdk/test/inject-system-instructions.test.ts index 296b13fb168..cfbe00bf73c 100644 --- a/packages/trigger-sdk/test/inject-system-instructions.test.ts +++ b/packages/trigger-sdk/test/inject-system-instructions.test.ts @@ -232,4 +232,61 @@ describe("chat.inject with a system role", () => { await harness.close(); } }); + + it("carries the injection into every options build in the turn, not only the first", async () => { + /** + * A `run()` that builds options twice, a classifier pass and then the + * answer, has to see the injection in both. Consuming on read hands it to + * whichever call ran first and drops it from the rest, silently. + */ + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + const seen: { first: boolean; second: boolean }[] = []; + let injectedOnce = false; + + const agent = chat.agent({ + id: "inject-system-two-builds", + onTurnComplete: async () => { + if (injectedOnce) return; + injectedOnce = true; + chat.inject([{ role: "system", content: "SENTINEL-BOTH-BUILDS" }]); + }, + run: async ({ messages, signal }) => { + const first = chat.toStreamTextOptions(); + const second = chat.toStreamTextOptions(); + const has = (o: { system?: unknown }) => + JSON.stringify(o.system ?? null).includes("SENTINEL-BOTH-BUILDS"); + seen.push({ first: has(first), second: has(second) }); + return streamText({ ...second, model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "inject-system-two-builds" }); + + try { + await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "one" }] }); + await new Promise((r) => setTimeout(r, 40)); + + await harness.sendMessage({ id: "u2", role: "user", parts: [{ type: "text", text: "two" }] }); + await new Promise((r) => setTimeout(r, 40)); + + await harness.sendMessage({ + id: "u3", + role: "user", + parts: [{ type: "text", text: "three" }], + }); + await new Promise((r) => setTimeout(r, 40)); + + // Turn 1 predates the injection, turn 2 carries it in both builds, turn 3 is clear again. + expect(seen).toEqual([ + { first: false, second: false }, + { first: true, second: true }, + { first: false, second: false }, + ]); + } finally { + await harness.close(); + } + }); }); From 692c060bdbaf23af705609c520030c6f640a5d5f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 3 Sep 2026 15:02:07 +0100 Subject: [PATCH 16/37] fix(chat): keep an injection made during the turn that consumed the lane Consuming the instructions lane marked the blocks read but left them in it, so an injection made in that turn's onTurnComplete queued behind them and the next turn's clear destroyed both. Turn 1 carried its instruction and every turn after it silently carried none, which is worse than the per-read draining it replaced. The consumed blocks now move to turn-scoped state, so a second options build in the same turn still sees them while the lane holds only what is pending. Also guards the stash lookup: outside a turn both sides of the turn comparison are undefined, so the optional-chained check matched and dereferenced nothing. --- packages/trigger-sdk/src/v3/ai.ts | 62 ++++++++++++------- .../test/inject-system-instructions.test.ts | 54 ++++++++++++++++ 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 1fa3816e6d1..5069d406018 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -2712,10 +2712,18 @@ const chatBackgroundQueueKey = locals.create("chat.backgroundQue const chatInjectedInstructionsKey = locals.create( "chat.injectedInstructions" ); -/** The turn that consumed the instructions lane, so a second read in the same turn still sees it. */ -const chatInstructionsConsumedTurnKey = locals.create( - "chat.injectedInstructionsConsumedTurn" -); +/** + * What a turn already consumed from the instructions lane, so a second + * `toStreamTextOptions()` call in the same turn sees the same blocks. + * + * Consumed blocks are moved here rather than left in the pending lane: leaving + * them there means an injection made during the consumed turn sits behind them, + * and clearing the lane on the next turn destroys both. + */ +const chatInstructionsConsumedKey = locals.create<{ + turn: number; + blocks: SystemModelMessage[]; +}>("chat.injectedInstructionsConsumed"); /** * Run-scoped pipe counter. Stored in locals so concurrent runs in the @@ -4734,28 +4742,34 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record 0) { - /** - * Consumed once per turn, not once per read. A `run()` that builds options - * twice, a cheap classifier pass and then the answer, has to see the - * injection in both: draining on read hands it to whichever call ran first - * and drops it from the rest without saying so. Outside a turn there is no - * turn to scope that to, so the lane drains on read there instead. - */ - const currentTurn = locals.get(chatTurnContextKey)?.turn; - const consumedTurn = locals.get(chatInstructionsConsumedTurnKey); - - let blocks: SystemModelMessage[]; - if (currentTurn === undefined) { - blocks = injectedInstructions.splice(0); - } else if (consumedTurn !== undefined && consumedTurn !== currentTurn) { - injectedInstructions.length = 0; - blocks = []; - } else { - locals.set(chatInstructionsConsumedTurnKey, currentTurn); - blocks = injectedInstructions; + const currentTurn = locals.get(chatTurnContextKey)?.turn; + const consumedThisTurn = + currentTurn === undefined ? undefined : locals.get(chatInstructionsConsumedKey); + + let injectedBlocks: SystemModelMessage[] = []; + if (consumedThisTurn && consumedThisTurn.turn === currentTurn) { + injectedBlocks = consumedThisTurn.blocks; + } else if (injectedInstructions && injectedInstructions.length > 0) { + injectedBlocks = injectedInstructions.splice(0); + if (currentTurn !== undefined) { + locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks }); } + } + + if (injectedBlocks.length > 0) { + const blocks = injectedBlocks; const injectedText = blocks .map((block) => (typeof block.content === "string" ? block.content : "")) diff --git a/packages/trigger-sdk/test/inject-system-instructions.test.ts b/packages/trigger-sdk/test/inject-system-instructions.test.ts index cfbe00bf73c..2126382effe 100644 --- a/packages/trigger-sdk/test/inject-system-instructions.test.ts +++ b/packages/trigger-sdk/test/inject-system-instructions.test.ts @@ -289,4 +289,58 @@ describe("chat.inject with a system role", () => { await harness.close(); } }); + + it("gives each turn only its own injection, over consecutive turns", async () => { + /** + * Consuming the lane has to move the blocks out of it, not mark them read in + * place. Left in place, an injection made during the consumed turn queues + * behind them and the next turn's clear destroys both: turn 1 gets its + * instruction and every turn after it silently gets none. + */ + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + let n = 0; + + const agent = chat.agent({ + id: "inject-system-consecutive", + onTurnComplete: async () => { + n++; + chat.inject([{ role: "system", content: `INJECT-${n}` }]); + }, + run: async ({ messages, signal }) => + streamText({ + ...chat.toStreamTextOptions(), + model, + messages, + abortSignal: signal, + }), + }); + + const harness = mockChatAgent(agent, { chatId: "inject-system-consecutive" }); + + try { + for (const id of ["u1", "u2", "u3", "u4"]) { + await harness.sendMessage({ id, role: "user", parts: [{ type: "text", text: id }] }); + await new Promise((r) => setTimeout(r, 40)); + } + + const injectionsSeenOn = (turn: number) => { + const system = JSON.stringify( + model.doStreamCalls[turn]!.prompt.filter((m) => m.role === "system") + ); + return ["INJECT-1", "INJECT-2", "INJECT-3"].filter((key) => system.includes(key)); + }; + + // Turn 0 predates any injection; after that each turn carries exactly the + // one injected at the end of the turn before it. + expect(injectionsSeenOn(0)).toEqual([]); + expect(injectionsSeenOn(1)).toEqual(["INJECT-1"]); + expect(injectionsSeenOn(2)).toEqual(["INJECT-2"]); + expect(injectionsSeenOn(3)).toEqual(["INJECT-3"]); + } finally { + await harness.close(); + } + }); }); From 900418d92b766c002421df3cd3f0a8fdc4e8348b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 10:08:05 +0100 Subject: [PATCH 17/37] fix(chat): rebuild the model messages after a steering injection The UI and model accumulators are maintained separately, and a drained message was appended to the UI one only. The model saw it through the prepareStep return value, which is per-step, so the model lane never learned it existed and every later turn of the run answered without it while the browser, the snapshot and chat.history.* all still showed it. The drain now marks the model lane stale and it is rebuilt from the UI lane at the end of the turn. Flips the it.fails repro in steering-injection.test.ts to a passing test. --- .changeset/steering-messages-accumulator.md | 2 +- packages/trigger-sdk/src/v3/ai.ts | 33 +++++++++++++++++++ .../test/steering-injection.test.ts | 16 ++++----- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.changeset/steering-messages-accumulator.md b/.changeset/steering-messages-accumulator.md index 1769ad00d59..f207cd7095c 100644 --- a/.changeset/steering-messages-accumulator.md +++ b/.changeset/steering-messages-accumulator.md @@ -2,6 +2,6 @@ "@trigger.dev/sdk": patch --- -Steering messages injected mid-answer are now part of the conversation your hooks see. Previously they reached the model and the browser but not `onTurnComplete`, so an app storing its own transcript lost the instruction the answer was shaped by. It vanished from the conversation on reload, and later turns had no record of it. +Steering messages injected mid-answer are now part of the conversation, both for your hooks and for the model on later turns. Previously they reached the model for the answer they steered and reached the browser, but nothing else: `onTurnComplete` never saw them, so an app storing its own transcript lost the instruction the answer was shaped by, and it vanished from the conversation on reload. The model also forgot the instruction from the next turn onwards, answering as though the message had never been sent, while the chat UI still showed it. If you worked around this by saving steering messages as they arrive, in `pendingMessages.onReceived` for example, that write now duplicates the one you get from `newUIMessages`. Drop it, or skip messages you have already stored. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 5069d406018..089789f29e6 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -3561,6 +3561,19 @@ const chatSteeringQueueKey = locals.create("chat.steeringQ * `onTurnComplete` never learns it existed. */ const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages"); + +/** + * Set when a steering drain appended to the UI accumulator, so the model + * accumulator gets rebuilt from it at the end of the turn. + * + * The two accumulators are maintained separately, and the model one is + * normally advanced by appending each turn's delta. A drained message is + * appended to the UI one but reaches the model only through the `prepareStep` + * return value, which is per-step: without a rebuild the model lane never + * learns the message exists and every later turn of the run answers without + * it, while the browser, the snapshot and `chat.history.*` all still show it. + */ +const chatModelLaneStaleKey = locals.create("chat.modelLaneStale"); /** @internal — IDs of messages that were successfully injected via prepareStep */ const chatInjectedMessageIdsKey = locals.create>("chat.injectedMessageIds"); /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */ @@ -4286,6 +4299,9 @@ async function drainSteeringQueue( turnNew.push(m); } } + if (claimedUIMessages.length > 0) { + locals.set(chatModelLaneStaleKey, true); + } // Write injection confirmation chunk to the stream so the frontend // knows which messages were injected and where in the response. @@ -8600,6 +8616,23 @@ function chatAgent< turnBufferedChunks.length = 0; } + // Bring the model accumulator back in line with the UI one + // after a steering drain. Placed after response accumulation + // and before compaction reads `accumulatedMessages`, and + // outside the `capturedResponseMessage` branches so a turn + // that captured no response is covered too. + if (locals.get(chatModelLaneStaleKey)) { + locals.set(chatModelLaneStaleKey, false); + try { + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + } catch (error) { + logger.warn( + "chat.agent: toModelMessages failed rebuilding after an injection; the injected message will be missing from the next turn", + { error: error instanceof Error ? error.message : String(error) } + ); + } + } + if (runSignal.aborted) return "exit"; // Await deferred background work (e.g. DB writes from onTurnStart) diff --git a/packages/trigger-sdk/test/steering-injection.test.ts b/packages/trigger-sdk/test/steering-injection.test.ts index 12f3dfa0c87..70255bf59c8 100644 --- a/packages/trigger-sdk/test/steering-injection.test.ts +++ b/packages/trigger-sdk/test/steering-injection.test.ts @@ -355,18 +355,14 @@ describe("chat.agent injection claims only its own batch", () => { /** * Whether an injected message survives into the next turn's model context. * - * Recorded here because the deployed QA lane finds the two surfaces disagree: - * a `chat.createSession()` recap in the same run recalls a mid-turn steer, - * while the managed `chat.agent` loop denies it. That difference is - * pre-existing and is the surface-specific half of the accumulator gap. - * - * `it.fails` because the managed path does not carry it: turn 2's prompt comes - * back as the original and the following message only, with the injected one - * absent. Held here so the day that changes is noticed, and so the gap has a - * repro that does not need a deployed environment. + * The UI accumulator and the model accumulator are maintained separately, and + * a drained message used to reach only the first: the browser, the snapshot + * and `chat.history.*` all showed it while every later turn of the run + * answered without it. The model lane is now rebuilt from the UI lane at the + * end of a turn that drained, and this is the repro for it. */ describe("chat.agent injected message in the next turn's context", () => { - it.fails( + it( "carries an injected message into the following turn's prompt", { timeout: 30_000 }, async () => { From 83fe5ef97af99cb1852ec322df14277b8fb60452 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 10:48:38 +0100 Subject: [PATCH 18/37] test(chat): cover the model-lane rebuild on a turn that captures no response A run() that pipes the stream itself skips the auto-pipe, so no onFinish is attached and nothing is captured. The rebuild sits outside both capturedResponseMessage branches for that reason. Gating it on a captured response fails this test and leaves the other one passing. --- .../test/steering-injection.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/trigger-sdk/test/steering-injection.test.ts b/packages/trigger-sdk/test/steering-injection.test.ts index 70255bf59c8..514e285913a 100644 --- a/packages/trigger-sdk/test/steering-injection.test.ts +++ b/packages/trigger-sdk/test/steering-injection.test.ts @@ -444,4 +444,97 @@ describe("chat.agent injected message in the next turn's context", () => { } } ); + + /** + * The same thing for a turn that captures no assistant response. + * + * `run()` piping the stream itself skips the auto-pipe, so no `onFinish` is + * attached and nothing is captured. The rebuild sits outside both + * `capturedResponseMessage` branches for that reason: moving it inside + * either one leaves this turn's model lane without the steer while the + * captured case looks fine. + */ + it( + "carries it into the following turn when the turn captures no response", + { timeout: 30_000 }, + async () => { + const chatId = "inject-next-turn-manual-pipe"; + const toolGate = makeGate(); + let toolEntered = false; + const prompts: string[][] = []; + + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); + + let step = 0; + const recordingModel = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push( + prompt + .filter((m) => m.role === "user") + .flatMap((m) => + Array.isArray(m.content) + ? (m.content as { type: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + : [] + ) + ); + const isToolStep = step++ % 2 === 0; + return { + stream: simulateReadableStream({ + chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), + initialDelayInMs: 10, + chunkDelayInMs: 2, + }), + }; + }, + }); + + const agent = chat.agent({ + id: "steering-injection.next-turn-manual-pipe", + pendingMessages: { shouldInject: () => true }, + run: async ({ messages, signal }) => { + const result = streamText({ + model: recordingModel, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }); + // Piping here rather than returning the result is what leaves the + // turn with no captured response. + await chat.pipe(result.toUIMessageStream(), { signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId }); + try { + const first = harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => toolEntered, "tool entered"); + await sendAndLand(harness, chatId, "steer-me", "u-2"); + toolGate.open(); + await first; + + await waitFor(() => turnCompleteCount(harness) >= 1, "turn 1 complete"); + const promptsAfterTurn1 = prompts.length; + + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built"); + + expect(prompts[promptsAfterTurn1]!).toContain("steer-me"); + } finally { + toolGate.open(); + await harness.close(); + } + } + ); }); From 6683e4b19cdce3ee6c2b06a06f90491aa0b59f7b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 11:50:28 +0100 Subject: [PATCH 19/37] fix(chat): keep a steer in the lanes on the createSession surface drainSteeringQueue reported what it claimed by pushing into a locals array that only chat.agent populates, so on chat.createSession and chat.MessageAccumulator the push was a silent no-op behind its truthiness guard. A mid-turn steer shaped that turn's answer and then existed nowhere: not in the session's uiMessages, not in its modelMessages, and not deferred to its own turn either. The drain now returns what it claimed alongside what to inject, and each surface files it. Adds absorbSteering to the accumulator, used by both of its drain sites. --- .changeset/createsession-steering-lanes.md | 5 + packages/trigger-sdk/src/v3/ai.ts | 64 +++- .../test/createsession-steering-lanes.test.ts | 332 ++++++++++++++++++ 3 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 .changeset/createsession-steering-lanes.md create mode 100644 packages/trigger-sdk/test/createsession-steering-lanes.test.ts diff --git a/.changeset/createsession-steering-lanes.md b/.changeset/createsession-steering-lanes.md new file mode 100644 index 00000000000..bf39c2a4685 --- /dev/null +++ b/.changeset/createsession-steering-lanes.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Steering messages are now kept in the conversation when you drive turns yourself with `chat.createSession()` or `chat.MessageAccumulator`. Previously a message that arrived mid-answer shaped that answer and then existed nowhere: it was missing from `turn.uiMessages`, so an app persisting from there never stored it, missing from `turn.messages`, so every later turn answered as though it had never been sent, and it was not queued as its own turn either. It now lands in both, the same way it does on `chat.agent`. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 089789f29e6..963003a5b9f 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -4158,11 +4158,26 @@ function chatCompactionStep( // Steering queue drain — shared by toStreamTextOptions, session, accumulator // --------------------------------------------------------------------------- +/** What a steering drain produced: what to send now, and what it consumed. */ +type DrainedSteering = { + /** Model messages to add to this step's prompt. */ + injected: ModelMessage[]; + /** The UI messages the drain consumed, for the caller to record. */ + claimed: UIMessage[]; +}; + +const EMPTY_DRAIN: DrainedSteering = { injected: [], claimed: [] }; + /** * Drain the steering queue as a batch. Calls `shouldInject` once with all * pending messages. If it returns true, calls `prepareMessages` once to * transform the batch, then clears the queue. - * Returns the model messages to inject (empty if none). + * Returns the model messages to inject and the UI messages actually claimed. + * + * `claimed` is returned rather than only published to locals because each + * surface files it somewhere different: `chat.agent` has an accumulator in + * locals, while `chat.createSession` keeps its own. Publishing to locals alone + * is silently a no-op for any surface that never set the key. * @internal */ async function drainSteeringQueue( @@ -4170,9 +4185,9 @@ async function drainSteeringQueue( messages: ModelMessage[], steps: CompactionStep[], queueOverride?: SteeringQueueEntry[] -): Promise { +): Promise { const queue = queueOverride ?? locals.get(chatSteeringQueueKey); - if (!queue || queue.length === 0) return []; + if (!queue || queue.length === 0) return EMPTY_DRAIN; const ctx = locals.get(chatTurnContextKey); const stepNumber = steps.length - 1; @@ -4198,7 +4213,7 @@ async function drainSteeringQueue( // Call shouldInject once for the whole batch const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false; - if (!shouldInject) return []; + if (!shouldInject) return EMPTY_DRAIN; const textOfUIMessage = (m: UIMessage) => (m.parts ?? []) @@ -4248,7 +4263,7 @@ async function drainSteeringQueue( if (at !== -1) queue.splice(at, 1); } - if (claimed.length === 0) return []; + if (claimed.length === 0) return EMPTY_DRAIN; /** * Give the claim back if the transform fails. `prepare` is caller code and @@ -4299,7 +4314,7 @@ async function drainSteeringQueue( turnNew.push(m); } } - if (claimedUIMessages.length > 0) { + if (claimedUIMessages.length > 0 && currentUIMessages) { locals.set(chatModelLaneStaleKey, true); } @@ -4344,7 +4359,7 @@ async function drainSteeringQueue( } } - return injected; + return { injected, claimed: claimedUIMessages }; }, { attributes: { @@ -4883,7 +4898,7 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record { + if (claimed.length === 0) return; + let added = false; + for (const m of claimed) { + if (!this.uiMessages.some((existing) => existing.id === m.id)) { + this.uiMessages.push(m); + added = true; + } + } + if (!added) return; + this.modelMessages = await toModelMessages(this.uiMessages); + } + /** * Get and clear unconsumed steering messages. */ @@ -10688,7 +10725,13 @@ class ChatMessageAccumulator { // 2. Pending message injection if (pm && queue.length > 0) { - const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue); + const { injected, claimed } = await drainSteeringQueue( + pm, + resultMessages ?? messages, + steps, + queue + ); + await this.absorbSteering(claimed); if (injected.length > 0) { resultMessages = [...(resultMessages ?? messages), ...injected]; } @@ -11458,12 +11501,13 @@ function createChatSession( } if (sessionPendingMessages) { - const injected = await drainSteeringQueue( + const { injected, claimed } = await drainSteeringQueue( sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue ); + await accumulator.absorbSteering(claimed); if (injected.length > 0) { resultMessages = [...(resultMessages ?? stepMsgs), ...injected]; } diff --git a/packages/trigger-sdk/test/createsession-steering-lanes.test.ts b/packages/trigger-sdk/test/createsession-steering-lanes.test.ts new file mode 100644 index 00000000000..6649da3c124 --- /dev/null +++ b/packages/trigger-sdk/test/createsession-steering-lanes.test.ts @@ -0,0 +1,332 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { sessionStreams } from "@trigger.dev/core/v3"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, stepCountIs, streamText, tool } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { UIMessage } from "ai"; +import { chat } from "../src/v3/ai.js"; + +/** + * `chat.createSession` keeps its own accumulator rather than the one + * `chat.agent` publishes to locals, so the two lanes have to be checked on + * this surface separately. + * + * The steering drain appends claimed messages to + * `locals.get(chatCurrentUIMessagesKey)` behind a truthiness guard, and + * `createSession` never sets that key, so the append is a silent no-op here. + * If that is what happens, a mid-turn steer reaches the model for the answer + * it steered and then disappears from both of the session's own lanes: + * `turn.uiMessages`, which is what an app persists from, and `turn.messages`, + * which is what every later turn sends to the model. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; + +function userMessage(text: string, id: string) { + return { id, role: "user" as const, parts: [{ type: "text" as const, text }] }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +function textChunks(text: string): LanguageModelV3StreamPart[] { + return [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, + ]; +} + +function toolCallChunks(callId: string): LanguageModelV3StreamPart[] { + return [ + { + type: "tool-call", + toolCallId: callId, + toolName: "gate", + input: JSON.stringify({ q: "go" }), + }, + { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage: USAGE }, + ]; +} + +type SeqReader = { lastSeqNum: (chatId: string, dir: "in" | "out") => number | undefined }; + +/** Send and wait for the record to land on the channel, so the steer is claimable. */ +async function sendAndLand( + harness: { sendMessage: (m: ReturnType) => Promise }, + chatId: string, + text: string, + id: string +) { + const seqs = sessionStreams as unknown as SeqReader; + const before = seqs.lastSeqNum(chatId, "in") ?? -1; + void harness.sendMessage(userMessage(text, id)); + await waitFor(() => (seqs.lastSeqNum(chatId, "in") ?? -1) > before, `append ${id}`); +} + +describe("chat.createSession steering across turns", () => { + it("keeps a mid-turn steer in both of the session's own lanes", { timeout: 30_000 }, async () => { + const chatId = "createsession-steer-lanes"; + const toolGate = deferred(); + let toolEntered = false; + + /** Per-turn snapshots of the session's own two lanes. */ + const lanes: { turn: number; ui: string[]; model: string[] }[] = []; + const prompts: string[][] = []; + let turnCount = 0; + + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); + + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push( + prompt + .filter((m) => m.role === "user") + .flatMap((m) => + Array.isArray(m.content) + ? (m.content as { type: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + : [] + ) + ); + const isToolStep = step++ % 2 === 0; + return { + stream: simulateReadableStream({ + chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), + initialDelayInMs: 10, + chunkDelayInMs: 2, + }), + }; + }, + }); + + const textOf = (m: { parts?: unknown[] }) => + ((m.parts ?? []) as { type: string; text?: string }[]) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); + + const modelTextOf = (m: { content: unknown }) => + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? (m.content as { type: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join("") + : ""; + + const agent = chat.customAgent({ + id: "createsession-steer-lanes", + run: async (payload, { signal }) => { + const session = chat.createSession(payload, { + signal, + idleTimeoutInSeconds: 1, + pendingMessages: { shouldInject: () => true }, + }); + + for await (const turn of session) { + const thisTurn = turnCount++; + await turn.complete( + streamText({ + model, + messages: turn.messages, + abortSignal: turn.signal, + prepareStep: turn.prepareStep(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }) + ); + lanes.push({ + turn: thisTurn, + ui: turn.uiMessages.map(textOf), + model: turn.messages.map(modelTextOf), + }); + } + }, + }); + + const harness = mockChatAgent(agent, { chatId }); + + try { + const first = harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => toolEntered, "tool entered"); + await sendAndLand(harness, chatId, "steer-me", "u-2"); + toolGate.resolve(); + await first; + + await waitFor(() => lanes.length >= 1, "turn 1 recorded"); + const promptsAfterTurn1 = prompts.length; + + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built"); + await waitFor(() => lanes.length >= 2, "turn 2 recorded"); + + // The lane an app persists from. + expect(lanes[0]!.ui).toContain("steer-me"); + // The lane every later turn sends to the model. + expect(lanes[1]!.model).toContain("steer-me"); + // And what the model was actually asked on the later turn. + expect(prompts[promptsAfterTurn1]!).toContain("steer-me"); + } finally { + toolGate.resolve(); + await harness.close(); + } + }); +}); + +/** + * The same lane check for a fully manual loop built on + * `chat.MessageAccumulator`. + * + * This is the other accumulator-based drain site, and it files the claimed + * messages through `this` rather than through a captured `accumulator`, so a + * binding mistake there would not show up in the `createSession` test above. + */ +describe("chat.MessageAccumulator steering", () => { + it("records a steer the drain consumed in both of its lanes", { timeout: 30_000 }, async () => { + let toolEntered = false; + const toolGate = deferred(); + const lanes: { ui: string[]; model: string[] }[] = []; + const prompts: string[][] = []; + + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); + + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push( + prompt + .filter((m) => m.role === "user") + .flatMap((m) => + Array.isArray(m.content) + ? (m.content as { type: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + : [] + ) + ); + const isToolStep = step++ % 2 === 0; + return { + stream: simulateReadableStream({ + chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), + initialDelayInMs: 10, + chunkDelayInMs: 2, + }), + }; + }, + }); + + const textOf = (m: { parts?: unknown[] }) => + ((m.parts ?? []) as { type: string; text?: string }[]) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); + + const modelTextOf = (m: { content: unknown }) => + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? (m.content as { type: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join("") + : ""; + + const agent = chat.customAgent({ + id: "accumulator-steer-lanes", + run: async () => { + const conversation = new chat.MessageAccumulator({ + pendingMessages: { shouldInject: () => true }, + }); + const next = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 60, + timeout: "1h", + }); + if (!next.ok) return; + const wire = next.output as { message?: UIMessage; trigger: string }; + const messages = await conversation.addIncoming( + wire.message ? [wire.message] : [], + wire.trigger, + 0 + ); + + const result = streamText({ + model, + messages, + prepareStep: conversation.prepareStep(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }); + + // Steer while the tool holds the turn open, so the drain has a step + // boundary to consume it at. + void (async () => { + await waitFor(() => toolEntered, "tool entered"); + await conversation.steerAsync(userMessage("steer-me", "u-2")); + toolGate.resolve(); + })(); + + const captured = await chat.pipeAndCapture(result); + if (captured.message) await conversation.addResponse(captured.message); + lanes.push({ + ui: conversation.uiMessages.map(textOf), + model: conversation.modelMessages.map(modelTextOf), + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "accumulator-steer-lanes" }); + try { + await harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => lanes.length >= 1, "turn recorded"); + + // The drain put it in the prompt, which is what makes the lane checks meaningful. + expect(prompts.some((p) => p.includes("steer-me"))).toBe(true); + expect(lanes[0]!.ui).toContain("steer-me"); + expect(lanes[0]!.model).toContain("steer-me"); + } finally { + toolGate.resolve(); + await harness.close(); + } + }); +}); From bf822210fcc3a02a5232f3423d13eedcf2a70e57 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 14:18:10 +0100 Subject: [PATCH 20/37] fix(chat): append a steer to the model lane instead of rebuilding it Reconciling the model lane by reconverting the UI lane assumed the UI lane is a superset of it. Compaction breaks that by design: it replaces the model lane with a summary and deliberately leaves the UI lane whole, so any reconversion restored every message the summary had replaced. Reproduced on both surfaces: the next turn was sent the full transcript with no summary, while the steer itself was present, which is what made a steer-presence check pass. The model lane is now only ever appended to. The stale flag becomes the claimed messages themselves, reconciled before the response is appended so the order stays steer-then-answer, and onto whatever the lane holds, summary included. absorbSteering appends to both lanes the same way. --- packages/trigger-sdk/src/v3/ai.ts | 83 ++++---- .../accumulator-steering-compaction.test.ts | 60 ++++++ .../test/steering-compaction-lanes.test.ts | 178 ++++++++++++++++++ 3 files changed, 285 insertions(+), 36 deletions(-) create mode 100644 packages/trigger-sdk/test/accumulator-steering-compaction.test.ts create mode 100644 packages/trigger-sdk/test/steering-compaction-lanes.test.ts diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 963003a5b9f..03697976df2 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -3563,17 +3563,22 @@ const chatSteeringQueueKey = locals.create("chat.steeringQ const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages"); /** - * Set when a steering drain appended to the UI accumulator, so the model - * accumulator gets rebuilt from it at the end of the turn. + * Steering messages a drain consumed that the model accumulator has not been + * given yet. * * The two accumulators are maintained separately, and the model one is * normally advanced by appending each turn's delta. A drained message is * appended to the UI one but reaches the model only through the `prepareStep` - * return value, which is per-step: without a rebuild the model lane never - * learns the message exists and every later turn of the run answers without - * it, while the browser, the snapshot and `chat.history.*` all still show it. + * return value, which is per-step: without this the model lane never learns + * the message exists and every later turn of the run answers without it, + * while the browser, the snapshot and `chat.history.*` all still show it. + * + * Held as the messages rather than a "rebuild me" flag because the model lane + * can only be appended to, never reconstructed. Compaction replaces it with a + * summary and deliberately leaves the UI lane whole, so reconverting the UI + * lane restores every message the summary replaced. */ -const chatModelLaneStaleKey = locals.create("chat.modelLaneStale"); +const chatPendingSteerKey = locals.create("chat.pendingSteer"); /** @internal — IDs of messages that were successfully injected via prepareStep */ const chatInjectedMessageIdsKey = locals.create>("chat.injectedMessageIds"); /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */ @@ -4315,7 +4320,11 @@ async function drainSteeringQueue( } } if (claimedUIMessages.length > 0 && currentUIMessages) { - locals.set(chatModelLaneStaleKey, true); + const pendingSteer = locals.get(chatPendingSteerKey) ?? []; + for (const m of claimedUIMessages) { + if (!pendingSteer.some((existing) => existing.id === m.id)) pendingSteer.push(m); + } + locals.set(chatPendingSteerKey, pendingSteer); } // Write injection confirmation chunk to the stream so the frontend @@ -8543,6 +8552,27 @@ function chatAgent< // Determine if the user stopped generation this turn (not a full run cancel). const wasStopped = stopController.signal.aborted && !runSignal.aborted; + // Give the model accumulator the steering messages the drain + // consumed. Appended, never reconverted from the UI lane, so a + // model-only compaction summary set just above survives; and done + // before the response is appended so the order stays + // steer-then-answer. Outside the `capturedResponseMessage` + // branches below, so a turn that captured no response is covered. + const pendingSteer = locals.get(chatPendingSteerKey); + if (pendingSteer && pendingSteer.length > 0) { + locals.set(chatPendingSteerKey, []); + try { + accumulatedMessages.push( + ...(await toModelMessages(pendingSteer.map(stripProviderMetadata))) + ); + } catch (error) { + logger.warn( + "chat.agent: toModelMessages failed for an injected message; it will be missing from the next turn", + { error: error instanceof Error ? error.message : String(error) } + ); + } + } + // Append the assistant's response (partial or complete) to the accumulator. // The onFinish callback fires even on abort/stop, so partial responses // from stopped generation are captured correctly. @@ -8631,23 +8661,6 @@ function chatAgent< turnBufferedChunks.length = 0; } - // Bring the model accumulator back in line with the UI one - // after a steering drain. Placed after response accumulation - // and before compaction reads `accumulatedMessages`, and - // outside the `capturedResponseMessage` branches so a turn - // that captured no response is covered too. - if (locals.get(chatModelLaneStaleKey)) { - locals.set(chatModelLaneStaleKey, false); - try { - accumulatedMessages = await toModelMessages(accumulatedUIMessages); - } catch (error) { - logger.warn( - "chat.agent: toModelMessages failed rebuilding after an injection; the injected message will be missing from the next turn", - { error: error instanceof Error ? error.message : String(error) } - ); - } - } - if (runSignal.aborted) return "exit"; // Await deferred background work (e.g. DB writes from onTurnStart) @@ -10669,20 +10682,18 @@ class ChatMessageAccumulator { * The drain only puts them in this step's prompt, so without this they * shape one answer and then exist in neither lane: not in `uiMessages`, * which is what an app persists from, and not in `modelMessages`, which is - * what every later turn sends. The UI lane is authoritative and the model - * lane is its conversion, matching how `chat.agent` reconciles the two. + * what every later turn sends. + * + * Both lanes are appended to. The model lane is never reconverted from the + * UI lane, because `compactIfNeeded` replaces it with a summary and leaves + * the UI lane whole: a reconversion would restore everything the summary + * replaced. */ async absorbSteering(claimed: UIMessage[]): Promise { - if (claimed.length === 0) return; - let added = false; - for (const m of claimed) { - if (!this.uiMessages.some((existing) => existing.id === m.id)) { - this.uiMessages.push(m); - added = true; - } - } - if (!added) return; - this.modelMessages = await toModelMessages(this.uiMessages); + const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id)); + if (fresh.length === 0) return; + this.uiMessages.push(...fresh); + this.modelMessages.push(...(await toModelMessages(fresh))); } /** diff --git a/packages/trigger-sdk/test/accumulator-steering-compaction.test.ts b/packages/trigger-sdk/test/accumulator-steering-compaction.test.ts new file mode 100644 index 00000000000..083f0d72779 --- /dev/null +++ b/packages/trigger-sdk/test/accumulator-steering-compaction.test.ts @@ -0,0 +1,60 @@ +import type { UIMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import { chat } from "../src/v3/ai.js"; + +/** + * `chat.MessageAccumulator` compaction is model-only: it replaces + * `modelMessages` with a summary and leaves `uiMessages` whole so the chat can + * still display the conversation. Recording a steer by reconverting + * `modelMessages` from `uiMessages` therefore restores everything the summary + * replaced, on the next steer after any compaction. + * + * Asserted directly on the accumulator: no run, no model, no harness, because + * the whole question is which of its two lanes gets written and how. + */ + +const USAGE = { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + totalTokens: 15, +}; + +const userMessage = (text: string, id: string): UIMessage => + ({ id, role: "user", parts: [{ type: "text", text }] }) as UIMessage; + +const assistantMessage = (text: string, id: string): UIMessage => + ({ id, role: "assistant", parts: [{ type: "text", text }] }) as UIMessage; + +const flatten = (messages: { content: unknown }[]) => JSON.stringify(messages); + +describe("chat.MessageAccumulator steering after compaction", () => { + it("keeps the summary in the model lane when a later steer is absorbed", async () => { + const conversation = new chat.MessageAccumulator({ + compaction: { + shouldCompact: () => true, + summarize: async () => "SUMMARY-OF-EVERYTHING", + }, + }); + + await conversation.addIncoming([userMessage("EARLY-SENTINEL", "u-1")], "submit-message", 0); + await conversation.addResponse(assistantMessage("first answer", "a-1")); + + const didCompact = await conversation.compactIfNeeded(USAGE as never); + expect(didCompact).toBe(true); + + // Compaction is model-only, so the two lanes deliberately disagree here. + expect(flatten(conversation.modelMessages)).toContain("SUMMARY-OF-EVERYTHING"); + expect(flatten(conversation.modelMessages)).not.toContain("EARLY-SENTINEL"); + expect(JSON.stringify(conversation.uiMessages)).toContain("EARLY-SENTINEL"); + + await conversation.absorbSteering([userMessage("steer-me", "u-2")]); + + // The steer has to land in both lanes. + expect(JSON.stringify(conversation.uiMessages)).toContain("steer-me"); + expect(flatten(conversation.modelMessages)).toContain("steer-me"); + // And the compaction has to survive it. Reconverting from the UI lane + // brings the compacted message back and drops the summary. + expect(flatten(conversation.modelMessages)).toContain("SUMMARY-OF-EVERYTHING"); + expect(flatten(conversation.modelMessages)).not.toContain("EARLY-SENTINEL"); + }); +}); diff --git a/packages/trigger-sdk/test/steering-compaction-lanes.test.ts b/packages/trigger-sdk/test/steering-compaction-lanes.test.ts new file mode 100644 index 00000000000..5de7ed4a89d --- /dev/null +++ b/packages/trigger-sdk/test/steering-compaction-lanes.test.ts @@ -0,0 +1,178 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { sessionStreams } from "@trigger.dev/core/v3"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, stepCountIs, streamText, tool } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * Steering and compaction in the same turn. + * + * Compaction is model-only by design: it replaces the model messages with a + * summary and deliberately leaves the UI messages whole, so the chat still + * displays the full conversation. Reconciling the model lane by rebuilding it + * from the UI lane therefore un-compacts it, and the next turn is sent the + * entire pre-compaction transcript. + * + * The assertion that catches this is the absence of an early message, not the + * presence of the steer: a rebuild puts the steer in the prompt too, so a + * steer-presence check passes while compaction has been silently undone. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; + +function userMessage(text: string, id: string) { + return { id, role: "user" as const, parts: [{ type: "text" as const, text }] }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +function textChunks(text: string): LanguageModelV3StreamPart[] { + return [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, + ]; +} + +function toolCallChunks(callId: string): LanguageModelV3StreamPart[] { + return [ + { type: "tool-call", toolCallId: callId, toolName: "gate", input: JSON.stringify({ q: "go" }) }, + { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage: USAGE }, + ]; +} + +type SeqReader = { lastSeqNum: (chatId: string, dir: "in" | "out") => number | undefined }; + +async function sendAndLand( + harness: { sendMessage: (m: ReturnType) => Promise }, + chatId: string, + text: string, + id: string +) { + const seqs = sessionStreams as unknown as SeqReader; + const before = seqs.lastSeqNum(chatId, "in") ?? -1; + void harness.sendMessage(userMessage(text, id)); + await waitFor(() => (seqs.lastSeqNum(chatId, "in") ?? -1) > before, `append ${id}`); +} + +const flatUserTexts = (prompt: { role: string; content: unknown }[]) => + prompt + .filter((m) => m.role === "user") + .flatMap((m) => + Array.isArray(m.content) + ? (m.content as { type: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + : [] + ); + +describe("chat.agent steering with compaction in the same turn", () => { + it("keeps the summary and adds the steer, rather than restoring the transcript", async () => { + const chatId = "steer-compaction"; + const toolGate = deferred(); + let toolEntered = false; + const prompts: string[][] = []; + const allPrompts: string[] = []; + + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); + + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(flatUserTexts(prompt)); + allPrompts.push(JSON.stringify(prompt)); + const isToolStep = step++ % 2 === 0; + return { + stream: simulateReadableStream({ + chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), + initialDelayInMs: 10, + chunkDelayInMs: 2, + }), + }; + }, + }); + + let compacted = 0; + const agent = chat.agent({ + id: "steer-compaction", + pendingMessages: { shouldInject: () => true }, + compaction: { + // Compact once, at the first step boundary of turn 1. + shouldCompact: () => compacted === 0, + summarize: async () => { + compacted++; + return "SUMMARY-OF-EVERYTHING"; + }, + }, + run: async ({ messages, signal }) => + streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }), + }); + + const harness = mockChatAgent(agent, { chatId }); + try { + const first = harness.sendMessage(userMessage("EARLY-SENTINEL", "u-1")); + await waitFor(() => toolEntered, "tool entered"); + await sendAndLand(harness, chatId, "steer-me", "u-2"); + toolGate.resolve(); + await first; + + await waitFor(() => compacted > 0, "compaction ran"); + const promptsAfterTurn1 = prompts.length; + + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built"); + + const turn2 = prompts[promptsAfterTurn1]!; + const turn2Raw = allPrompts[promptsAfterTurn1]!; + + // The steer has to survive. + expect(turn2).toContain("steer-me"); + // And so does the compaction: the summary is what the model gets... + expect(turn2Raw).toContain("SUMMARY-OF-EVERYTHING"); + // ...instead of the message the summary replaced. This is the assertion a + // rebuild-based reconciliation fails. + expect(turn2).not.toContain("EARLY-SENTINEL"); + } finally { + toolGate.resolve(); + await harness.close(); + } + }); +}); From dd067456458a6bf2150912d7e056f0fff60890f7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 14:18:11 +0100 Subject: [PATCH 21/37] fix(chat): report a drained steer from a turn that fails The error path built newUIMessages from the wire message and the partial only, never from the per-turn list the drain appends to, so a turn that failed after a steer handed onTurnComplete everything except the steer. Seeded from the per-turn list at both construction sites, deduped by id. Reproduced with a stream that rejects mid-answer; an AI SDK error part takes the normal completion path and was never affected. --- packages/trigger-sdk/src/v3/ai.ts | 26 ++- .../test/steering-error-path.test.ts | 168 ++++++++++++++++++ 2 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 packages/trigger-sdk/test/steering-error-path.test.ts diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 03697976df2..5f002298fe0 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -9206,10 +9206,26 @@ function chatAgent< i === partialIdx ? partialResponse! : m ) as TUIMessage[]); - let erroredNewUIMessages: TUIMessage[] = erroredWireMessage ? [erroredWireMessage] : []; - if (includePartial) { - erroredNewUIMessages.push(partialResponse!); - } + /** + * Seeded from the per-turn list, not just the wire message and the + * partial, so a steering message the drain consumed is reported too. + * An app persisting from `newUIMessages` would otherwise lose the + * instruction whenever the turn it steered went on to fail. + */ + const buildErroredNew = (): TUIMessage[] => { + const out: TUIMessage[] = []; + const addUnique = (m?: TUIMessage) => { + if (m && !out.some((existing) => existing.id === m.id)) out.push(m); + }; + addUnique(erroredWireMessage); + for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? []) as TUIMessage[]) { + addUnique(m); + } + if (includePartial) addUnique(partialResponse!); + return out; + }; + + let erroredNewUIMessages: TUIMessage[] = buildErroredNew(); let erroredNewModelMessages: ModelMessage[] = []; @@ -9237,7 +9253,7 @@ function chatAgent< } catch { erroredNewModelMessages = []; erroredUIMessagesWithPartial = erroredUIMessages; - erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : []; + erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse); } } diff --git a/packages/trigger-sdk/test/steering-error-path.test.ts b/packages/trigger-sdk/test/steering-error-path.test.ts new file mode 100644 index 00000000000..60bc6d6d08a --- /dev/null +++ b/packages/trigger-sdk/test/steering-error-path.test.ts @@ -0,0 +1,168 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { sessionStreams } from "@trigger.dev/core/v3"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { stepCountIs, streamText, tool } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { UIMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * A turn that drains a steering message and then fails. + * + * The error path builds its own `newUIMessages` from the wire message and the + * partial response, so a steer the drain consumed is not in it. That is the + * same append-only persistence hole the steering fix exists to close: the app + * stores what `onTurnComplete` hands it, the failed turn hands it everything + * except the steer, and the instruction is gone. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; + +function userMessage(text: string, id: string) { + return { id, role: "user" as const, parts: [{ type: "text" as const, text }] }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +function toolCallChunks(callId: string): LanguageModelV3StreamPart[] { + return [ + { type: "tool-call", toolCallId: callId, toolName: "gate", input: JSON.stringify({ q: "go" }) }, + { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage: USAGE }, + ]; +} + +/** + * Emits a partial then errors, one chunk per pull so the queue isn't reset by + * erroring in the same tick as the enqueue. + */ +function erroringStream(): ReadableStream { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "partial" }, + ]; + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(chunks[i++]!); + return; + } + controller.error(new Error("UND_ERR_BODY_TIMEOUT")); + }, + }); +} + +type SeqReader = { lastSeqNum: (chatId: string, dir: "in" | "out") => number | undefined }; + +async function sendAndLand( + harness: { sendMessage: (m: ReturnType) => Promise }, + chatId: string, + text: string, + id: string +) { + const seqs = sessionStreams as unknown as SeqReader; + const before = seqs.lastSeqNum(chatId, "in") ?? -1; + void harness.sendMessage(userMessage(text, id)); + await waitFor(() => (seqs.lastSeqNum(chatId, "in") ?? -1) > before, `append ${id}`); +} + +describe("chat.agent steering on a turn that fails", () => { + it("still reports the steer in the error path's newUIMessages", async () => { + const chatId = "steer-error-path"; + const toolGate = deferred(); + let toolEntered = false; + const events: { newUIMessages: UIMessage[]; finishReason?: string }[] = []; + const promptsSawSteer: boolean[] = []; + + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); + + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + promptsSawSteer.push(JSON.stringify(prompt).includes("steer-me")); + // Step 1 calls the tool, step 2 fails mid-stream. + return step++ === 0 + ? { + stream: new ReadableStream({ + start(c) { + for (const ch of toolCallChunks("tc-1")) c.enqueue(ch); + c.close(); + }, + }), + } + : { stream: erroringStream() }; + }, + }); + + const agent = chat.agent({ + id: "steer-error-path", + pendingMessages: { shouldInject: () => true }, + onTurnComplete: async ({ newUIMessages, finishReason }) => { + events.push({ newUIMessages: [...(newUIMessages ?? [])], finishReason }); + }, + run: async ({ messages, signal }) => + streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }), + }); + + const harness = mockChatAgent(agent, { chatId }); + try { + void harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => toolEntered, "tool entered"); + await sendAndLand(harness, chatId, "steer-me", "u-2"); + toolGate.resolve(); + + await waitFor(() => events.length >= 1, "turn complete fired"); + + // The turn really did fail, and the steer really did reach the model, + // so the lane check below is about persistence and nothing else. + expect(events[0]!.finishReason).toBe("error"); + expect(promptsSawSteer.some(Boolean)).toBe(true); + + const texts = events[0]!.newUIMessages.flatMap((m) => + ((m.parts ?? []) as { type: string; text?: string }[]) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + ); + expect(texts).toContain("steer-me"); + } finally { + toolGate.resolve(); + await harness.close(); + } + }); +}); From a0a07bb36cd7cc3e2dce27f73750cfb1a706acad Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 14:18:11 +0100 Subject: [PATCH 22/37] test(chat): pin a one-shot instruction across an action An action and the message after it share a turn number, so the action reads the pending instruction and the intended turn still receives it, and it does not carry to the turn after. Removing the per-turn stash makes the action consume it and the intended turn gets nothing, which is the failure the stash prevents. --- .../test/instructions-action-replay.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/trigger-sdk/test/instructions-action-replay.test.ts diff --git a/packages/trigger-sdk/test/instructions-action-replay.test.ts b/packages/trigger-sdk/test/instructions-action-replay.test.ts new file mode 100644 index 00000000000..29107ca39e3 --- /dev/null +++ b/packages/trigger-sdk/test/instructions-action-replay.test.ts @@ -0,0 +1,122 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * A one-shot instruction and an action in between. + * + * `turn--` marks an action as not-a-turn, so an action and the message after + * it share a turn number. The consumed-instruction stash is keyed on that + * number, so an action that builds options consumes the injection and the next + * real turn reads the same stash back. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; + +function userMessage(text: string, id: string) { + return { id, role: "user" as const, parts: [{ type: "text" as const, text }] }; +} + +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +function textChunks(text: string): LanguageModelV3StreamPart[] { + return [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, + ]; +} + +describe("a one-shot instruction across an action", () => { + it( + "reaches each turn once and is not replayed by the turn after an action", + { timeout: 30_000 }, + async () => { + /** One entry per model call, in order, saying whether it carried the instruction. */ + const sawInstruction: { label: string; saw: boolean }[] = []; + + const makeModel = (label: string) => + new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + sawInstruction.push({ + label, + saw: JSON.stringify(prompt).includes("INSTRUCTION-ONE-SHOT"), + }); + return { + stream: simulateReadableStream({ chunks: textChunks("ok"), initialDelayInMs: 5 }), + }; + }, + }); + + const turnModel = makeModel("turn"); + const actionModel = makeModel("action"); + + const agent = chat.agent({ + id: "instructions-action-replay", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("ping") })]), + onTurnComplete: async ({ turn }) => { + // Injecting from inside the run, because the lane lives in run locals. + if (turn === 0) + chat.inject([{ role: "system", content: "INSTRUCTION-ONE-SHOT" }] as never); + }, + onAction: async ({ action, streamText: bound }) => { + if (action.type !== "ping") return; + return bound({ + model: actionModel, + messages: [{ role: "user", content: "regenerate" }], + }); + }, + run: async ({ messages, signal, streamText: bound }) => + bound({ model: turnModel, messages, abortSignal: signal }), + }); + + const harness = mockChatAgent(agent, { chatId: "instructions-action-replay" }); + try { + // Turn 1, nothing injected yet, then inject for the next turn. + await harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => sawInstruction.length >= 1, "turn 1"); + + // An action lands before the next message. + await harness.sendAction({ type: "ping" }); + await waitFor(() => sawInstruction.length >= 2, "action"); + + // Then the real turn the injection was meant for. + await harness.sendMessage(userMessage("m2", "u-2")); + await waitFor(() => sawInstruction.length >= 3, "turn 2"); + + // And one more, which must not see it again. + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => sawInstruction.length >= 4, "turn 3"); + + const carriers = sawInstruction.filter((e) => e.saw).map((e) => e.label); + // The action sees it: it is pending context, and an action is not a turn, + // so the action reading it must not use it up. + expect(sawInstruction[1]!).toEqual({ label: "action", saw: true }); + // And the turn it was actually injected for still gets it. + expect(sawInstruction[2]!).toEqual({ label: "turn", saw: true }); + // The turn after that does not: one-shot means one turn. + expect(sawInstruction[3]!).toEqual({ label: "turn", saw: false }); + // And it is never carried by more than one real turn. + expect(carriers.filter((l) => l === "turn")).toHaveLength(1); + } finally { + await harness.close(); + } + } + ); +}); From c9fe897f0776d68ec2f18a57051e3c5796da6d00 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 15:00:35 +0100 Subject: [PATCH 23/37] test(chat): use the spread form in the action-instruction test The bound streamText on the run and onAction arguments is #4884's, so on this branch alone the test neither typechecked nor ran. --- .../test/instructions-action-replay.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/trigger-sdk/test/instructions-action-replay.test.ts b/packages/trigger-sdk/test/instructions-action-replay.test.ts index 29107ca39e3..1794f687242 100644 --- a/packages/trigger-sdk/test/instructions-action-replay.test.ts +++ b/packages/trigger-sdk/test/instructions-action-replay.test.ts @@ -1,7 +1,7 @@ import { mockChatAgent } from "../src/v3/test/index.js"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { simulateReadableStream } from "ai"; +import { simulateReadableStream, streamText } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -75,15 +75,21 @@ describe("a one-shot instruction across an action", () => { if (turn === 0) chat.inject([{ role: "system", content: "INSTRUCTION-ONE-SHOT" }] as never); }, - onAction: async ({ action, streamText: bound }) => { + onAction: async ({ action }) => { if (action.type !== "ping") return; - return bound({ + return streamText({ model: actionModel, messages: [{ role: "user", content: "regenerate" }], + ...chat.toStreamTextOptions(), }); }, - run: async ({ messages, signal, streamText: bound }) => - bound({ model: turnModel, messages, abortSignal: signal }), + run: async ({ messages, signal }) => + streamText({ + model: turnModel, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + }), }); const harness = mockChatAgent(agent, { chatId: "instructions-action-replay" }); From 4f535ac0f14e048502b231ecdd8777d77a8fb002 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 16:05:04 +0100 Subject: [PATCH 24/37] fix(chat): keep a steer's prepared form for later turns The steered turn was sent what pendingMessages.prepare produced; later turns were sent the claimed UI message reconverted, so the model's memory of the instruction differed from the one it acted on. The pending list now carries both forms and reconciliation appends the model form, still without reconverting the UI lane so compaction survives. absorbSteering takes the injected form as well. --- packages/trigger-sdk/src/v3/ai.ts | 63 +++++--- .../test/steering-prepare-transform.test.ts | 144 ++++++++++++++++++ 2 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 packages/trigger-sdk/test/steering-prepare-transform.test.ts diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 5f002298fe0..d5c2559cf41 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -3578,7 +3578,14 @@ const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessa * summary and deliberately leaves the UI lane whole, so reconverting the UI * lane restores every message the summary replaced. */ -const chatPendingSteerKey = locals.create("chat.pendingSteer"); +const chatPendingSteerKey = locals.create("chat.pendingSteer"); + +/** + * A consumed steering message in both forms: the UI message for display and + * persistence, and the model messages `pendingMessages.prepare` produced for + * it, which is what the model actually saw and what later turns must see too. + */ +type PendingSteer = { ui: UIMessage; model: ModelMessage[] }; /** @internal — IDs of messages that were successfully injected via prepareStep */ const chatInjectedMessageIdsKey = locals.create>("chat.injectedMessageIds"); /** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */ @@ -4173,6 +4180,16 @@ type DrainedSteering = { const EMPTY_DRAIN: DrainedSteering = { injected: [], claimed: [] }; +/** + * The model messages to record for one claimed message. Without `prepare` + * each entry's own conversion is used. With it, `prepare` returned one list + * for the whole batch, so the first claimed message carries all of it and the + * rest carry none, which keeps the total exactly what the model received. + */ +function modelFormOf(m: UIMessage, batch: UIMessage[], injected: ModelMessage[]): ModelMessage[] { + return batch[0] === m ? injected : []; +} + /** * Drain the steering queue as a batch. Calls `shouldInject` once with all * pending messages. If it returns true, calls `prepareMessages` once to @@ -4322,7 +4339,9 @@ async function drainSteeringQueue( if (claimedUIMessages.length > 0 && currentUIMessages) { const pendingSteer = locals.get(chatPendingSteerKey) ?? []; for (const m of claimedUIMessages) { - if (!pendingSteer.some((existing) => existing.id === m.id)) pendingSteer.push(m); + if (!pendingSteer.some((existing) => existing.ui.id === m.id)) { + pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) }); + } } locals.set(chatPendingSteerKey, pendingSteer); } @@ -6681,6 +6700,19 @@ function chatAgent< // durable snapshot + `session.out` replay (or `hydrateMessages` if // registered) — the wire is delta-only now, no longer a seed. let accumulatedMessages: ModelMessage[] = []; + /** + * Give the model accumulator the steering messages a drain consumed, + * in the form the model actually received. Appended, never reconverted + * from the UI lane, so a model-only compaction summary survives. Called + * on both the success and the error path, before the response or the + * partial joins the lane, so the order stays steer-then-answer. + */ + const reconcilePendingSteer = () => { + const pending = locals.get(chatPendingSteerKey); + if (!pending || pending.length === 0) return; + locals.set(chatPendingSteerKey, []); + for (const entry of pending) accumulatedMessages.push(...entry.model); + }; // Accumulated UI messages for persistence. Mirrors the model accumulator // but in frontend-friendly UIMessage format (with parts, id, etc.). @@ -8558,20 +8590,7 @@ function chatAgent< // before the response is appended so the order stays // steer-then-answer. Outside the `capturedResponseMessage` // branches below, so a turn that captured no response is covered. - const pendingSteer = locals.get(chatPendingSteerKey); - if (pendingSteer && pendingSteer.length > 0) { - locals.set(chatPendingSteerKey, []); - try { - accumulatedMessages.push( - ...(await toModelMessages(pendingSteer.map(stripProviderMetadata))) - ); - } catch (error) { - logger.warn( - "chat.agent: toModelMessages failed for an injected message; it will be missing from the next turn", - { error: error instanceof Error ? error.message : String(error) } - ); - } - } + reconcilePendingSteer(); // Append the assistant's response (partial or complete) to the accumulator. // The onFinish callback fires even on abort/stop, so partial responses @@ -10705,11 +10724,15 @@ class ChatMessageAccumulator { * the UI lane whole: a reconversion would restore everything the summary * replaced. */ - async absorbSteering(claimed: UIMessage[]): Promise { + async absorbSteering(claimed: UIMessage[], injected?: ModelMessage[]): Promise { const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id)); if (fresh.length === 0) return; this.uiMessages.push(...fresh); - this.modelMessages.push(...(await toModelMessages(fresh))); + // Record what the model received. Only when the whole batch is new is + // `injected` known to describe exactly these messages. + this.modelMessages.push( + ...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)) + ); } /** @@ -10758,7 +10781,7 @@ class ChatMessageAccumulator { steps, queue ); - await this.absorbSteering(claimed); + await this.absorbSteering(claimed, injected); if (injected.length > 0) { resultMessages = [...(resultMessages ?? messages), ...injected]; } @@ -11534,7 +11557,7 @@ function createChatSession( steps, turnSteeringQueue ); - await accumulator.absorbSteering(claimed); + await accumulator.absorbSteering(claimed, injected); if (injected.length > 0) { resultMessages = [...(resultMessages ?? stepMsgs), ...injected]; } diff --git a/packages/trigger-sdk/test/steering-prepare-transform.test.ts b/packages/trigger-sdk/test/steering-prepare-transform.test.ts new file mode 100644 index 00000000000..e91e0ea21d7 --- /dev/null +++ b/packages/trigger-sdk/test/steering-prepare-transform.test.ts @@ -0,0 +1,144 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { sessionStreams } from "@trigger.dev/core/v3"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, stepCountIs, streamText, tool } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * `pendingMessages.prepare` decides how a steer is presented to the model. + * The steered turn gets that form. Later turns have to get the same form, + * or the model's memory of the instruction differs from what it acted on. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; +const toolCallChunks = (callId: string): LanguageModelV3StreamPart[] => [ + { type: "tool-call", toolCallId: callId, toolName: "gate", input: JSON.stringify({ q: "go" }) }, + { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage: USAGE }, +]; +type SeqReader = { lastSeqNum: (chatId: string, dir: "in" | "out") => number | undefined }; +async function sendAndLand( + harness: { sendMessage: (m: ReturnType) => Promise }, + chatId: string, + text: string, + id: string +) { + const seqs = sessionStreams as unknown as SeqReader; + const before = seqs.lastSeqNum(chatId, "in") ?? -1; + void harness.sendMessage(userMessage(text, id)); + await waitFor(() => (seqs.lastSeqNum(chatId, "in") ?? -1) > before, `append ${id}`); +} + +describe("a steer transformed by pendingMessages.prepare", () => { + it("reaches later turns in the transformed form", { timeout: 30_000 }, async () => { + const chatId = "steer-prepare-transform"; + const toolGate = deferred(); + let toolEntered = false; + const prompts: string[] = []; + + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); + + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(JSON.stringify(prompt)); + const isToolStep = step++ % 2 === 0; + return { + stream: simulateReadableStream({ + chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), + initialDelayInMs: 10, + chunkDelayInMs: 2, + }), + }; + }, + }); + + const agent = chat.agent({ + id: "steer-prepare-transform", + pendingMessages: { + shouldInject: () => true, + // Present the steer to the model as an operator note, not a user turn. + prepare: async ({ messages }) => [ + { + role: "system", + content: `[OPERATOR-NOTE] ${messages.map((m) => (m.parts as { text?: string }[]).map((p) => p.text ?? "").join("")).join(" ")}`, + }, + ], + }, + run: async ({ messages, signal }) => + streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }), + }); + + const harness = mockChatAgent(agent, { chatId }); + try { + const first = harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => toolEntered, "tool entered"); + await sendAndLand(harness, chatId, "steer-me", "u-2"); + toolGate.resolve(); + await first; + await waitFor(() => prompts.length >= 2, "turn 1 second step"); + + // The steered turn saw the transformed form. + expect(prompts[1]!).toContain("[OPERATOR-NOTE] steer-me"); + const promptsAfterTurn1 = prompts.length; + + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built"); + + // And so does the next one. Reconverting the UI message gives the raw + // user turn instead, so the model remembers a different instruction + // from the one it acted on. + expect(prompts[promptsAfterTurn1]!).toContain("[OPERATOR-NOTE] steer-me"); + } finally { + toolGate.resolve(); + await harness.close(); + } + }); +}); From 39ea54191ad056c77fe6972dd874a990d4bc6e5a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 16:05:05 +0100 Subject: [PATCH 25/37] fix(chat): reconcile a steer into the model lane when the turn fails The append ran on the success path only. A turn that failed after a steer reported it to the hook's newUIMessages but left it pending, so the error event's messages lacked it and the next turn received it one slot late. The catch path reconciles it now, before the partial is considered. --- packages/trigger-sdk/src/v3/ai.ts | 2 + .../test/steering-error-path.test.ts | 49 +++++++++++++------ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index d5c2559cf41..5afb06d0fdc 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -9248,6 +9248,8 @@ function chatAgent< let erroredNewModelMessages: ModelMessage[] = []; + reconcilePendingSteer(); + if (!responseCommitted) { try { if (erroredNewUIMessages.length > 0) { diff --git a/packages/trigger-sdk/test/steering-error-path.test.ts b/packages/trigger-sdk/test/steering-error-path.test.ts index 60bc6d6d08a..21df89b897c 100644 --- a/packages/trigger-sdk/test/steering-error-path.test.ts +++ b/packages/trigger-sdk/test/steering-error-path.test.ts @@ -92,7 +92,7 @@ describe("chat.agent steering on a turn that fails", () => { const chatId = "steer-error-path"; const toolGate = deferred(); let toolEntered = false; - const events: { newUIMessages: UIMessage[]; finishReason?: string }[] = []; + const events: { newUIMessages: UIMessage[]; messages: unknown[]; finishReason?: string }[] = []; const promptsSawSteer: boolean[] = []; const gateTool = tool({ @@ -109,25 +109,36 @@ describe("chat.agent steering on a turn that fails", () => { const model = new MockLanguageModelV3({ doStream: async ({ prompt }) => { promptsSawSteer.push(JSON.stringify(prompt).includes("steer-me")); - // Step 1 calls the tool, step 2 fails mid-stream. - return step++ === 0 - ? { - stream: new ReadableStream({ - start(c) { - for (const ch of toolCallChunks("tc-1")) c.enqueue(ch); - c.close(); - }, - }), - } - : { stream: erroringStream() }; + // Step 1 calls the tool, step 2 fails mid-stream, the next turn answers. + const n = step++; + const fromChunks = (chunks: LanguageModelV3StreamPart[]) => ({ + stream: new ReadableStream({ + start(c) { + for (const ch of chunks) c.enqueue(ch); + c.close(); + }, + }), + }); + if (n === 0) return fromChunks(toolCallChunks("tc-1")); + if (n === 1) return { stream: erroringStream() }; + return fromChunks([ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "ok" }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, + ]); }, }); const agent = chat.agent({ id: "steer-error-path", pendingMessages: { shouldInject: () => true }, - onTurnComplete: async ({ newUIMessages, finishReason }) => { - events.push({ newUIMessages: [...(newUIMessages ?? [])], finishReason }); + onTurnComplete: async ({ newUIMessages, messages, finishReason }) => { + events.push({ + newUIMessages: [...(newUIMessages ?? [])], + messages: [...messages], + finishReason, + }); }, run: async ({ messages, signal }) => streamText({ @@ -160,6 +171,16 @@ describe("chat.agent steering on a turn that fails", () => { .map((p) => p.text ?? "") ); expect(texts).toContain("steer-me"); + + // The model lane the failed turn reports has to carry it too, and so + // does the prompt the next turn actually sends. Reconciling only on the + // success path leaves it pending, so the next turn misses it and it + // lands a slot late at the end of that turn. + expect(JSON.stringify(events[0]!.messages)).toContain("steer-me"); + const promptsBefore = promptsSawSteer.length; + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => promptsSawSteer.length > promptsBefore, "turn 2 prompt built"); + expect(promptsSawSteer[promptsBefore]).toBe(true); } finally { toolGate.resolve(); await harness.close(); From 920c76db90daef9a6a794d150fa3fdf8ca024101 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 16:05:05 +0100 Subject: [PATCH 26/37] fix(chat): keep a failed action from counting as a turn Reporting a failed action stream by throwing landed in the shared turn-error path, which fired onTurnComplete, kept the turn number and consumed the one-shot instruction lane. The action branch now reports the failure itself and falls through to its own snapshot, completion and turn--, so the next real turn is still next and still gets an instruction injected before the action. --- packages/trigger-sdk/src/v3/ai.ts | 14 ++- .../test/action-failure-not-a-turn.test.ts | 117 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/trigger-sdk/test/action-failure-not-a-turn.test.ts diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 5afb06d0fdc..b1df58e35dc 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -8203,7 +8203,19 @@ function chatAgent< ) { return "exit"; } - throw error; + // Reported here rather than rethrown: the shared catch + // below is the turn-error path, and it would fire + // onTurnComplete, keep the turn number and consume the + // one-shot instruction lane, none of which an action does. + try { + await withChatWriter(async (writer) => { + const errorText = + error instanceof Error ? error.message : "An unexpected error occurred"; + writer.write({ type: "error", errorText } as any); + }); + } catch { + // best effort + } } } diff --git a/packages/trigger-sdk/test/action-failure-not-a-turn.test.ts b/packages/trigger-sdk/test/action-failure-not-a-turn.test.ts new file mode 100644 index 00000000000..54a907b4bfd --- /dev/null +++ b/packages/trigger-sdk/test/action-failure-not-a-turn.test.ts @@ -0,0 +1,117 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * An action whose stream fails is still an action, not a turn. + * + * Reporting the failure by throwing lands in the shared turn-error path, + * which fires `onTurnComplete`, advances the turn counter and consumes the + * one-shot instruction lane, none of which an action is supposed to do. The + * failure still has to be reported to the client and the partial kept. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; + +describe("an action whose stream fails", () => { + it("is reported without being counted as a turn", { timeout: 30_000 }, async () => { + const turnCompletes: { turn: number; finishReason?: string }[] = []; + const turnPrompts: string[] = []; + + const turnModel = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + turnPrompts.push(JSON.stringify(prompt)); + return { + stream: simulateReadableStream({ chunks: textChunks("answer"), initialDelayInMs: 5 }), + }; + }, + }); + const failingActionModel = new MockLanguageModelV3({ + doStream: async () => ({ + stream: new ReadableStream({ + pull(c) { + c.error(new Error("provider exploded mid-stream")); + }, + }), + }), + }); + + const agent = chat.agent({ + id: "action-failure-not-a-turn", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]), + onTurnComplete: async ({ turn, finishReason }) => { + turnCompletes.push({ turn, finishReason }); + // Injected after turn 0, meant for the next real turn. + if (turn === 0) + chat.inject([{ role: "system", content: "INSTRUCTION-FOR-NEXT-TURN" }] as never); + }, + onAction: async ({ action, messages }) => { + if (action.type !== "regenerate") return; + chat.history.slice(0, -1); + return streamText({ model: failingActionModel, messages, ...chat.toStreamTextOptions() }); + }, + run: async ({ messages, signal }) => + streamText({ + model: turnModel, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + }), + }); + + const harness = mockChatAgent(agent, { chatId: "action-failure-not-a-turn" }); + try { + await harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => turnCompletes.length >= 1, "turn 0"); + + await harness.sendAction({ type: "regenerate" }).catch(() => {}); + await new Promise((r) => setTimeout(r, 200)); + + // The failure reached the client. + const errors = (harness.allRawChunks as { type?: string }[]).filter( + (c) => c.type === "error" + ); + expect(errors.length).toBeGreaterThan(0); + + // But it was not a turn: no turn lifecycle for it. + expect(turnCompletes).toHaveLength(1); + + await harness.sendMessage(userMessage("m2", "u-2")); + await waitFor(() => turnCompletes.length >= 2, "turn 1"); + + // The next real turn is turn 1, not turn 2, and it still gets the + // instruction the failed action must not have consumed. + expect(turnCompletes[1]!.turn).toBe(1); + expect(turnPrompts.at(-1)!).toContain("INSTRUCTION-FOR-NEXT-TURN"); + } finally { + await harness.close(); + } + }); +}); From e348d615207dd2a62dc45d8772ce20ff730d5914 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 16:05:06 +0100 Subject: [PATCH 27/37] fix(chat): move the snapshot cursor on a failed turn The error path wrote its snapshot with the failed turn's completion cursor but did not update the shared cursor, so a later history-changing action, whose snapshot is cursor-neutral, wrote the cursor from before the failed turn and a continuation would replay superseded output. --- packages/trigger-sdk/src/v3/ai.ts | 4 + .../test/error-snapshot-cursor.test.ts | 108 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 packages/trigger-sdk/test/error-snapshot-cursor.test.ts diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index b1df58e35dc..5486a68c59d 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -9186,6 +9186,10 @@ function chatAgent< }); // Signal turn complete so the client knows this turn is done errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId); + // A later action's snapshot reuses this cursor, so it has to move + // here too or that snapshot resumes from before the failed turn. + lastSnapshotOutEventId = + errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId; } catch { // Best-effort — if stream write fails, let the run continue anyway } diff --git a/packages/trigger-sdk/test/error-snapshot-cursor.test.ts b/packages/trigger-sdk/test/error-snapshot-cursor.test.ts new file mode 100644 index 00000000000..7c2e8390ec3 --- /dev/null +++ b/packages/trigger-sdk/test/error-snapshot-cursor.test.ts @@ -0,0 +1,108 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * The snapshot cursor after a failed turn. + * + * The error path writes its snapshot with the failed turn's completion cursor + * but does not update the shared cursor holder, so a later action's snapshot, + * which is cursor-neutral and reuses the holder, writes the cursor from + * BEFORE the failed turn. A continuation then resumes from there and replays + * output the failed turn's snapshot had already superseded. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; +function erroringStream(): ReadableStream { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "partial" }, + ]; + let i = 0; + return new ReadableStream({ + pull(c) { + if (i < chunks.length) return void c.enqueue(chunks[i++]!); + c.error(new Error("UND_ERR_BODY_TIMEOUT")); + }, + }); +} + +describe("the snapshot an action writes after a failed turn", () => { + it("carries the failed turn's cursor, not the one before it", { timeout: 30_000 }, async () => { + const completes: { finishReason?: string }[] = []; + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async () => + step++ === 0 + ? { stream: simulateReadableStream({ chunks: textChunks("first"), initialDelayInMs: 5 }) } + : { stream: erroringStream() }, + }); + + const agent = chat.agent({ + id: "error-snapshot-cursor", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("undo") })]), + onTurnComplete: async ({ finishReason }) => { + completes.push({ finishReason }); + }, + onAction: async ({ action }) => { + if (action.type === "undo") chat.history.slice(0, -2); + }, + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + + const harness = mockChatAgent(agent, { chatId: "error-snapshot-cursor" }); + try { + await harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => harness.getSnapshot()?.lastOutEventId !== undefined, "turn 0 snapshot"); + const afterTurn0 = harness.getSnapshot()?.lastOutEventId; + + await harness.sendMessage(userMessage("m2", "u-2")); + await waitFor(() => completes.length >= 2, "turn 1 (failed)"); + expect(completes[1]!.finishReason).toBe("error"); + await waitFor( + () => harness.getSnapshot()?.lastOutEventId !== afterTurn0, + "failed turn snapshot" + ); + const afterFailedTurn = harness.getSnapshot()?.lastOutEventId; + expect(afterFailedTurn).toBeDefined(); + // The failed turn moved the cursor: it wrote an error and a completion. + expect(afterFailedTurn).not.toBe(afterTurn0); + + await harness.sendAction({ type: "undo" }); + await new Promise((r) => setTimeout(r, 60)); + + // An action's write is cursor-neutral, so it has to keep the CURRENT + // cursor, which is the failed turn's, not the one from before it. + expect(harness.getSnapshot()?.lastOutEventId).toBe(afterFailedTurn); + } finally { + await harness.close(); + } + }); +}); From 0ef1caa9aa99d351b2ae8ae783d0729cb067612b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 16:12:14 +0100 Subject: [PATCH 28/37] docs(chat): cover the second review round in the changesets A prepared steer keeps its form on later turns, a failed action is still not a turn, and an action's rollback after a failed turn keeps the right cursor. --- .changeset/action-stream-into-conversation.md | 2 +- .changeset/persist-action-history-mutations.md | 2 +- .changeset/steering-messages-accumulator.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md index 3561b2e25a6..d4aa141a4c6 100644 --- a/.changeset/action-stream-into-conversation.md +++ b/.changeset/action-stream-into-conversation.md @@ -4,4 +4,4 @@ A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of, and the next turn carried on from the answer it had replaced. -A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. An action that used to end quietly on a mid-stream failure now surfaces an error to the frontend, so handle it the way you handle a failed turn. +A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. An action that used to end quietly on a mid-stream failure now surfaces an error to the frontend. It is still an action, not a turn: `onTurnComplete` does not fire for it, the turn count is unchanged, and an instruction injected for the next turn still reaches that turn. diff --git a/.changeset/persist-action-history-mutations.md b/.changeset/persist-action-history-mutations.md index c02c644d0bc..337494f327d 100644 --- a/.changeset/persist-action-history-mutations.md +++ b/.changeset/persist-action-history-mutations.md @@ -2,4 +2,4 @@ "@trigger.dev/sdk": patch --- -Undo, edit and regenerate now survive a run ending. History rolled back from `onAction` was only kept in the running worker's memory, so the rollback held while that worker stayed warm and then reverted on the next continuation. The undone messages came back, minutes later, with no error. +Undo, edit and regenerate now survive a run ending. History rolled back from `onAction` was only kept in the running worker's memory, so the rollback held while that worker stayed warm and then reverted on the next continuation. The undone messages came back, minutes later, with no error. This also holds when the turn before the action failed: the rollback used to be written against the cursor from before that turn, so a continuation could replay output the failed turn had already superseded. diff --git a/.changeset/steering-messages-accumulator.md b/.changeset/steering-messages-accumulator.md index f207cd7095c..3b2a6f6883e 100644 --- a/.changeset/steering-messages-accumulator.md +++ b/.changeset/steering-messages-accumulator.md @@ -2,6 +2,6 @@ "@trigger.dev/sdk": patch --- -Steering messages injected mid-answer are now part of the conversation, both for your hooks and for the model on later turns. Previously they reached the model for the answer they steered and reached the browser, but nothing else: `onTurnComplete` never saw them, so an app storing its own transcript lost the instruction the answer was shaped by, and it vanished from the conversation on reload. The model also forgot the instruction from the next turn onwards, answering as though the message had never been sent, while the chat UI still showed it. +Steering messages injected mid-answer are now part of the conversation, both for your hooks and for the model on later turns. Previously they reached the model for the answer they steered and reached the browser, but nothing else: `onTurnComplete` never saw them, so an app storing its own transcript lost the instruction the answer was shaped by, and it vanished from the conversation on reload. The model also forgot the instruction from the next turn onwards, answering as though the message had never been sent, while the chat UI still showed it. This holds when the steered turn fails part-way, and when `pendingMessages.prepare` reshapes the message: later turns now see the same form the steered turn did, not the original message. If you worked around this by saving steering messages as they arrive, in `pendingMessages.onReceived` for example, that write now duplicates the one you get from `newUIMessages`. Drop it, or skip messages you have already stored. From ae72c70687d40cac3658762f7e8c9c73884afcf7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 16:35:03 +0100 Subject: [PATCH 29/37] fix(chat): report a steer in the turn delta, and once after a history edit The per-turn model delta onTurnComplete reports as newMessages never received a steer's model form, so append-only persistence from it lost the model's view. And a chat.history edit after a drain rebuilt the model lane from the UI lane, which already held the steer, then appended it again, so later turns received it twice. Reconciliation now writes the delta as well, and skips the lane append for anything the rebuild already placed. On that path the lane keeps the raw form from the rebuild; a history edit is the app rewriting history. --- packages/trigger-sdk/src/v3/ai.ts | 23 ++- .../test/steering-history-edit-once.test.ts | 141 ++++++++++++++++++ .../test/steering-prepare-transform.test.ts | 8 + 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 packages/trigger-sdk/test/steering-history-edit-once.test.ts diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 5486a68c59d..73392c85ad9 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -6707,11 +6707,23 @@ function chatAgent< * on both the success and the error path, before the response or the * partial joins the lane, so the order stays steer-then-answer. */ - const reconcilePendingSteer = () => { + const reconcilePendingSteer = (options?: { + /** This turn's model delta, as `onTurnComplete.newMessages` reports it. */ + turnNew?: ModelMessage[]; + /** + * UI message ids already in the lane because a `chat.history` edit + * rebuilt it from the UI lane this turn. Their model form is not + * appended again, or later turns would get the steer twice. + */ + alreadyInLane?: Set; + }) => { const pending = locals.get(chatPendingSteerKey); if (!pending || pending.length === 0) return; locals.set(chatPendingSteerKey, []); - for (const entry of pending) accumulatedMessages.push(...entry.model); + for (const entry of pending) { + if (!options?.alreadyInLane?.has(entry.ui.id)) accumulatedMessages.push(...entry.model); + options?.turnNew?.push(...entry.model); + } }; // Accumulated UI messages for persistence. Mirrors the model accumulator @@ -8555,11 +8567,13 @@ function chatAgent< // during this turn. The updated messages become the new base, and the // response gets appended on top. const runOverride = locals.get(chatOverrideMessagesKey); + let rebuiltFromUiIds: Set | undefined; if (runOverride) { locals.set(chatOverrideMessagesKey, undefined); accumulatedUIMessages = [...runOverride] as TUIMessage[]; accumulatedMessages = await toModelMessages(runOverride); locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + rebuiltFromUiIds = new Set(runOverride.map((m) => m.id)); } // Check if compaction set a model-only override (preserves UI messages). @@ -8602,7 +8616,10 @@ function chatAgent< // before the response is appended so the order stays // steer-then-answer. Outside the `capturedResponseMessage` // branches below, so a turn that captured no response is covered. - reconcilePendingSteer(); + reconcilePendingSteer({ + turnNew: turnNewModelMessages, + alreadyInLane: rebuiltFromUiIds, + }); // Append the assistant's response (partial or complete) to the accumulator. // The onFinish callback fires even on abort/stop, so partial responses diff --git a/packages/trigger-sdk/test/steering-history-edit-once.test.ts b/packages/trigger-sdk/test/steering-history-edit-once.test.ts new file mode 100644 index 00000000000..078c5423e1c --- /dev/null +++ b/packages/trigger-sdk/test/steering-history-edit-once.test.ts @@ -0,0 +1,141 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { sessionStreams } from "@trigger.dev/core/v3"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, stepCountIs, streamText, tool } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * A `chat.history` edit after a steer has been drained. + * + * The edit is applied by rebuilding the model lane from the UI lane, and the + * UI lane already holds the steer, so the rebuilt lane has it. Appending the + * recorded model form on top of that sends it twice from the next turn on. A + * steer-presence check passes either way; the count is what discriminates. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; +const toolCallChunks = (callId: string): LanguageModelV3StreamPart[] => [ + { type: "tool-call", toolCallId: callId, toolName: "gate", input: JSON.stringify({ q: "go" }) }, + { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage: USAGE }, +]; +type SeqReader = { lastSeqNum: (chatId: string, dir: "in" | "out") => number | undefined }; +async function sendAndLand( + harness: { sendMessage: (m: ReturnType) => Promise }, + chatId: string, + text: string, + id: string +) { + const seqs = sessionStreams as unknown as SeqReader; + const before = seqs.lastSeqNum(chatId, "in") ?? -1; + void harness.sendMessage(userMessage(text, id)); + await waitFor(() => (seqs.lastSeqNum(chatId, "in") ?? -1) > before, `append ${id}`); +} +const countOf = (hay: string, needle: string) => hay.split(needle).length - 1; + +describe("a history edit after a steer was drained", () => { + it("sends the steer to later turns exactly once", { timeout: 30_000 }, async () => { + const chatId = "steer-history-edit-once"; + const toolGate = deferred(); + let toolEntered = false; + const prompts: string[] = []; + + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); + + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(JSON.stringify(prompt)); + const isToolStep = step++ % 2 === 0; + return { + stream: simulateReadableStream({ + chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), + initialDelayInMs: 10, + chunkDelayInMs: 2, + }), + }; + }, + }); + + const agent = chat.agent({ + id: "steer-history-edit-once", + pendingMessages: { + shouldInject: () => true, + // An identity rewrite is enough: any chat.history write sets the + // override that is applied by rebuilding from the UI lane. + onInjected: () => { + chat.history.set(chat.history.all()); + }, + }, + run: async ({ messages, signal }) => + streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }), + }); + + const harness = mockChatAgent(agent, { chatId }); + try { + const first = harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => toolEntered, "tool entered"); + await sendAndLand(harness, chatId, "steer-me", "u-2"); + toolGate.resolve(); + await first; + await waitFor(() => prompts.length >= 2, "turn 1 done"); + const promptsAfterTurn1 = prompts.length; + + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built"); + + const turn2 = prompts[promptsAfterTurn1]!; + expect(countOf(turn2, '"steer-me"')).toBe(1); + } finally { + toolGate.resolve(); + await harness.close(); + } + }); +}); diff --git a/packages/trigger-sdk/test/steering-prepare-transform.test.ts b/packages/trigger-sdk/test/steering-prepare-transform.test.ts index e91e0ea21d7..0d6102353e3 100644 --- a/packages/trigger-sdk/test/steering-prepare-transform.test.ts +++ b/packages/trigger-sdk/test/steering-prepare-transform.test.ts @@ -67,6 +67,7 @@ describe("a steer transformed by pendingMessages.prepare", () => { const toolGate = deferred(); let toolEntered = false; const prompts: string[] = []; + const newModelDeltas: string[] = []; const gateTool = tool({ description: "blocks until the test opens it", @@ -105,6 +106,9 @@ describe("a steer transformed by pendingMessages.prepare", () => { }, ], }, + onTurnComplete: async ({ newMessages }) => { + newModelDeltas.push(JSON.stringify(newMessages)); + }, run: async ({ messages, signal }) => streamText({ model, @@ -136,6 +140,10 @@ describe("a steer transformed by pendingMessages.prepare", () => { // user turn instead, so the model remembers a different instruction // from the one it acted on. expect(prompts[promptsAfterTurn1]!).toContain("[OPERATOR-NOTE] steer-me"); + + // The per-turn model delta the hook reports carries it in the same form, + // or append-only persistence from `newMessages` loses the model's view. + expect(newModelDeltas[0]!).toContain("[OPERATOR-NOTE] steer-me"); } finally { toolGate.resolve(); await harness.close(); From 62fd77c05cfe002b8b1bdf71a5f01d6cc31b50df Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 17:30:45 +0100 Subject: [PATCH 30/37] fix(chat): keep a prepared steer through a history edit and a failed turn A chat.history edit rebuilt the model lane from the UI lane, which put the steer's raw form back, and when a compaction override replaced that lane in the same turn the steer was left with no form at all, since the id-set skip then withheld the prepared one. The rebuild now leaves consumed steers out and reconciliation appends the prepared form once; a steer the edit removed stays removed. The id-set skip is gone. The failed-turn delta is built from the recorded forms rather than by converting the UI list, so newMessages reports the form the lane holds. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a --- packages/trigger-sdk/src/v3/ai.ts | 58 ++++-- .../test/steering-error-path.test.ts | 28 ++- .../test/steering-history-edit-once.test.ts | 189 ++++++++++++------ 3 files changed, 187 insertions(+), 88 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 73392c85ad9..56fb87edb7e 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -6710,20 +6710,15 @@ function chatAgent< const reconcilePendingSteer = (options?: { /** This turn's model delta, as `onTurnComplete.newMessages` reports it. */ turnNew?: ModelMessage[]; - /** - * UI message ids already in the lane because a `chat.history` edit - * rebuilt it from the UI lane this turn. Their model form is not - * appended again, or later turns would get the steer twice. - */ - alreadyInLane?: Set; - }) => { + }): PendingSteer[] => { const pending = locals.get(chatPendingSteerKey); - if (!pending || pending.length === 0) return; + if (!pending || pending.length === 0) return []; locals.set(chatPendingSteerKey, []); for (const entry of pending) { - if (!options?.alreadyInLane?.has(entry.ui.id)) accumulatedMessages.push(...entry.model); + accumulatedMessages.push(...entry.model); options?.turnNew?.push(...entry.model); } + return pending; }; // Accumulated UI messages for persistence. Mirrors the model accumulator @@ -8567,13 +8562,27 @@ function chatAgent< // during this turn. The updated messages become the new base, and the // response gets appended on top. const runOverride = locals.get(chatOverrideMessagesKey); - let rebuiltFromUiIds: Set | undefined; if (runOverride) { locals.set(chatOverrideMessagesKey, undefined); accumulatedUIMessages = [...runOverride] as TUIMessage[]; - accumulatedMessages = await toModelMessages(runOverride); + /** + * Steers the drain consumed are left out of the rebuild and + * appended by the reconciliation below instead, so the lane + * gets the form the model actually received rather than a + * reconversion of the UI message, and gets it once. A steer + * the edit removed is dropped from the pending list too, so + * the edit is honoured. + */ + const overrideIds = new Set(runOverride.map((m) => m.id)); + const pending = (locals.get(chatPendingSteerKey) ?? []).filter((e) => + overrideIds.has(e.ui.id) + ); + locals.set(chatPendingSteerKey, pending); + const pendingIds = new Set(pending.map((e) => e.ui.id)); + accumulatedMessages = await toModelMessages( + runOverride.filter((m) => !pendingIds.has(m.id)) + ); locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); - rebuiltFromUiIds = new Set(runOverride.map((m) => m.id)); } // Check if compaction set a model-only override (preserves UI messages). @@ -8616,10 +8625,7 @@ function chatAgent< // before the response is appended so the order stays // steer-then-answer. Outside the `capturedResponseMessage` // branches below, so a turn that captured no response is covered. - reconcilePendingSteer({ - turnNew: turnNewModelMessages, - alreadyInLane: rebuiltFromUiIds, - }); + reconcilePendingSteer({ turnNew: turnNewModelMessages }); // Append the assistant's response (partial or complete) to the accumulator. // The onFinish callback fires even on abort/stop, so partial responses @@ -9281,14 +9287,28 @@ function chatAgent< let erroredNewModelMessages: ModelMessage[] = []; - reconcilePendingSteer(); + const reconciledSteer = reconcilePendingSteer(); if (!responseCommitted) { try { if (erroredNewUIMessages.length > 0) { - erroredNewModelMessages = await toModelMessages( - erroredNewUIMessages.map((m) => stripProviderMetadata(m)) + /** + * Built in order from the recorded forms rather than by + * converting the UI list, so a steer appears in the delta as + * the model received it (what `prepare` produced), matching the + * lane. The wire message and partial are converted as before. + */ + const steerModelById = new Map( + reconciledSteer.map((e) => [e.ui.id, e.model] as const) ); + for (const m of erroredNewUIMessages) { + const recorded = steerModelById.get(m.id); + if (recorded) erroredNewModelMessages.push(...recorded); + else + erroredNewModelMessages.push( + ...(await toModelMessages([stripProviderMetadata(m)])) + ); + } } if (erroredUIMessagesWithPartial !== accumulatedUIMessages) { if (partialIdx === -1) { diff --git a/packages/trigger-sdk/test/steering-error-path.test.ts b/packages/trigger-sdk/test/steering-error-path.test.ts index 21df89b897c..8029a061e98 100644 --- a/packages/trigger-sdk/test/steering-error-path.test.ts +++ b/packages/trigger-sdk/test/steering-error-path.test.ts @@ -92,7 +92,12 @@ describe("chat.agent steering on a turn that fails", () => { const chatId = "steer-error-path"; const toolGate = deferred(); let toolEntered = false; - const events: { newUIMessages: UIMessage[]; messages: unknown[]; finishReason?: string }[] = []; + const events: { + newUIMessages: UIMessage[]; + messages: unknown[]; + newMessages: unknown[]; + finishReason?: string; + }[] = []; const promptsSawSteer: boolean[] = []; const gateTool = tool({ @@ -132,11 +137,22 @@ describe("chat.agent steering on a turn that fails", () => { const agent = chat.agent({ id: "steer-error-path", - pendingMessages: { shouldInject: () => true }, - onTurnComplete: async ({ newUIMessages, messages, finishReason }) => { + pendingMessages: { + shouldInject: () => true, + prepare: async ({ messages }) => [ + { + role: "system", + content: `[OPERATOR-NOTE] ${messages + .map((m) => (m.parts as { text?: string }[]).map((p) => p.text ?? "").join("")) + .join(" ")}`, + }, + ], + }, + onTurnComplete: async ({ newUIMessages, messages, newMessages, finishReason }) => { events.push({ newUIMessages: [...(newUIMessages ?? [])], messages: [...messages], + newMessages: [...(newMessages ?? [])], finishReason, }); }, @@ -176,7 +192,11 @@ describe("chat.agent steering on a turn that fails", () => { // does the prompt the next turn actually sends. Reconciling only on the // success path leaves it pending, so the next turn misses it and it // lands a slot late at the end of that turn. - expect(JSON.stringify(events[0]!.messages)).toContain("steer-me"); + expect(JSON.stringify(events[0]!.messages)).toContain("[OPERATOR-NOTE] steer-me"); + // The per-turn delta carries the same form, not a reconversion of the UI + // message: append-only model persistence from `newMessages` would + // otherwise store a different instruction from the one the model acted on. + expect(JSON.stringify(events[0]!.newMessages)).toContain("[OPERATOR-NOTE] steer-me"); const promptsBefore = promptsSawSteer.length; await harness.sendMessage(userMessage("m3", "u-3")); await waitFor(() => promptsSawSteer.length > promptsBefore, "turn 2 prompt built"); diff --git a/packages/trigger-sdk/test/steering-history-edit-once.test.ts b/packages/trigger-sdk/test/steering-history-edit-once.test.ts index 078c5423e1c..c33b28285bd 100644 --- a/packages/trigger-sdk/test/steering-history-edit-once.test.ts +++ b/packages/trigger-sdk/test/steering-history-edit-once.test.ts @@ -65,77 +65,136 @@ async function sendAndLand( } const countOf = (hay: string, needle: string) => hay.split(needle).length - 1; -describe("a history edit after a steer was drained", () => { - it("sends the steer to later turns exactly once", { timeout: 30_000 }, async () => { - const chatId = "steer-history-edit-once"; - const toolGate = deferred(); - let toolEntered = false; - const prompts: string[] = []; +type Variant = { prepare?: boolean; compact?: boolean; deleteSteer?: boolean }; - const gateTool = tool({ - description: "blocks until the test opens it", - inputSchema: z.object({ q: z.string() }), - execute: async () => { - toolEntered = true; - await toolGate.promise; - return "ok"; - }, - }); +/** One steered turn with a history edit from onInjected, then a follow-up turn. Returns turn 2's prompt. */ +async function runVariant(chatId: string, v: Variant): Promise { + const toolGate = deferred(); + let toolEntered = false; + const prompts: string[] = []; + let compacted = 0; - let step = 0; - const model = new MockLanguageModelV3({ - doStream: async ({ prompt }) => { - prompts.push(JSON.stringify(prompt)); - const isToolStep = step++ % 2 === 0; - return { - stream: simulateReadableStream({ - chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), - initialDelayInMs: 10, - chunkDelayInMs: 2, - }), - }; - }, - }); + const gateTool = tool({ + description: "blocks until the test opens it", + inputSchema: z.object({ q: z.string() }), + execute: async () => { + toolEntered = true; + await toolGate.promise; + return "ok"; + }, + }); - const agent = chat.agent({ - id: "steer-history-edit-once", - pendingMessages: { - shouldInject: () => true, - // An identity rewrite is enough: any chat.history write sets the - // override that is applied by rebuilding from the UI lane. - onInjected: () => { - chat.history.set(chat.history.all()); - }, - }, - run: async ({ messages, signal }) => - streamText({ - model, - messages, - abortSignal: signal, - ...chat.toStreamTextOptions(), - tools: { gate: gateTool }, - stopWhen: stepCountIs(5), + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(JSON.stringify(prompt)); + const isToolStep = step++ % 2 === 0; + return { + stream: simulateReadableStream({ + chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"), + initialDelayInMs: 10, + chunkDelayInMs: 2, }), - }); + }; + }, + }); + + const agent = chat.agent({ + id: chatId, + pendingMessages: { + shouldInject: () => true, + ...(v.prepare + ? { + prepare: async ({ messages }) => [ + { + role: "system" as const, + content: `[OPERATOR-NOTE] ${messages.map((m) => (m.parts as { text?: string }[]).map((p) => p.text ?? "").join("")).join(" ")}`, + }, + ], + } + : {}), + onInjected: () => { + chat.history.set(chat.history.all().filter((m) => !(v.deleteSteer && m.id === "u-2"))); + }, + }, + ...(v.compact + ? { + compaction: { + shouldCompact: () => compacted === 0, + summarize: async () => { + compacted++; + return "SUMMARY-OF-EVERYTHING"; + }, + }, + } + : {}), + run: async ({ messages, signal }) => + streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + tools: { gate: gateTool }, + stopWhen: stepCountIs(5), + }), + }); + + const harness = mockChatAgent(agent, { chatId }); + try { + const first = harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => toolEntered, "tool entered"); + await sendAndLand(harness, chatId, "steer-me", "u-2"); + toolGate.resolve(); + await first; + await waitFor(() => prompts.length >= 2, "turn 1 done"); + if (v.compact) await waitFor(() => compacted > 0, "compaction ran"); + const promptsAfterTurn1 = prompts.length; + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built"); + return prompts[promptsAfterTurn1]!; + } finally { + toolGate.resolve(); + await harness.close(); + } +} + +describe("a history edit after a steer was drained", () => { + it("sends the steer to later turns exactly once", { timeout: 30_000 }, async () => { + const turn2 = await runVariant("steer-history-edit-once", {}); + expect(countOf(turn2, '"steer-me"')).toBe(1); + }); - const harness = mockChatAgent(agent, { chatId }); - try { - const first = harness.sendMessage(userMessage("m1", "u-1")); - await waitFor(() => toolEntered, "tool entered"); - await sendAndLand(harness, chatId, "steer-me", "u-2"); - toolGate.resolve(); - await first; - await waitFor(() => prompts.length >= 2, "turn 1 done"); - const promptsAfterTurn1 = prompts.length; + it("keeps the prepared form, once", { timeout: 30_000 }, async () => { + /** + * The rebuild converts the UI message, which is the raw form. If the raw + * form is what stays, the model's memory of the instruction differs from + * the one it acted on. If both stay, it is there twice. + */ + const turn2 = await runVariant("steer-history-edit-prepared", { prepare: true }); + expect(countOf(turn2, "[OPERATOR-NOTE] steer-me")).toBe(1); + expect(countOf(turn2, '"steer-me"')).toBe(0); + }); - await harness.sendMessage(userMessage("m3", "u-3")); - await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built"); + it("keeps the steer when compaction also replaces the lane", { timeout: 30_000 }, async () => { + /** + * A model-only compaction replaces the rebuilt lane, raw steer included. + * If reconciliation then withholds the prepared form because the rebuild + * "already had it", the steer is gone from the model lane altogether. + */ + const turn2 = await runVariant("steer-history-edit-compacted", { + prepare: true, + compact: true, + }); + expect(turn2).toContain("SUMMARY-OF-EVERYTHING"); + expect(countOf(turn2, "[OPERATOR-NOTE] steer-me")).toBe(1); + }); - const turn2 = prompts[promptsAfterTurn1]!; - expect(countOf(turn2, '"steer-me"')).toBe(1); - } finally { - toolGate.resolve(); - await harness.close(); - } + it("does not bring back a steer the edit removed", { timeout: 30_000 }, async () => { + /** The edit is the app's decision. Reconciliation must not undo it. */ + const turn2 = await runVariant("steer-history-edit-deleted", { + prepare: true, + deleteSteer: true, + }); + expect(turn2).not.toContain("steer-me"); }); }); From 6482650315530df6b63f7c6a5420f8561f4350a0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 19:39:33 +0100 Subject: [PATCH 31/37] fix(chat): deliver plain onAction replies, and keep compaction through a tool approval Two fixes in one commit because they share a hunk introducing both helpers. A string or assistant UIMessage returned from onAction was documented and did nothing: it reached neither the browser nor the conversation. A plain reply is now normalised into a UI stream before the streamable branch, so one path pipes, captures, commits and snapshots all three return kinds. An action's reply is also appended to the model lane rather than rebuilding it from the UI lane, which undid compaction. A tool-approval response arrives as an update to the existing assistant message, and every site that replaced a message in place rebuilt the model lane from the UI lane: the continuation's turn start, its response commit, the error path, and the accumulator's addResponse. A chat summarised to fit the context window was sent the whole transcript on the next call. The replaced message's run of model messages is now swapped in place, with the old full reconversion as a warned fallback when the lane's tail does not match what that message contributed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a --- .changeset/action-stream-into-conversation.md | 2 +- .changeset/steering-messages-accumulator.md | 2 + docs/ai-chat/actions.mdx | 2 +- packages/trigger-sdk/src/v3/ai.ts | 166 ++++++++++++++++-- .../test/action-plain-replies.test.ts | 121 +++++++++++++ .../trigger-sdk/test/hitl-uncompacts.test.ts | 158 +++++++++++++++++ 6 files changed, 436 insertions(+), 15 deletions(-) create mode 100644 packages/trigger-sdk/test/action-plain-replies.test.ts create mode 100644 packages/trigger-sdk/test/hitl-uncompacts.test.ts diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md index d4aa141a4c6..be3723ef784 100644 --- a/.changeset/action-stream-into-conversation.md +++ b/.changeset/action-stream-into-conversation.md @@ -2,6 +2,6 @@ "@trigger.dev/sdk": patch --- -A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of, and the next turn carried on from the answer it had replaced. +A response returned from `onAction` is now part of the conversation, whether it is a `StreamTextResult`, a `string`, or an assistant `UIMessage`. A `string` or `UIMessage` return was documented but did nothing: it reached neither the browser nor the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of, and the next turn carried on from the answer it had replaced. A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. An action that used to end quietly on a mid-stream failure now surfaces an error to the frontend. It is still an action, not a turn: `onTurnComplete` does not fire for it, the turn count is unchanged, and an instruction injected for the next turn still reaches that turn. diff --git a/.changeset/steering-messages-accumulator.md b/.changeset/steering-messages-accumulator.md index 3b2a6f6883e..52bd67bef38 100644 --- a/.changeset/steering-messages-accumulator.md +++ b/.changeset/steering-messages-accumulator.md @@ -4,4 +4,6 @@ Steering messages injected mid-answer are now part of the conversation, both for your hooks and for the model on later turns. Previously they reached the model for the answer they steered and reached the browser, but nothing else: `onTurnComplete` never saw them, so an app storing its own transcript lost the instruction the answer was shaped by, and it vanished from the conversation on reload. The model also forgot the instruction from the next turn onwards, answering as though the message had never been sent, while the chat UI still showed it. This holds when the steered turn fails part-way, and when `pendingMessages.prepare` reshapes the message: later turns now see the same form the steered turn did, not the original message. +Approving a tool call no longer undoes compaction. A tool-approval continuation used to rebuild the model's context from the full conversation, so a chat that had been summarised to fit the context window was sent the whole transcript again on the next call, and could go over the limit it had just been compacted to avoid. The same applied to a regenerated answer that replaced an existing one. + If you worked around this by saving steering messages as they arrive, in `pendingMessages.onReceived` for example, that write now duplicates the one you get from `newUIMessages`. Drop it, or skip messages you have already stored. diff --git a/docs/ai-chat/actions.mdx b/docs/ai-chat/actions.mdx index a3b5787b71e..c16b662ec0e 100644 --- a/docs/ai-chat/actions.mdx +++ b/docs/ai-chat/actions.mdx @@ -54,7 +54,7 @@ export const myChat = chat.agent({ ## Returning a model response from an action -`onAction` can return a `StreamTextResult`, `string`, or `UIMessage` to produce a response. The returned stream is auto-piped to the frontend just like a normal turn, but the rest of the turn machinery (`onTurnStart`, `onTurnComplete`, etc.) still does not fire. +`onAction` can return a `StreamTextResult`, `string`, or `UIMessage` to produce a response. All three are sent to the frontend and added to the conversation just like a normal turn's answer, but the rest of the turn machinery (`onTurnStart`, `onTurnComplete`, etc.) still does not fire. A returned `UIMessage` must have `role: "assistant"`; its text and `data-*` parts are delivered, and other part types are dropped. ```ts onAction: async ({ action, messages }) => { diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 56fb87edb7e..2f7879a1e93 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -5003,6 +5003,94 @@ type UIMessageStreamable = { toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; }; +/** + * A plain `onAction` reply (`string` or assistant `UIMessage`) as a stream, so + * it takes the same path as a `StreamTextResult`: piped to the browser, + * captured, committed to the conversation, snapshotted. Text and `data-*` + * parts are emitted; anything else in a supplied message is dropped, since a + * tool part with no execution behind it cannot be replayed as chunks. + */ +function plainReplyAsStream(value: unknown): UIMessageStreamable | undefined { + let message: UIMessage | undefined; + if (typeof value === "string") { + message = { + id: generateMessageId(), + role: "assistant", + parts: [{ type: "text", text: value }], + } as UIMessage; + } else if ( + typeof value === "object" && + value !== null && + (value as UIMessage).role === "assistant" && + Array.isArray((value as UIMessage).parts) + ) { + const m = value as UIMessage; + message = { ...m, id: m.id || generateMessageId() }; + } + if (!message) return undefined; + + const chunks: Record[] = [{ type: "start", messageId: message.id }]; + let n = 0; + for (const part of message.parts as { + type: string; + text?: string; + data?: unknown; + id?: string; + }[]) { + if (part.type === "text") { + const id = `t${n++}`; + chunks.push( + { type: "text-start", id }, + { type: "text-delta", id, delta: part.text ?? "" }, + { type: "text-end", id } + ); + } else if (part.type.startsWith("data-")) { + chunks.push({ type: part.type, id: part.id, data: part.data }); + } + } + chunks.push({ type: "finish" }); + + return { + toUIMessageStream: () => + new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(c); + controller.close(); + }, + }), + } as unknown as UIMessageStreamable; +} + +/** + * Replace, in a model lane, the run of messages one UI message contributed. + * + * Used when a UI message is replaced in place (a tool-approval continuation + * merging onto the trailing assistant, a captured response reusing an existing + * id, a partial replacing an existing message). Reconverting the whole lane + * from the UI lane would also replace a compaction summary with the full + * transcript and drop the model forms `pendingMessages.prepare` produced. + * + * The replaced message is the trailing one, so its run is the lane's tail, + * before any steer forms appended after it this turn (`tailAfter`). If the + * tail does not match the old message's conversion, nothing is changed and + * `false` is returned so the caller can fall back to a full reconversion. + */ +async function replaceModelRun( + lane: ModelMessage[], + oldUi: UIMessage, + newUi: UIMessage, + tailAfter: number +): Promise { + const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]); + const newRun = await toModelMessages([stripProviderMetadata(newUi)]); + const end = lane.length - tailAfter; + const start = end - oldRun.length; + if (start < 0 || end > lane.length) return false; + if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun)) return false; + lane.splice(start, oldRun.length, ...newRun); + return true; +} + function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { return ( typeof value === "object" && @@ -8060,6 +8148,7 @@ function chatAgent< // where AI SDK regenerates the id (TRI-9137) still // applies via `rewriteIncomingIdViaToolCallMap`. let replaced = false; + const replacedPairs: { previous: TUIMessage; merged: TUIMessage }[] = []; for (const raw of cleanedUIMessages) { let incoming = raw; let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id); @@ -8071,10 +8160,12 @@ function chatAgent< } } if (idx !== -1) { + const previous = accumulatedUIMessages[idx]!; accumulatedUIMessages[idx] = mergeIncomingIntoHydrated( - accumulatedUIMessages[idx]!, + previous, incoming ) as TUIMessage; + replacedPairs.push({ previous, merged: accumulatedUIMessages[idx]! }); replaced = true; } else { accumulatedUIMessages.push(incoming as TUIMessage); @@ -8083,9 +8174,19 @@ function chatAgent< recordToolCallIdsFromMessage(incoming); } if (replaced) { - // Replacement changes structure — reconvert all model - // messages instead of appending. - accumulatedMessages = await toModelMessages(accumulatedUIMessages); + let inPlace = true; + for (const { previous, merged } of replacedPairs) { + if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) { + inPlace = false; + break; + } + } + if (!inPlace) { + logger.warn( + "chat.agent: replaced message not found at the model lane tail; reconverting the lane" + ); + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + } } else { const incomingModelMessages = await toModelMessages(cleanedUIMessages); accumulatedMessages.push(...incomingModelMessages); @@ -8145,6 +8246,9 @@ function chatAgent< if (isAction) { msgSub?.off(); + // A documented plain reply takes the streamed reply's path. + actionStreamResult = plainReplyAsStream(actionStreamResult) ?? actionStreamResult; + if ( (locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(actionStreamResult) @@ -8186,10 +8290,17 @@ function chatAgent< : -1; if (existingIdx !== -1) { accumulatedUIMessages[existingIdx] = actionResponse as TUIMessage; + // Replacing an existing message has no in-place model + // form to swap, so this path still reconverts. + accumulatedMessages = await toModelMessages(accumulatedUIMessages); } else { accumulatedUIMessages.push(actionResponse as TUIMessage); + // Appended, not reconverted: a reconversion from the UI + // lane would undo a model-only compaction summary. + accumulatedMessages.push( + ...(await toModelMessages([stripProviderMetadata(actionResponse)])) + ); } - accumulatedMessages = await toModelMessages(accumulatedUIMessages); locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); actionChangedHistory = true; } @@ -8625,7 +8736,9 @@ function chatAgent< // before the response is appended so the order stays // steer-then-answer. Outside the `capturedResponseMessage` // branches below, so a turn that captured no response is covered. - reconcilePendingSteer({ turnNew: turnNewModelMessages }); + const steerTailThisTurn = reconcilePendingSteer({ + turnNew: turnNewModelMessages, + }).reduce((n, e) => n + e.model.length, 0); // Append the assistant's response (partial or complete) to the accumulator. // The onFinish callback fires even on abort/stop, so partial responses @@ -8664,6 +8777,8 @@ function chatAgent< const existingIdx = capturedResponseMessage.id ? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage!.id) : -1; + const previousAtIdx = + existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined; if (existingIdx !== -1) { accumulatedUIMessages[existingIdx] = capturedResponseMessage; } else { @@ -8682,8 +8797,20 @@ function chatAgent< stripProviderMetadata(capturedResponseMessage), ]); if (existingIdx !== -1) { - // Reconvert all model messages since we replaced rather than appended - accumulatedMessages = await toModelMessages(accumulatedUIMessages); + const ok = + previousAtIdx !== undefined && + (await replaceModelRun( + accumulatedMessages, + previousAtIdx, + capturedResponseMessage, + steerTailThisTurn + )); + if (!ok) { + logger.warn( + "chat.agent: replaced response not found at the model lane tail; reconverting the lane" + ); + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + } } else { accumulatedMessages.push(...responseModelMessages); } @@ -9319,7 +9446,18 @@ function chatAgent< ...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))) ); } else { - accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial); + const ok = await replaceModelRun( + accumulatedMessages, + erroredUIMessages[partialIdx]!, + partialResponse!, + reconciledSteer.reduce((n, e) => n + e.model.length, 0) + ); + if (!ok) { + logger.warn( + "chat.agent: replaced partial not found at the model lane tail; reconverting the lane" + ); + accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial); + } } accumulatedUIMessages = erroredUIMessagesWithPartial; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); @@ -10725,12 +10863,14 @@ class ChatMessageAccumulator { // a duplicate, mirroring the chat.agent accumulator. const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id); if (existingIdx !== -1) { + const previous = this.uiMessages[existingIdx]!; this.uiMessages[existingIdx] = response; try { - // Reconvert all model messages since we replaced rather than appended. - this.modelMessages = await toModelMessages( - this.uiMessages.map((m) => stripProviderMetadata(m)) - ); + if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) { + this.modelMessages = await toModelMessages( + this.uiMessages.map((m) => stripProviderMetadata(m)) + ); + } } catch { // Conversion failed — leave the existing model messages in place } diff --git a/packages/trigger-sdk/test/action-plain-replies.test.ts b/packages/trigger-sdk/test/action-plain-replies.test.ts new file mode 100644 index 00000000000..5a98f0c82f2 --- /dev/null +++ b/packages/trigger-sdk/test/action-plain-replies.test.ts @@ -0,0 +1,121 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, streamText } from "ai"; +import type { UIMessage } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * `onAction` is documented to accept a `string` or a `UIMessage` as a reply, + * not only a stream. Each has to reach the browser, the conversation the next + * turn is built from, and the snapshot, the same as a streamed reply does. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; +const textOf = (m: { parts?: unknown[] }) => + ((m.parts ?? []) as { type: string; text?: string }[]) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); + +async function runAction(chatId: string, reply: () => unknown, opts?: { compact?: boolean }) { + const prompts: string[] = []; + let compacted = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(JSON.stringify(prompt)); + return { + stream: simulateReadableStream({ chunks: textChunks("first answer"), initialDelayInMs: 5 }), + }; + }, + }); + const agent = chat.agent({ + id: chatId, + ...(opts?.compact + ? { + compaction: { + shouldCompact: ({ source }) => source === "outer" && compacted === 0, + summarize: async () => { + compacted++; + return "SUMMARY-OF-EVERYTHING"; + }, + }, + } + : {}), + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("note") })]), + onAction: async ({ action }) => (action.type === "note" ? reply() : undefined), + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId }); + try { + await harness.sendMessage(userMessage("m1", "u-1")); + if (opts?.compact) { + const start = Date.now(); + while (compacted === 0 && Date.now() - start < 5000) + await new Promise((r) => setTimeout(r, 10)); + } + await harness.sendAction({ type: "note" }); + await new Promise((r) => setTimeout(r, 80)); + const streamed = (harness.allRawChunks as { type?: string; delta?: string }[]) + .filter((c) => c.type === "text-delta") + .map((c) => c.delta ?? "") + .join(""); + const snapshot = (harness.getSnapshot()?.messages ?? []).map(textOf); + const promptsBefore = prompts.length; + await harness.sendMessage(userMessage("m2", "u-2")); + return { streamed, snapshot, nextPrompt: prompts[promptsBefore]! }; + } finally { + await harness.close(); + } +} + +describe("a plain reply from onAction", () => { + it("delivers a returned string like a streamed reply", { timeout: 30_000 }, async () => { + const r = await runAction("action-string-reply", () => "NOTE-FROM-ACTION"); + expect(r.streamed).toContain("NOTE-FROM-ACTION"); + expect(r.snapshot.at(-1)).toBe("NOTE-FROM-ACTION"); + expect(r.nextPrompt).toContain("NOTE-FROM-ACTION"); + }); + + it("delivers a returned UIMessage like a streamed reply", { timeout: 30_000 }, async () => { + const message = { + id: "a-note", + role: "assistant", + parts: [{ type: "text", text: "UIMESSAGE-FROM-ACTION" }], + } as UIMessage; + const r = await runAction("action-uimessage-reply", () => message); + expect(r.streamed).toContain("UIMESSAGE-FROM-ACTION"); + expect(r.snapshot.at(-1)).toBe("UIMESSAGE-FROM-ACTION"); + expect(r.nextPrompt).toContain("UIMESSAGE-FROM-ACTION"); + }); + + it("is appended to a compacted lane rather than rebuilding it", { timeout: 30_000 }, async () => { + /** + * Compaction is model-only. Committing the reply by reconverting the UI + * lane would put the message compaction removed back in front of the model. + */ + const r = await runAction("action-reply-after-compaction", () => "NOTE-AFTER-COMPACTION", { + compact: true, + }); + expect(r.nextPrompt).toContain("SUMMARY-OF-EVERYTHING"); + expect(r.nextPrompt).toContain("NOTE-AFTER-COMPACTION"); + expect(r.nextPrompt).not.toContain("first answer"); + }); +}); diff --git a/packages/trigger-sdk/test/hitl-uncompacts.test.ts b/packages/trigger-sdk/test/hitl-uncompacts.test.ts new file mode 100644 index 00000000000..62130beba23 --- /dev/null +++ b/packages/trigger-sdk/test/hitl-uncompacts.test.ts @@ -0,0 +1,158 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream, streamText, tool } from "ai"; +import type { UIMessage } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * A tool-approval continuation after compaction. + * + * Compaction is model-only: the model lane becomes a summary while the UI + * lane keeps everything. A tool-approval response arrives as an update to the + * existing assistant message, and that path rebuilds the model lane from the + * UI lane. The summary is replaced by the full transcript, and the message + * compaction had removed is sent to the model again. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, + totalTokens: 2, +}; +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; +const approvalToolCall = (callId: string): LanguageModelV3StreamPart[] => [ + { + type: "tool-call", + toolCallId: callId, + toolName: "risky", + input: JSON.stringify({ what: "x" }), + }, + { type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage: USAGE }, +]; + +describe("a tool-approval turn after compaction", () => { + it("keeps the summary in the model lane", { timeout: 30_000 }, async () => { + const prompts: string[] = []; + const turns: UIMessage[][] = []; + let compacted = 0; + + const risky = tool({ + description: "needs a human to approve", + inputSchema: z.object({ what: z.string() }), + needsApproval: true, + execute: async () => "done", + }); + + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(JSON.stringify(prompt)); + const n = step++; + // turn 0 answers; turn 1 asks for approval; the continuation answers. + const chunks = n === 1 ? approvalToolCall("tc-1") : textChunks(`answer-${n}`); + return { stream: simulateReadableStream({ chunks, initialDelayInMs: 5 }) }; + }, + }); + + const agent = chat.agent({ + id: "hitl-uncompacts", + compaction: { + // Compact once, between turns 0 and 1. + shouldCompact: ({ source }) => source === "outer" && compacted === 0, + summarize: async () => { + compacted++; + return "SUMMARY-OF-EVERYTHING"; + }, + }, + onTurnComplete: async ({ uiMessages }) => { + turns.push(uiMessages.map((m) => structuredClone(m))); + }, + run: async ({ messages, signal }) => + streamText({ + model, + messages, + abortSignal: signal, + tools: { risky }, + ...chat.toStreamTextOptions(), + }), + }); + + const harness = mockChatAgent(agent, { chatId: "hitl-uncompacts" }); + try { + await harness.sendMessage(userMessage("EARLY-SENTINEL", "u-1")); + await waitFor(() => turns.length >= 1 && compacted > 0, "turn 0 + compaction"); + + await harness.sendMessage(userMessage("please do the risky thing", "u-2")); + await waitFor(() => turns.length >= 2, "turn 1 (approval requested)"); + + // The summary is in force going into the approval turn. + expect(prompts.at(-1)!).toContain("SUMMARY-OF-EVERYTHING"); + expect(prompts.at(-1)!).not.toContain("EARLY-SENTINEL"); + + // Approve, as the browser would: a slim update to the existing assistant. + const head = turns.at(-1)!.at(-1)!; + const part = ( + head.parts as { + type: string; + toolCallId?: string; + state?: string; + approval?: { id: string }; + }[] + ).find((p) => p.type === "tool-risky"); + expect(part?.state).toBe("approval-requested"); + // sendMessage resolves at turn-complete, so the continuation's prompt is + // recorded by the time it returns; capture the index first. + const promptsBefore = prompts.length; + await harness.sendMessage({ + id: head.id, + role: "assistant", + parts: [ + { + type: "tool-risky", + toolCallId: part!.toolCallId!, + state: "approval-responded", + approval: { id: part!.approval!.id, approved: true }, + }, + ], + } as unknown as UIMessage); + // The continuation has to run against the compacted lane, not the + // whole transcript that compaction had already replaced. + const cont = prompts[promptsBefore]!; + expect(cont).toContain("SUMMARY-OF-EVERYTHING"); + expect(cont).not.toContain("EARLY-SENTINEL"); + + // And the turn after it: the continuation's own response is committed by + // replacing the approval-requested assistant, and that path must not + // reconvert the lane either. + const promptsBeforeNext = prompts.length; + await harness.sendMessage(userMessage("and then?", "u-3")); + const next = prompts[promptsBeforeNext]!; + expect(next).toContain("SUMMARY-OF-EVERYTHING"); + expect(next).not.toContain("EARLY-SENTINEL"); + } finally { + await harness.close(); + } + }); +}); From 472eaf494440d5c5f0979ca84f587d203c870070 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 23:45:02 +0100 Subject: [PATCH 32/37] fix(chat): keep an instruction injected after an action for the next turn An action and the real turn after it share a turn number. If the action's option build had already taken the instruction stash for that number, an instruction injected in between waited for the turn after. Anything injected since is now spliced into the stash when it is reused. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a --- .changeset/inject-system-to-instructions.md | 2 +- packages/trigger-sdk/src/v3/ai.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/inject-system-to-instructions.md b/.changeset/inject-system-to-instructions.md index f5c2e5993b4..020d6ab4a0b 100644 --- a/.changeset/inject-system-to-instructions.md +++ b/.changeset/inject-system-to-instructions.md @@ -4,4 +4,4 @@ `chat.inject()` with `role: "system"` now works. It previously put the system message into the conversation, which AI SDK 7 rejects for every provider: the next turn died with a generic "An error occurred." and persisted an empty assistant message, so the agent looked like it had stopped answering. System-role context is now appended to the model's instructions, which is also the only way to inject context the agent treats as trusted. -Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it does not receive a system-role injection. The conversational lane has no such requirement. And an injection applies to the next turn only, rather than repeating on every turn that follows it. Every inference call in that turn sees it, so a `run()` that builds options more than once gets the same instructions each time. +Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it does not receive a system-role injection. The conversational lane has no such requirement. And an injection applies to the next turn only, rather than repeating on every turn that follows it. Every inference call in that turn sees it, so a `run()` that builds options more than once gets the same instructions each time. An instruction injected after an action has run, and before the next message, reaches that next turn rather than the one after it. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 2f7879a1e93..f198f9e977c 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -4820,6 +4820,12 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record 0) { + injectedBlocks.push(...injectedInstructions.splice(0)); + } } else if (injectedInstructions && injectedInstructions.length > 0) { injectedBlocks = injectedInstructions.splice(0); if (currentTurn !== undefined) { From 023432dfd9912ea344d82d490bd555fa0d4d4bff Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 23:45:03 +0100 Subject: [PATCH 33/37] feat(chat): send actions through useChat so their turns render TriggerChatTransport recognises body.action on a useChat request and sends it as an action, so useChat owns the response and a turn that follows the action renders like a message turn. useChatActions is a thin wrapper over sendMessage(undefined, { body: { action } }). transport.sendAction is unchanged for callers outside useChat and takes an optional abortSignal. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a --- .changeset/use-chat-actions.md | 13 +++++++ docs/ai-chat/frontend.mdx | 31 ++++++++-------- packages/trigger-sdk/src/v3/chat-react.ts | 43 +++++++++++++++++++++++ packages/trigger-sdk/src/v3/chat.test.ts | 36 +++++++++++++++++++ packages/trigger-sdk/src/v3/chat.ts | 17 +++++++-- 5 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 .changeset/use-chat-actions.md diff --git a/.changeset/use-chat-actions.md b/.changeset/use-chat-actions.md new file mode 100644 index 00000000000..b0ff9b0ffd9 --- /dev/null +++ b/.changeset/use-chat-actions.md @@ -0,0 +1,13 @@ +--- +"@trigger.dev/sdk": minor +--- + +Actions are sent through `useChat` so a turn that follows one renders like any turn. `TriggerChatTransport` recognises `body.action` on a `useChat` request and sends it as an action, so `sendMessage(undefined, { body: { action } })` or `regenerate({ body: { action } })` sends the action and `useChat` owns the response: it streams into the message list, `status` and `error` behave as for a message, and `stop` works. `useChatActions({ sendMessage })` in `@trigger.dev/sdk/chat/react` is a two-line convenience over that. + +```tsx +const { sendMessage } = useChat({ id: chatId, transport }); +const { sendAction } = useChatActions({ sendMessage }); +sendAction({ type: "regenerate" }); +``` + +Previously the frontend docs said `useChat` consumed the stream `transport.sendAction` returns; it never did, so an action's answer was never rendered by an app following them. `transport.sendAction` is unchanged for callers outside `useChat` and still returns a stream the caller must read. diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx index 8d5e0be4a93..35259b3e3c9 100644 --- a/docs/ai-chat/frontend.mdx +++ b/docs/ai-chat/frontend.mdx @@ -442,45 +442,48 @@ function Chat({ chatId, transport }) { ## Sending actions -Send custom actions (undo, rollback, edit) to the agent via `transport.sendAction()`. Actions wake the agent and fire only `hydrateMessages` (if configured) and `onAction` — they're not turns, so `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete` and `run()` do not fire. - -For optimistic UI, mirror the action's effect on the `useChat` state via `setMessages` while the request is in flight: +Send custom actions (undo, rollback, edit, regenerate) as `useChat` requests, with the action in the request `body`. The transport recognises `body.action` and sends it as an action rather than a message, and because `useChat` made the request it owns the response: an action that returns `chat.turn()` on the server streams its answer into the message list like any turn, with `status`, `error` and `stop` behaving as for a message. An action that returns nothing completes with no message added. ```tsx +import { useChat } from "@ai-sdk/react"; +import { useChatActions, useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; + function ChatControls({ chatId }: { chatId: string }) { const transport = useTriggerChatTransport({ task: "my-chat", accessToken: ({ chatId }) => mintChatAccessToken(chatId), - startSession: ({ chatId, clientData }) => - startChatSession({ chatId, clientData }), + startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }), }); - - const { setMessages } = useChat({ transport }); + const { sendMessage, regenerate, setMessages } = useChat({ id: chatId, transport }); + const { sendAction } = useChatActions({ sendMessage }); return (
-
); } ``` -The action payload is validated against the agent's `actionSchema` on the backend — invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup. +`useChatActions` is a two-line convenience over `sendMessage(undefined, { body: { action } })`. Any `useChat` request can carry an action the same way, `regenerate({ body })` included. + +The action payload is validated against the agent's `actionSchema` on the backend; invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup. - `sendAction` returns a `ReadableStream`. For side-effect-only actions (where `onAction` returns `void`), the stream completes immediately with `trigger:turn-complete`. For actions where `onAction` returns a `StreamTextResult`, the stream carries the assistant chunks the same way `sendMessages` does — `useChat` consumes them automatically. + `transport.sendAction()` still exists for callers outside `useChat` (server to server, or a custom client). It returns the response as a raw `ReadableStream` that the caller must read; `useChat` does not consume it. For server-to-server usage, `AgentChat` has the same method: diff --git a/packages/trigger-sdk/src/v3/chat-react.ts b/packages/trigger-sdk/src/v3/chat-react.ts index b6823d6dc21..be28d4881d9 100644 --- a/packages/trigger-sdk/src/v3/chat-react.ts +++ b/packages/trigger-sdk/src/v3/chat-react.ts @@ -471,3 +471,46 @@ export function usePendingMessages( getInjectedMessages, }; } + +/** + * Send actions through `useChat`, so an action that returns `chat.turn()` on + * the server renders like any turn. + * + * `transport.sendAction` returns the response as a stream that `useChat` never + * reads. This hook sends the action as a `useChat` request instead (the + * transport recognises `body.action` and sends it as an action), so `useChat` + * owns the response: it streams into the message list, `status` and `error` + * behave as for a message, and `stop` works. An action that returns nothing on + * the server completes with no message added. + * + * History changes the action makes on the server (an undo removing messages, + * for example) are not mirrored automatically; apply those with `setMessages` + * optimistically, as before. + * + * @example + * ```tsx + * const transport = useTriggerChatTransport({ task: "my-chat", accessToken }); + * const { messages, sendMessage, setMessages } = useChat({ id: chatId, transport }); + * const { sendAction } = useChatActions({ sendMessage }); + * + * + * ``` + */ +export function useChatActions(options: { + /** `useChat`'s `sendMessage`. */ + sendMessage: ( + message: undefined, + options?: { body?: object; headers?: Record | Headers } + ) => Promise | void; +}): { + /** Send an action; resolves when `useChat` has finished the request. */ + sendAction: (action: unknown) => Promise; +} { + const { sendMessage } = options; + const sendMessageRef = useRef(sendMessage); + sendMessageRef.current = sendMessage; + const sendAction = useCallback(async (action: unknown) => { + await sendMessageRef.current(undefined, { body: { action } }); + }, []); + return { sendAction }; +} diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index e3fb3ad4b9a..473d0fa91cd 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -982,6 +982,42 @@ describe("TriggerChatTransport", () => { expect(actionBody.payload.action).toEqual({ type: "undo" }); }); + it("sends a useChat request carrying body.action as an action", async () => { + // `useChatActions` and `regenerate({ body })` reach the transport through + // `sendMessages`; the action has to go out as an action, not a message, + // and the response comes back on the request useChat made. + let actionBody: any; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + actionBody = JSON.parse(init!.body as string); + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { "chat-act-body": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-act-body", + messageId: undefined, + messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }], + abortSignal: undefined, + body: { action: { type: "regenerate" } }, + }); + await drainChunks(stream); + + expect(actionBody.payload.trigger).toBe("action"); + expect(actionBody.payload.action).toEqual({ type: "regenerate" }); + expect(actionBody.payload.message).toBeUndefined(); + }); + it("marks the session streaming and notifies before subscribing", async () => { global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { const urlStr = typeof url === "string" ? url : url.toString(); diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 531bc36b3d2..1a12d2ebfb6 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -802,6 +802,15 @@ export class TriggerChatTransport implements ChatTransport { ? { ...(this.defaultMetadata ?? {}), ...((metadata as Record) ?? {}) } : undefined; + // An action sent through `useChat`. `useChatActions` (or any caller) puts + // it in `body.action`; sending it here rather than through + // `transport.sendAction` means `useChat` owns the response stream, so an + // action that becomes a turn renders the way a message turn does. + const actionInBody = (body as { action?: unknown } | undefined)?.action; + if (actionInBody !== undefined) { + return this.sendAction(chatId, actionInBody, { abortSignal }); + } + // First-turn handover routing — when `headStart` is set AND no // session state exists yet for this chatId, POST the wire payload // to the customer's `chat.handover` route handler. The handler @@ -1257,7 +1266,11 @@ export class TriggerChatTransport implements ChatTransport { * `StreamTextResult`); for `void`-returning side-effect-only actions * the stream completes immediately with `trigger:turn-complete`. */ - sendAction = async (chatId: string, action: unknown): Promise> => { + sendAction = async ( + chatId: string, + action: unknown, + options?: { abortSignal?: AbortSignal } + ): Promise> => { if (this.coordinator) { if (this.coordinator.isReadOnly(chatId)) { throw new Error("This chat is active in another tab"); @@ -1306,7 +1319,7 @@ export class TriggerChatTransport implements ChatTransport { this.notifySessionChange(chatId, state); // Owning action: aborting this send stops the turn the user drives. - return this.subscribeToSessionStream(state, undefined, chatId, { + return this.subscribeToSessionStream(state, options?.abortSignal, chatId, { sinceInSeq: inSeq, sendStopOnAbort: true, }); From d332a2adc3a072509f28d3ea8e9d021772f53c63 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Sep 2026 23:45:28 +0100 Subject: [PATCH 34/37] feat(chat): let an action become a turn with chat.turn() An action is a state edit. To answer after the edit, onAction returns chat.turn() and a turn runs on the edited history with everything a turn has: system prompt, tools, steering, compaction, instructions, hooks, numbering and persistence. Returning a StreamTextResult, string or UIMessage is no longer supported and fails with a pointer to chat.turn(). The response path this replaces was a turn without a turn's guarantees: each had to be re-added by hand, and the browser never rendered the result reliably. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a --- .changeset/action-stream-into-conversation.md | 18 +- packages/trigger-sdk/src/v3/ai.ts | 222 +++++------------- .../test/action-failure-not-a-turn.test.ts | 117 --------- .../test/action-plain-replies.test.ts | 121 ---------- .../test/action-stream-accumulator.test.ts | 195 ++++++--------- packages/trigger-sdk/test/action-turn.test.ts | 156 ++++++++++++ .../test/instructions-action-replay.test.ts | 163 ++++++------- .../trigger-sdk/test/mockChatAgent.test.ts | 31 +-- 8 files changed, 410 insertions(+), 613 deletions(-) delete mode 100644 packages/trigger-sdk/test/action-failure-not-a-turn.test.ts delete mode 100644 packages/trigger-sdk/test/action-plain-replies.test.ts create mode 100644 packages/trigger-sdk/test/action-turn.test.ts diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md index be3723ef784..a3607aaed83 100644 --- a/.changeset/action-stream-into-conversation.md +++ b/.changeset/action-stream-into-conversation.md @@ -1,7 +1,19 @@ --- -"@trigger.dev/sdk": patch +"@trigger.dev/sdk": minor --- -A response returned from `onAction` is now part of the conversation, whether it is a `StreamTextResult`, a `string`, or an assistant `UIMessage`. A `string` or `UIMessage` return was documented but did nothing: it reached neither the browser nor the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of, and the next turn carried on from the answer it had replaced. +Actions can now become turns. `onAction` edits history with `chat.history`; to answer after the edit, return `chat.turn()` and a turn runs on the edited history with everything a turn has: the agent's system prompt and tools, steering, compaction, injected instructions, `onTurnStart` and `onTurnComplete`, and persistence. A regenerate is `chat.history.slice(0, -1); return chat.turn();`. -A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer. An action that used to end quietly on a mid-stream failure now surfaces an error to the frontend. It is still an action, not a turn: `onTurnComplete` does not fire for it, the turn count is unchanged, and an instruction injected for the next turn still reaches that turn. +```ts +onAction: async ({ action }) => { + if (action.type === "regenerate") { + chat.history.slice(0, -1); + return chat.turn(); + } + if (action.type === "undo") chat.history.slice(0, -2); // edit only +}, +``` + +Returning a `StreamTextResult`, `string` or `UIMessage` from `onAction` is no longer supported and now fails with an error pointing to `chat.turn()`. A response produced that way skipped every turn guarantee, and its delivery to the browser was unreliable: the frontend never read the stream `transport.sendAction` returned, so a regenerate that appeared to work on the server did not render. The `onAction` event no longer carries `streamText` or `tools`, since the handler no longer calls the model. + +History edits made by an action are still persisted as before: platform-managed snapshots are written after the edit, and apps with their own store mirror the edit themselves. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index f198f9e977c..c800f3a3583 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -5009,62 +5009,42 @@ type UIMessageStreamable = { toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; }; +const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn"); + /** - * A plain `onAction` reply (`string` or assistant `UIMessage`) as a stream, so - * it takes the same path as a `StreamTextResult`: piped to the browser, - * captured, committed to the conversation, snapshotted. Text and `data-*` - * parts are emitted; anything else in a supplied message is dropped, since a - * tool part with no execution behind it cannot be replayed as chunks. - */ -function plainReplyAsStream(value: unknown): UIMessageStreamable | undefined { - let message: UIMessage | undefined; - if (typeof value === "string") { - message = { - id: generateMessageId(), - role: "assistant", - parts: [{ type: "text", text: value }], - } as UIMessage; - } else if ( - typeof value === "object" && - value !== null && - (value as UIMessage).role === "assistant" && - Array.isArray((value as UIMessage).parts) - ) { - const m = value as UIMessage; - message = { ...m, id: m.id || generateMessageId() }; - } - if (!message) return undefined; + * The marker `onAction` returns to have a turn run on the history it just + * edited. Produce it with {@link chat.turn}. + */ +export type ActionTurn = { readonly [actionTurnBrand]: true }; - const chunks: Record[] = [{ type: "start", messageId: message.id }]; - let n = 0; - for (const part of message.parts as { - type: string; - text?: string; - data?: unknown; - id?: string; - }[]) { - if (part.type === "text") { - const id = `t${n++}`; - chunks.push( - { type: "text-start", id }, - { type: "text-delta", id, delta: part.text ?? "" }, - { type: "text-end", id } - ); - } else if (part.type.startsWith("data-")) { - chunks.push({ type: part.type, id: part.id, data: part.data }); - } - } - chunks.push({ type: "finish" }); +/** + * Turn the current action into a turn. + * + * Return it from `onAction` after editing history. The action's own work is + * finished first (the edit is applied and snapshotted), then a turn runs on the + * result exactly as a message turn does: `onTurnStart`, `run()` with the edited + * history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter + * advances. That gives the answer everything a turn has, the system prompt, + * tools, steering, compaction, injected instructions and persistence, with no + * action-specific handling. + * + * @example + * ```ts + * onAction: async ({ action }) => { + * if (action.type === "regenerate") { + * chat.history.slice(0, -1); + * return chat.turn(); + * } + * if (action.type === "undo") chat.history.slice(0, -2); // no turn + * }, + * ``` + */ +function chatTurn(): ActionTurn { + return { [actionTurnBrand]: true } as ActionTurn; +} - return { - toUIMessageStream: () => - new ReadableStream({ - start(controller) { - for (const c of chunks) controller.enqueue(c); - controller.close(); - }, - }), - } as unknown as UIMessageStreamable; +function isActionTurn(value: unknown): value is ActionTurn { + return typeof value === "object" && value !== null && (value as any)[actionTurnBrand] === true; } /** @@ -5858,9 +5838,10 @@ export type ChatAgentOptions< * `onBeforeTurnComplete` / `onTurnComplete`, no `run()`. Use * `chat.history.*` inside `onAction` to mutate state. * - * To produce a model response from an action, return a - * `StreamTextResult` (auto-piped), `string`, or `UIMessage`. Returning - * `void` or nothing is the side-effect-only default. + * To answer after the edit, return `chat.turn()`: the edit is applied and + * snapshotted, then a turn runs on the result with every turn guarantee. An + * action that returns nothing is a state edit only. Returning anything else + * is an error; a response can no longer be returned directly. */ onAction?: ( event: ActionEvent< @@ -5868,7 +5849,7 @@ export type ChatAgentOptions< inferSchemaOut, TUIMessage > - ) => Promise | unknown; + ) => Promise | void | ActionTurn; /** * The tools available to this agent. @@ -7880,7 +7861,9 @@ function chatAgent< // an action, return a `StreamTextResult` (auto-piped), // string, or UIMessage from `onAction`. Turn counter // does not advance. - let actionStreamResult: unknown = undefined; + let actionResult: unknown = undefined; + /** Set when `onAction` returned `chat.turn()`: the turn block below runs. */ + let actionTurn = false; /** * Whether this action changed the conversation, by rolling history * back or by streaming a response. Drives the single snapshot write @@ -7927,7 +7910,7 @@ function chatAgent< // Fire onAction — handler may mutate state via // `chat.history.*` and / or return a model response. if (onAction) { - actionStreamResult = await tracer.startActiveSpan( + actionResult = await tracer.startActiveSpan( "onAction()", async () => { return await onAction({ @@ -8250,110 +8233,29 @@ function chatAgent< // The turn counter is decremented so the next iteration // sees the same `turn` value — actions don't count. if (isAction) { - msgSub?.off(); - - // A documented plain reply takes the streamed reply's path. - actionStreamResult = plainReplyAsStream(actionStreamResult) ?? actionStreamResult; - - if ( - (locals.get(chatPipeCountKey) ?? 0) === 0 && - isUIMessageStreamable(actionStreamResult) - ) { - try { - /** - * Captured, not just piped. The stream reaching the browser was - * never the problem — the problem was that it stopped there, so - * the user read an answer the accumulator had no record of and - * the next turn contradicted the screen. Worst on regenerate, - * which removes the old answer and used to leave nothing in its - * place. - * - * Persistence beyond the snapshot is still the app's job: an - * action fires no `onTurnComplete`, so an app owning its own - * store has to write the row itself — `chat.pipeAndCapture` - * hands back the same message for that. - */ - const captured = await pipeChatAndCapture( - actionStreamResult as UIMessageStreamable, - { signal: combinedSignal, spanName: "stream response" } - ); - - if (runSignal.aborted) return "exit"; - - /** - * A stopped action still commits what streamed, cleaned: - * incomplete tool and text parts left mid-flight are what - * strand the UI on a spinner forever once persisted. - */ - const actionResponse = - captured.status === "complete" || !captured.message - ? captured.message - : cleanupAbortedParts(captured.message); - - if (actionResponse) { - const existingIdx = actionResponse.id - ? accumulatedUIMessages.findIndex((m) => m.id === actionResponse.id) - : -1; - if (existingIdx !== -1) { - accumulatedUIMessages[existingIdx] = actionResponse as TUIMessage; - // Replacing an existing message has no in-place model - // form to swap, so this path still reconverts. - accumulatedMessages = await toModelMessages(accumulatedUIMessages); - } else { - accumulatedUIMessages.push(actionResponse as TUIMessage); - // Appended, not reconverted: a reconversion from the UI - // lane would undo a model-only compaction summary. - accumulatedMessages.push( - ...(await toModelMessages([stripProviderMetadata(actionResponse)])) - ); - } - locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); - actionChangedHistory = true; - } - - /** - * Reported after the partial is committed, not instead of it. - * `pipeChatAndCapture` returns a stream failure rather than - * throwing, so without this a mid-stream failure writes a - * normal turn-complete and the truncated answer is persisted - * as if it were finished — the next turn then builds on it. - */ - if (captured.status === "error") throw captured.error; - } catch (error) { - if ( - error instanceof Error && - error.name === "AbortError" && - runSignal.aborted - ) { - return "exit"; - } - // Reported here rather than rethrown: the shared catch - // below is the turn-error path, and it would fire - // onTurnComplete, keep the turn number and consume the - // one-shot instruction lane, none of which an action does. - try { - await withChatWriter(async (writer) => { - const errorText = - error instanceof Error ? error.message : "An unexpected error occurred"; - writer.write({ type: "error", errorText } as any); - }); - } catch { - // best effort - } + if (isActionTurn(actionResult)) { + // The edit is in the accumulators; the turn block below + // runs on it and does its own persistence, hooks and + // completion, so nothing more happens here. + actionTurn = true; + } else if (actionResult !== undefined) { + throw new Error( + "chat.agent: onAction returned a value. An action is a state edit; to answer " + + "after the edit, return chat.turn() and a turn runs on the edited history. " + + "Returning a StreamTextResult, string or UIMessage is no longer supported." + ); + } else { + msgSub?.off(); + if (actionChangedHistory) { + await writeSnapshotOutsideTurn("action"); } + await writeTurnCompleteChunk(currentWirePayload.chatId); + // Don't consume a turn iteration — actions aren't turns. + turn--; } - - if (actionChangedHistory) { - await writeSnapshotOutsideTurn("action"); - } - - await writeTurnCompleteChunk(currentWirePayload.chatId); - - // Don't consume a turn iteration — actions aren't turns. - turn--; } - if (!isAction) { + if (!isAction || actionTurn) { // Mint a scoped public access token once per turn, reused for // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk. const currentRunId = ctx.run.id; @@ -12420,6 +12322,8 @@ export const chat = { createStartSessionAction: createChatStartSessionAction, /** Pipe a stream to the chat transport. See {@link pipeChat}. */ pipe: pipeChat, + /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */ + turn: chatTurn, /** Create a per-run typed local. See {@link chatLocal}. */ local: chatLocal, /** Create a public access token for a chat task. See {@link createChatAccessToken}. */ diff --git a/packages/trigger-sdk/test/action-failure-not-a-turn.test.ts b/packages/trigger-sdk/test/action-failure-not-a-turn.test.ts deleted file mode 100644 index 54a907b4bfd..00000000000 --- a/packages/trigger-sdk/test/action-failure-not-a-turn.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { mockChatAgent } from "../src/v3/test/index.js"; - -import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { simulateReadableStream, streamText } from "ai"; -import { MockLanguageModelV3 } from "ai/test"; -import { describe, expect, it } from "vitest"; -import { z } from "zod"; -import { chat } from "../src/v3/ai.js"; - -/** - * An action whose stream fails is still an action, not a turn. - * - * Reporting the failure by throwing lands in the shared turn-error path, - * which fires `onTurnComplete`, advances the turn counter and consumes the - * one-shot instruction lane, none of which an action is supposed to do. The - * failure still has to be reported to the client and the partial kept. - */ - -const USAGE = { - inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, - outputTokens: { total: 1, text: 1, reasoning: undefined }, -}; -const userMessage = (text: string, id: string) => ({ - id, - role: "user" as const, - parts: [{ type: "text" as const, text }], -}); -async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (check()) return; - await new Promise((r) => setTimeout(r, 10)); - } - throw new Error(`waitFor timed out: ${label}`); -} -const textChunks = (text: string): LanguageModelV3StreamPart[] => [ - { type: "text-start", id: "t1" }, - { type: "text-delta", id: "t1", delta: text }, - { type: "text-end", id: "t1" }, - { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, -]; - -describe("an action whose stream fails", () => { - it("is reported without being counted as a turn", { timeout: 30_000 }, async () => { - const turnCompletes: { turn: number; finishReason?: string }[] = []; - const turnPrompts: string[] = []; - - const turnModel = new MockLanguageModelV3({ - doStream: async ({ prompt }) => { - turnPrompts.push(JSON.stringify(prompt)); - return { - stream: simulateReadableStream({ chunks: textChunks("answer"), initialDelayInMs: 5 }), - }; - }, - }); - const failingActionModel = new MockLanguageModelV3({ - doStream: async () => ({ - stream: new ReadableStream({ - pull(c) { - c.error(new Error("provider exploded mid-stream")); - }, - }), - }), - }); - - const agent = chat.agent({ - id: "action-failure-not-a-turn", - actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]), - onTurnComplete: async ({ turn, finishReason }) => { - turnCompletes.push({ turn, finishReason }); - // Injected after turn 0, meant for the next real turn. - if (turn === 0) - chat.inject([{ role: "system", content: "INSTRUCTION-FOR-NEXT-TURN" }] as never); - }, - onAction: async ({ action, messages }) => { - if (action.type !== "regenerate") return; - chat.history.slice(0, -1); - return streamText({ model: failingActionModel, messages, ...chat.toStreamTextOptions() }); - }, - run: async ({ messages, signal }) => - streamText({ - model: turnModel, - messages, - abortSignal: signal, - ...chat.toStreamTextOptions(), - }), - }); - - const harness = mockChatAgent(agent, { chatId: "action-failure-not-a-turn" }); - try { - await harness.sendMessage(userMessage("m1", "u-1")); - await waitFor(() => turnCompletes.length >= 1, "turn 0"); - - await harness.sendAction({ type: "regenerate" }).catch(() => {}); - await new Promise((r) => setTimeout(r, 200)); - - // The failure reached the client. - const errors = (harness.allRawChunks as { type?: string }[]).filter( - (c) => c.type === "error" - ); - expect(errors.length).toBeGreaterThan(0); - - // But it was not a turn: no turn lifecycle for it. - expect(turnCompletes).toHaveLength(1); - - await harness.sendMessage(userMessage("m2", "u-2")); - await waitFor(() => turnCompletes.length >= 2, "turn 1"); - - // The next real turn is turn 1, not turn 2, and it still gets the - // instruction the failed action must not have consumed. - expect(turnCompletes[1]!.turn).toBe(1); - expect(turnPrompts.at(-1)!).toContain("INSTRUCTION-FOR-NEXT-TURN"); - } finally { - await harness.close(); - } - }); -}); diff --git a/packages/trigger-sdk/test/action-plain-replies.test.ts b/packages/trigger-sdk/test/action-plain-replies.test.ts deleted file mode 100644 index 5a98f0c82f2..00000000000 --- a/packages/trigger-sdk/test/action-plain-replies.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { mockChatAgent } from "../src/v3/test/index.js"; - -import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { simulateReadableStream, streamText } from "ai"; -import type { UIMessage } from "ai"; -import { MockLanguageModelV3 } from "ai/test"; -import { describe, expect, it } from "vitest"; -import { z } from "zod"; -import { chat } from "../src/v3/ai.js"; - -/** - * `onAction` is documented to accept a `string` or a `UIMessage` as a reply, - * not only a stream. Each has to reach the browser, the conversation the next - * turn is built from, and the snapshot, the same as a streamed reply does. - */ - -const USAGE = { - inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, - outputTokens: { total: 1, text: 1, reasoning: undefined }, -}; -const userMessage = (text: string, id: string) => ({ - id, - role: "user" as const, - parts: [{ type: "text" as const, text }], -}); -const textChunks = (text: string): LanguageModelV3StreamPart[] => [ - { type: "text-start", id: "t1" }, - { type: "text-delta", id: "t1", delta: text }, - { type: "text-end", id: "t1" }, - { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, -]; -const textOf = (m: { parts?: unknown[] }) => - ((m.parts ?? []) as { type: string; text?: string }[]) - .filter((p) => p.type === "text") - .map((p) => p.text ?? "") - .join(""); - -async function runAction(chatId: string, reply: () => unknown, opts?: { compact?: boolean }) { - const prompts: string[] = []; - let compacted = 0; - const model = new MockLanguageModelV3({ - doStream: async ({ prompt }) => { - prompts.push(JSON.stringify(prompt)); - return { - stream: simulateReadableStream({ chunks: textChunks("first answer"), initialDelayInMs: 5 }), - }; - }, - }); - const agent = chat.agent({ - id: chatId, - ...(opts?.compact - ? { - compaction: { - shouldCompact: ({ source }) => source === "outer" && compacted === 0, - summarize: async () => { - compacted++; - return "SUMMARY-OF-EVERYTHING"; - }, - }, - } - : {}), - actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("note") })]), - onAction: async ({ action }) => (action.type === "note" ? reply() : undefined), - run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), - }); - const harness = mockChatAgent(agent, { chatId }); - try { - await harness.sendMessage(userMessage("m1", "u-1")); - if (opts?.compact) { - const start = Date.now(); - while (compacted === 0 && Date.now() - start < 5000) - await new Promise((r) => setTimeout(r, 10)); - } - await harness.sendAction({ type: "note" }); - await new Promise((r) => setTimeout(r, 80)); - const streamed = (harness.allRawChunks as { type?: string; delta?: string }[]) - .filter((c) => c.type === "text-delta") - .map((c) => c.delta ?? "") - .join(""); - const snapshot = (harness.getSnapshot()?.messages ?? []).map(textOf); - const promptsBefore = prompts.length; - await harness.sendMessage(userMessage("m2", "u-2")); - return { streamed, snapshot, nextPrompt: prompts[promptsBefore]! }; - } finally { - await harness.close(); - } -} - -describe("a plain reply from onAction", () => { - it("delivers a returned string like a streamed reply", { timeout: 30_000 }, async () => { - const r = await runAction("action-string-reply", () => "NOTE-FROM-ACTION"); - expect(r.streamed).toContain("NOTE-FROM-ACTION"); - expect(r.snapshot.at(-1)).toBe("NOTE-FROM-ACTION"); - expect(r.nextPrompt).toContain("NOTE-FROM-ACTION"); - }); - - it("delivers a returned UIMessage like a streamed reply", { timeout: 30_000 }, async () => { - const message = { - id: "a-note", - role: "assistant", - parts: [{ type: "text", text: "UIMESSAGE-FROM-ACTION" }], - } as UIMessage; - const r = await runAction("action-uimessage-reply", () => message); - expect(r.streamed).toContain("UIMESSAGE-FROM-ACTION"); - expect(r.snapshot.at(-1)).toBe("UIMESSAGE-FROM-ACTION"); - expect(r.nextPrompt).toContain("UIMESSAGE-FROM-ACTION"); - }); - - it("is appended to a compacted lane rather than rebuilding it", { timeout: 30_000 }, async () => { - /** - * Compaction is model-only. Committing the reply by reconverting the UI - * lane would put the message compaction removed back in front of the model. - */ - const r = await runAction("action-reply-after-compaction", () => "NOTE-AFTER-COMPACTION", { - compact: true, - }); - expect(r.nextPrompt).toContain("SUMMARY-OF-EVERYTHING"); - expect(r.nextPrompt).toContain("NOTE-AFTER-COMPACTION"); - expect(r.nextPrompt).not.toContain("first answer"); - }); -}); diff --git a/packages/trigger-sdk/test/action-stream-accumulator.test.ts b/packages/trigger-sdk/test/action-stream-accumulator.test.ts index f3a6234883f..0ee0ecfd92e 100644 --- a/packages/trigger-sdk/test/action-stream-accumulator.test.ts +++ b/packages/trigger-sdk/test/action-stream-accumulator.test.ts @@ -1,160 +1,119 @@ -// Import the test harness FIRST — installs the resource catalog so -// `chat.agent()` below registers its task functions correctly. import { mockChatAgent } from "../src/v3/test/index.js"; -import { describe, expect, it } from "vitest"; -import { chat } from "../src/v3/ai.js"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; import { simulateReadableStream, streamText } from "ai"; -import type { UIMessage } from "ai"; import { MockLanguageModelV3 } from "ai/test"; -import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { describe, expect, it } from "vitest"; import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; -function textStream(text: string): ReadableStream { - return simulateReadableStream({ +/** + * A regenerate as an action that returns `chat.turn()`: the answer it produces + * is a turn's answer, so it reaches the browser, the conversation and the + * snapshot, and a failure part-way is a turn failure. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; +const textStream = (text: string) => + simulateReadableStream({ chunks: [ { type: "text-start", id: "t1" }, { type: "text-delta", id: "t1", delta: text }, { type: "text-end", id: "t1" }, - { - type: "finish", - finishReason: { unified: "stop", raw: "stop" }, - usage: { - inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, - outputTokens: { total: 10, text: 10, reasoning: undefined }, - }, - }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, ], + initialDelayInMs: 5, }); -} - -function textOf(message: UIMessage): string { - return message.parts.map((part) => (part.type === "text" ? part.text : "")).join(""); -} - -describe("a StreamTextResult returned from onAction", () => { - it("becomes part of the conversation, not just something the browser saw", async () => { +const textOf = (m: { parts?: unknown[] }) => + ((m.parts ?? []) as { type: string; text?: string }[]) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); + +describe("a regenerate action that returns chat.turn()", () => { + it("puts the new answer in the conversation in place of the old one", async () => { + let calls = 0; const model = new MockLanguageModelV3({ - doStream: async () => ({ stream: textStream("regenerated answer") }), + doStream: async () => ({ + stream: textStream(calls++ === 0 ? "first answer" : "regenerated answer"), + }), }); - const agent = chat.agent({ - id: "action-stream-accumulator", + id: "action-turn-stream", actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]), - - /** - * The bare shape the docs show: return the stream and let the runtime pipe - * it. The alternative — consuming it with `chat.pipeAndCapture` — is the - * workaround, so testing that instead would prove nothing about this path. - */ - onAction: async ({ action, messages }) => { + onAction: async ({ action }) => { if (action.type !== "regenerate") return; chat.history.slice(0, -1); - return streamText({ model, messages }); + return chat.turn(); }, - - run: async ({ messages, signal }) => - streamText({ - model: new MockLanguageModelV3({ - doStream: async () => ({ stream: textStream("first answer") }), - }), - messages, - abortSignal: signal, - }), + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), }); - - const harness = mockChatAgent(agent, { chatId: "action-stream-accumulator" }); - + const harness = mockChatAgent(agent, { chatId: "action-turn-stream" }); try { - await harness.sendMessage({ - id: "u1", - role: "user", - parts: [{ type: "text", text: "ask" }], - }); - await new Promise((r) => setTimeout(r, 30)); - - const turn = await harness.sendAction({ type: "regenerate" }); - await new Promise((r) => setTimeout(r, 50)); + await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "ask" }] }); + await new Promise((r) => setTimeout(r, 40)); + const before = harness.allRawChunks.length; + await harness.sendAction({ type: "regenerate" }); + await new Promise((r) => setTimeout(r, 120)); - // The browser did see it — that part was never broken. - const streamed = turn.chunks + const streamed = (harness.allRawChunks.slice(before) as { type?: string; delta?: string }[]) .filter((c) => c.type === "text-delta") - .map((c) => (c as { delta: string }).delta) + .map((c) => c.delta ?? "") .join(""); expect(streamed).toBe("regenerated answer"); - - /** - * And the conversation agrees with the screen. Before the fix the response - * was piped and dropped: absent from the accumulator, absent from the - * snapshot, so the next turn's model context contained the question and the - * *old* answer that regenerate had just removed. - */ - const snapshot = harness.getSnapshot(); - expect(snapshot?.messages.map(textOf)).toEqual(["ask", "regenerated answer"]); + expect(harness.getSnapshot()?.messages.map(textOf)).toEqual(["ask", "regenerated answer"]); } finally { await harness.close(); } }); - it("reports a mid-stream failure instead of committing a truncated answer as finished", async () => { - /** - * `pipeChatAndCapture` returns a stream failure as `status: "error"` rather - * than throwing it. Unchecked, the action commits whatever streamed, writes a - * normal turn-complete, and the browser just sees the stream stop — so the - * user reads a half-finished answer presented as complete and the next turn - * builds on it. The partial is still kept, as on the turn path; what changes - * is that the failure is surfaced alongside it. - */ - let stage = 0; + it("reports a mid-stream failure as a turn failure and keeps the partial", async () => { + let calls = 0; const failsMidStream = new MockLanguageModelV3({ - doStream: async () => ({ - stream: new ReadableStream({ - async pull(controller) { - await new Promise((r) => setTimeout(r, 25)); - if (stage === 0) { - controller.enqueue({ type: "text-start", id: "t1" }); - stage++; - return; - } - if (stage === 1) { - controller.enqueue({ type: "text-delta", id: "t1", delta: "half an answer" }); - stage++; - return; - } - controller.error(new Error("provider exploded mid-stream")); - }, - }), - }), + doStream: async () => { + if (calls++ === 0) return { stream: textStream("first answer") }; + let stage = 0; + return { + stream: new ReadableStream({ + pull(controller) { + if (stage === 0) { + controller.enqueue({ type: "text-start", id: "t1" }); + stage++; + return; + } + if (stage === 1) { + controller.enqueue({ type: "text-delta", id: "t1", delta: "half an answer" }); + stage++; + return; + } + controller.error(new Error("provider exploded mid-stream")); + }, + }), + }; + }, }); - + const completes: { finishReason?: string }[] = []; const agent = chat.agent({ - id: "action-stream-error", + id: "action-turn-stream-error", actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]), - onAction: async ({ action, messages }) => { + onTurnComplete: async ({ finishReason }) => { + completes.push({ finishReason }); + }, + onAction: async ({ action }) => { if (action.type !== "regenerate") return; chat.history.slice(0, -1); - return streamText({ model: failsMidStream, messages }); + return chat.turn(); }, run: async ({ messages, signal }) => - streamText({ - model: new MockLanguageModelV3({ - doStream: async () => ({ stream: textStream("first answer") }), - }), - messages, - abortSignal: signal, - }), + streamText({ model: failsMidStream, messages, abortSignal: signal }), }); - - const harness = mockChatAgent(agent, { chatId: "action-stream-error" }); - + const harness = mockChatAgent(agent, { chatId: "action-turn-stream-error" }); try { - await harness.sendMessage({ - id: "u1", - role: "user", - parts: [{ type: "text", text: "ask" }], - }); + await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "ask" }] }); await new Promise((r) => setTimeout(r, 40)); - await harness.sendAction({ type: "regenerate" }).catch(() => {}); await new Promise((r) => setTimeout(r, 300)); @@ -162,8 +121,8 @@ describe("a StreamTextResult returned from onAction", () => { (c) => c.type === "error" ); expect(errors.length).toBeGreaterThan(0); - - // The partial is still kept rather than discarded. + // A turn failure: the hook saw it, and the partial is kept, not discarded. + expect(completes.at(-1)?.finishReason).toBe("error"); expect(harness.getSnapshot()?.messages.map(textOf).at(-1)).toContain("half an answer"); } finally { await harness.close(); diff --git a/packages/trigger-sdk/test/action-turn.test.ts b/packages/trigger-sdk/test/action-turn.test.ts new file mode 100644 index 00000000000..dfccdadbc85 --- /dev/null +++ b/packages/trigger-sdk/test/action-turn.test.ts @@ -0,0 +1,156 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { simulateReadableStream } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { chat } from "../src/v3/ai.js"; + +/** + * An action is a state edit. One that returns `chat.turn()` is followed by a + * turn on the edited history, with everything a turn has; one that returns + * nothing edits and stops; one that returns anything else is an error. + */ + +const USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, +}; +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; +const textOf = (m: { parts?: unknown[] }) => + ((m.parts ?? []) as { type: string; text?: string }[]) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); +async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +function agentWith(onAction: (action: { type: string }) => unknown) { + const prompts: string[] = []; + const starts: number[] = []; + const completes: { turn: number; finishReason?: string }[] = []; + let answers = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(JSON.stringify(prompt)); + return { + stream: simulateReadableStream({ + chunks: textChunks(`answer-${answers++}`), + initialDelayInMs: 5, + }), + }; + }, + }); + const agent = chat.agent({ + id: "action-turn", + system: "AGENT-SYSTEM", + actionSchema: z.discriminatedUnion("type", [ + z.object({ type: z.literal("regenerate") }), + z.object({ type: z.literal("undo") }), + z.object({ type: z.literal("bad") }), + ]), + onTurnStart: async ({ turn }) => { + starts.push(turn); + }, + onTurnComplete: async ({ turn, finishReason }) => { + completes.push({ turn, finishReason }); + }, + onAction: async ({ action }) => onAction(action) as never, + run: async ({ messages, signal, streamText: bound }) => + bound({ model, messages, abortSignal: signal }), + }); + return { agent, prompts, starts, completes }; +} + +describe("an action that returns chat.turn()", () => { + it("runs a turn on the edited history, with the turn's own machinery", async () => { + const { agent, prompts, starts, completes } = agentWith((action) => { + if (action.type === "regenerate") { + chat.history.slice(0, -1); + return chat.turn(); + } + return undefined; + }); + const harness = mockChatAgent(agent, { chatId: "action-turn-regenerate" }); + try { + await harness.sendMessage(userMessage("ask", "u-1")); + await waitFor(() => completes.length >= 1, "turn 0"); + + await harness.sendAction({ type: "regenerate" }); + await waitFor(() => completes.length >= 2, "the action's turn"); + + // It was a turn: hooks fired, and it took the next turn number. + expect(starts).toEqual([0, 1]); + expect(completes.map((c) => c.turn)).toEqual([0, 1]); + // It ran on the edited history with the agent's configuration. + const p = prompts.at(-1)!; + expect(p).toContain("AGENT-SYSTEM"); + expect(p).not.toContain("answer-0"); + // Its answer replaced the old one in the conversation. + expect(harness.getSnapshot()?.messages.map(textOf)).toEqual(["ask", "answer-1"]); + + // And the turn after it is numbered on from there. + await harness.sendMessage(userMessage("more", "u-2")); + await waitFor(() => completes.length >= 3, "turn 2"); + expect(completes.at(-1)!.turn).toBe(2); + expect(prompts.at(-1)!).toContain("answer-1"); + } finally { + await harness.close(); + } + }); + + it("is still just an edit when nothing is returned", async () => { + const { agent, starts, completes } = agentWith((action) => { + if (action.type === "undo") chat.history.slice(0, -2); + }); + const harness = mockChatAgent(agent, { chatId: "action-turn-undo" }); + try { + await harness.sendMessage(userMessage("ask", "u-1")); + await waitFor(() => completes.length >= 1, "turn 0"); + await harness.sendAction({ type: "undo" }); + await new Promise((r) => setTimeout(r, 60)); + expect(starts).toEqual([0]); + expect(completes).toHaveLength(1); + expect(harness.getSnapshot()?.messages ?? []).toEqual([]); + } finally { + await harness.close(); + } + }); + + it("rejects any other return value with a pointer to chat.turn()", async () => { + const { agent, completes } = agentWith((action) => { + if (action.type === "bad") return "a reply string"; + return undefined; + }); + const harness = mockChatAgent(agent, { chatId: "action-turn-bad" }); + try { + await harness.sendMessage(userMessage("ask", "u-1")); + await waitFor(() => completes.length >= 1, "turn 0"); + await harness.sendAction({ type: "bad" }).catch(() => {}); + await new Promise((r) => setTimeout(r, 120)); + const errors = (harness.allRawChunks as { type?: string; errorText?: string }[]) + .filter((c) => c.type === "error") + .map((c) => c.errorText ?? ""); + expect(errors.some((e) => e.includes("chat.turn()"))).toBe(true); + } finally { + await harness.close(); + } + }); +}); diff --git a/packages/trigger-sdk/test/instructions-action-replay.test.ts b/packages/trigger-sdk/test/instructions-action-replay.test.ts index 1794f687242..d4e1389ba0a 100644 --- a/packages/trigger-sdk/test/instructions-action-replay.test.ts +++ b/packages/trigger-sdk/test/instructions-action-replay.test.ts @@ -8,23 +8,22 @@ import { z } from "zod"; import { chat } from "../src/v3/ai.js"; /** - * A one-shot instruction and an action in between. + * One-shot instructions around actions. * - * `turn--` marks an action as not-a-turn, so an action and the message after - * it share a turn number. The consumed-instruction stash is keyed on that - * number, so an action that builds options consumes the injection and the next - * real turn reads the same stash back. + * An action is a state edit and makes no model call, so it neither consumes + * nor delays the instruction lane. One that returns `chat.turn()` IS the next + * turn, so it consumes the lane the way any turn does. */ const USAGE = { inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: 1, text: 1, reasoning: undefined }, }; - -function userMessage(text: string, id: string) { - return { id, role: "user" as const, parts: [{ type: "text" as const, text }] }; -} - +const userMessage = (text: string, id: string) => ({ + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], +}); async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -33,93 +32,97 @@ async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_ } throw new Error(`waitFor timed out: ${label}`); } +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, +]; -function textChunks(text: string): LanguageModelV3StreamPart[] { - return [ - { type: "text-start", id: "t1" }, - { type: "text-delta", id: "t1", delta: text }, - { type: "text-end", id: "t1" }, - { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE }, - ]; +function recordingModel(seen: { a: boolean; b: boolean }[]) { + return new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + const p = JSON.stringify(prompt); + seen.push({ a: p.includes("INSTRUCTION-A"), b: p.includes("INSTRUCTION-B") }); + return { stream: simulateReadableStream({ chunks: textChunks("ok"), initialDelayInMs: 5 }) }; + }, + }); } -describe("a one-shot instruction across an action", () => { +describe("one-shot instructions around an action", () => { + it("an edit-only action neither consumes nor delays them", { timeout: 30_000 }, async () => { + const seen: { a: boolean; b: boolean }[] = []; + const model = recordingModel(seen); + const agent = chat.agent({ + id: "instructions-edit-only-action", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("note") })]), + onTurnComplete: async ({ turn }) => { + if (turn === 0) chat.inject([{ role: "system", content: "INSTRUCTION-A" }] as never); + }, + onAction: async ({ action }) => { + if (action.type !== "note") return; + // An action can add context for the next turn too. + chat.inject([{ role: "system", content: "INSTRUCTION-B" }] as never); + }, + run: async ({ messages, signal }) => + streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }), + }); + const harness = mockChatAgent(agent, { chatId: "instructions-edit-only-action" }); + try { + await harness.sendMessage(userMessage("m1", "u-1")); + await waitFor(() => seen.length >= 1, "turn 0"); + await harness.sendAction({ type: "note" }); + await new Promise((r) => setTimeout(r, 60)); + // No model call for the action. + expect(seen).toHaveLength(1); + await harness.sendMessage(userMessage("m2", "u-2")); + await waitFor(() => seen.length >= 2, "turn 1"); + await harness.sendMessage(userMessage("m3", "u-3")); + await waitFor(() => seen.length >= 3, "turn 2"); + + expect(seen[0]).toEqual({ a: false, b: false }); + // The next real turn gets both, on time. + expect(seen[1]).toEqual({ a: true, b: true }); + // And only that turn. + expect(seen[2]).toEqual({ a: false, b: false }); + } finally { + await harness.close(); + } + }); + it( - "reaches each turn once and is not replayed by the turn after an action", + "an action that returns chat.turn() is the turn that consumes them", { timeout: 30_000 }, async () => { - /** One entry per model call, in order, saying whether it carried the instruction. */ - const sawInstruction: { label: string; saw: boolean }[] = []; - - const makeModel = (label: string) => - new MockLanguageModelV3({ - doStream: async ({ prompt }) => { - sawInstruction.push({ - label, - saw: JSON.stringify(prompt).includes("INSTRUCTION-ONE-SHOT"), - }); - return { - stream: simulateReadableStream({ chunks: textChunks("ok"), initialDelayInMs: 5 }), - }; - }, - }); - - const turnModel = makeModel("turn"); - const actionModel = makeModel("action"); - + const seen: { a: boolean; b: boolean }[] = []; + const model = recordingModel(seen); const agent = chat.agent({ - id: "instructions-action-replay", - actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("ping") })]), + id: "instructions-action-turn", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]), onTurnComplete: async ({ turn }) => { - // Injecting from inside the run, because the lane lives in run locals. - if (turn === 0) - chat.inject([{ role: "system", content: "INSTRUCTION-ONE-SHOT" }] as never); + if (turn === 0) chat.inject([{ role: "system", content: "INSTRUCTION-A" }] as never); }, onAction: async ({ action }) => { - if (action.type !== "ping") return; - return streamText({ - model: actionModel, - messages: [{ role: "user", content: "regenerate" }], - ...chat.toStreamTextOptions(), - }); + if (action.type !== "regenerate") return; + chat.history.slice(0, -1); + return chat.turn(); }, run: async ({ messages, signal }) => - streamText({ - model: turnModel, - messages, - abortSignal: signal, - ...chat.toStreamTextOptions(), - }), + streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }), }); - - const harness = mockChatAgent(agent, { chatId: "instructions-action-replay" }); + const harness = mockChatAgent(agent, { chatId: "instructions-action-turn" }); try { - // Turn 1, nothing injected yet, then inject for the next turn. await harness.sendMessage(userMessage("m1", "u-1")); - await waitFor(() => sawInstruction.length >= 1, "turn 1"); - - // An action lands before the next message. - await harness.sendAction({ type: "ping" }); - await waitFor(() => sawInstruction.length >= 2, "action"); - - // Then the real turn the injection was meant for. + await waitFor(() => seen.length >= 1, "turn 0"); + await harness.sendAction({ type: "regenerate" }); + await waitFor(() => seen.length >= 2, "the action's turn"); await harness.sendMessage(userMessage("m2", "u-2")); - await waitFor(() => sawInstruction.length >= 3, "turn 2"); - - // And one more, which must not see it again. - await harness.sendMessage(userMessage("m3", "u-3")); - await waitFor(() => sawInstruction.length >= 4, "turn 3"); + await waitFor(() => seen.length >= 3, "the turn after"); - const carriers = sawInstruction.filter((e) => e.saw).map((e) => e.label); - // The action sees it: it is pending context, and an action is not a turn, - // so the action reading it must not use it up. - expect(sawInstruction[1]!).toEqual({ label: "action", saw: true }); - // And the turn it was actually injected for still gets it. - expect(sawInstruction[2]!).toEqual({ label: "turn", saw: true }); - // The turn after that does not: one-shot means one turn. - expect(sawInstruction[3]!).toEqual({ label: "turn", saw: false }); - // And it is never carried by more than one real turn. - expect(carriers.filter((l) => l === "turn")).toHaveLength(1); + // The action's turn is the next turn, so it takes the instruction. + expect(seen[1]).toEqual({ a: true, b: false }); + // One-shot: the turn after does not see it again. + expect(seen[2]).toEqual({ a: false, b: false }); } finally { await harness.close(); } diff --git a/packages/trigger-sdk/test/mockChatAgent.test.ts b/packages/trigger-sdk/test/mockChatAgent.test.ts index 62437369a39..ace55f1dd29 100644 --- a/packages/trigger-sdk/test/mockChatAgent.test.ts +++ b/packages/trigger-sdk/test/mockChatAgent.test.ts @@ -953,30 +953,31 @@ describe("mockChatAgent", () => { } }); - it("actions returning a stream pipe the response without firing turn hooks", async () => { + it("actions returning chat.turn() run a turn on the edited history", async () => { const onTurnStart = vi.fn(); const onTurnComplete = vi.fn(); - const actionModel = new MockLanguageModelV3({ - doStream: async () => ({ stream: textStream("regenerated") }), - }); - const turnModel = new MockLanguageModelV3({ - doStream: async () => ({ stream: textStream("normal-response") }), + let calls = 0; + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: textStream(calls++ === 0 ? "normal-response" : "regenerated"), + }), }); const agent = chat.agent({ - id: "mockChatAgent.actions.stream", + id: "mockChatAgent.actions.turn", actionSchema: z.object({ type: z.literal("regenerate") }), onTurnStart, onTurnComplete, - onAction: async ({ messages }) => { - return streamText({ model: actionModel, messages }); + onAction: async () => { + chat.history.slice(0, -1); + return chat.turn(); }, run: async ({ messages, signal }) => { - return streamText({ model: turnModel, messages, abortSignal: signal }); + return streamText({ model, messages, abortSignal: signal }); }, }); - const harness = mockChatAgent(agent, { chatId: "test-stream-action" }); + const harness = mockChatAgent(agent, { chatId: "test-turn-action" }); try { await harness.sendMessage(userMessage("hi")); await new Promise((r) => setTimeout(r, 50)); @@ -986,11 +987,11 @@ describe("mockChatAgent", () => { const actionTurn = await harness.sendAction({ type: "regenerate" }); await new Promise((r) => setTimeout(r, 50)); - // No turn hooks fired during the action. - expect(onTurnStart.mock.calls.length).toBe(baselineTurnStart); - expect(onTurnComplete.mock.calls.length).toBe(baselineTurnComplete); + // It is a turn: each hook fired once more. + expect(onTurnStart.mock.calls.length).toBe(baselineTurnStart + 1); + expect(onTurnComplete.mock.calls.length).toBe(baselineTurnComplete + 1); - // Action's streamText output landed on the response. + // And the turn's answer is what streamed back for the action. const text = actionTurn.chunks .filter((c) => c.type === "text-delta") .map((c) => (c as { delta: string }).delta) From 0cf59a22ab4049f751a63dcf1ebb8dd9242af0f7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 5 Sep 2026 06:05:33 +0100 Subject: [PATCH 35/37] fix(chat): persist an action's edit before its turn, and label that turn The edit an action makes is snapshotted before the turn chat.turn() requests begins, so a turn that is cancelled or runs out of memory continues from the edited history rather than from the snapshot the edit replaced. The turn's run() payload carries trigger "action-turn", not "action", so a handler that returns early on the action trigger still answers. An action sent through useChat keeps the request's metadata. Also moves the action-turn test onto this branch's own surface; it had used the bound streamText and chat.agent({ system }) from #4884. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a --- packages/trigger-sdk/src/v3/ai.ts | 15 ++++-- packages/trigger-sdk/src/v3/chat.test.ts | 3 ++ packages/trigger-sdk/src/v3/chat.ts | 6 +-- packages/trigger-sdk/test/action-turn.test.ts | 50 +++++++++++++++---- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index c800f3a3583..3858e01bd56 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -1602,7 +1602,7 @@ export type ChatTaskPayload = { * to short-circuit the LLM call when an action doesn't need a response. * - `"close"`: The chat session is being closed (internal; `run()` is not called). */ - trigger: "submit-message" | "regenerate-message" | "preload" | "action" | "close"; + trigger: "submit-message" | "regenerate-message" | "preload" | "action" | "action-turn" | "close"; /** The ID of the message to regenerate (only for `"regenerate-message"`) */ messageId?: string; @@ -8234,9 +8234,13 @@ function chatAgent< // sees the same `turn` value — actions don't count. if (isAction) { if (isActionTurn(actionResult)) { - // The edit is in the accumulators; the turn block below - // runs on it and does its own persistence, hooks and - // completion, so nothing more happens here. + // Persist the edit before the turn starts, so a turn that is + // cancelled or runs out of memory continues from the edited + // history rather than from the snapshot the edit replaced. + // The turn then does its own hooks, completion and snapshot. + if (actionChangedHistory) { + await writeSnapshotOutsideTurn("action"); + } actionTurn = true; } else if (actionResult !== undefined) { throw new Error( @@ -8439,6 +8443,9 @@ function chatAgent< ); runResult = await userRun({ ...restWire, + // A turn requested by chat.turn() is not the action itself: + // a run() that short-circuits on "action" must still answer. + ...(actionTurn ? { trigger: "action-turn" as const } : {}), messages: preparedMessages, clientData, continuation, diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index 473d0fa91cd..4859852514e 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -1010,12 +1010,15 @@ describe("TriggerChatTransport", () => { messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }], abortSignal: undefined, body: { action: { type: "regenerate" } }, + metadata: { tenant: "t-1" }, }); await drainChunks(stream); expect(actionBody.payload.trigger).toBe("action"); expect(actionBody.payload.action).toEqual({ type: "regenerate" }); expect(actionBody.payload.message).toBeUndefined(); + // The request's own metadata rides along, not only the transport defaults. + expect(actionBody.payload.metadata).toEqual({ tenant: "t-1" }); }); it("marks the session streaming and notifies before subscribing", async () => { diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 1a12d2ebfb6..1110f560812 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -808,7 +808,7 @@ export class TriggerChatTransport implements ChatTransport { // action that becomes a turn renders the way a message turn does. const actionInBody = (body as { action?: unknown } | undefined)?.action; if (actionInBody !== undefined) { - return this.sendAction(chatId, actionInBody, { abortSignal }); + return this.sendAction(chatId, actionInBody, { abortSignal, metadata: mergedMetadata }); } // First-turn handover routing — when `headStart` is set AND no @@ -1269,7 +1269,7 @@ export class TriggerChatTransport implements ChatTransport { sendAction = async ( chatId: string, action: unknown, - options?: { abortSignal?: AbortSignal } + options?: { abortSignal?: AbortSignal; metadata?: Record } ): Promise> => { if (this.coordinator) { if (this.coordinator.isReadOnly(chatId)) { @@ -1284,7 +1284,7 @@ export class TriggerChatTransport implements ChatTransport { chatId, trigger: "action" as const, action, - metadata: this.defaultMetadata ?? undefined, + metadata: options?.metadata ?? this.defaultMetadata ?? undefined, }; const body = this.serializeInputChunk({ kind: "message", payload: wirePayload }); diff --git a/packages/trigger-sdk/test/action-turn.test.ts b/packages/trigger-sdk/test/action-turn.test.ts index dfccdadbc85..85587781eba 100644 --- a/packages/trigger-sdk/test/action-turn.test.ts +++ b/packages/trigger-sdk/test/action-turn.test.ts @@ -1,7 +1,7 @@ import { mockChatAgent } from "../src/v3/test/index.js"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { simulateReadableStream } from "ai"; +import { simulateReadableStream, streamText } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -44,6 +44,9 @@ async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_ function agentWith(onAction: (action: { type: string }) => unknown) { const prompts: string[] = []; + const triggers: string[] = []; + /** The snapshot as it stood when each run() began. */ + const snapshotsAtRun: string[][] = []; const starts: number[] = []; const completes: { turn: number; finishReason?: string }[] = []; let answers = 0; @@ -60,7 +63,9 @@ function agentWith(onAction: (action: { type: string }) => unknown) { }); const agent = chat.agent({ id: "action-turn", - system: "AGENT-SYSTEM", + onChatStart: async () => { + chat.prompt.set("AGENT-SYSTEM"); + }, actionSchema: z.discriminatedUnion("type", [ z.object({ type: z.literal("regenerate") }), z.object({ type: z.literal("undo") }), @@ -73,22 +78,39 @@ function agentWith(onAction: (action: { type: string }) => unknown) { completes.push({ turn, finishReason }); }, onAction: async ({ action }) => onAction(action) as never, - run: async ({ messages, signal, streamText: bound }) => - bound({ model, messages, abortSignal: signal }), + run: async ({ messages, signal, trigger }) => { + triggers.push(trigger); + snapshotsAtRun.push((snapshotReader?.()?.messages ?? []).map(textOf)); + return streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }); + }, }); - return { agent, prompts, starts, completes }; + let snapshotReader: (() => { messages: { parts?: unknown[] }[] } | undefined) | undefined; + return { + agent, + prompts, + triggers, + snapshotsAtRun, + starts, + completes, + attach: (h: { getSnapshot: () => { messages: { parts?: unknown[] }[] } | undefined }) => { + snapshotReader = () => h.getSnapshot(); + }, + }; } describe("an action that returns chat.turn()", () => { it("runs a turn on the edited history, with the turn's own machinery", async () => { - const { agent, prompts, starts, completes } = agentWith((action) => { - if (action.type === "regenerate") { - chat.history.slice(0, -1); - return chat.turn(); + const { agent, prompts, triggers, snapshotsAtRun, starts, completes, attach } = agentWith( + (action) => { + if (action.type === "regenerate") { + chat.history.slice(0, -1); + return chat.turn(); + } + return undefined; } - return undefined; - }); + ); const harness = mockChatAgent(agent, { chatId: "action-turn-regenerate" }); + attach(harness); try { await harness.sendMessage(userMessage("ask", "u-1")); await waitFor(() => completes.length >= 1, "turn 0"); @@ -105,6 +127,12 @@ describe("an action that returns chat.turn()", () => { expect(p).not.toContain("answer-0"); // Its answer replaced the old one in the conversation. expect(harness.getSnapshot()?.messages.map(textOf)).toEqual(["ask", "answer-1"]); + // run() saw it as a turn requested by an action, not as the action, so a + // handler that returns early on "action" still answers. + expect(triggers[1]).toBe("action-turn"); + // And the edit was persisted before the turn began: a turn cut short + // continues from the edited history, not from the snapshot it replaced. + expect(snapshotsAtRun[1]).toEqual(["ask"]); // And the turn after it is numbered on from there. await harness.sendMessage(userMessage("more", "u-2")); From 8d5d46644d71acd9c9cdf34b318d39fb05756f5c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 5 Sep 2026 06:17:43 +0100 Subject: [PATCH 36/37] fix(chat): merge an action's metadata over the transport's clientData sendAction with per-action metadata replaced the transport defaults instead of merging them, so required default fields vanished for direct callers. sendMessages already merged before routing; sendAction now merges itself, and its docstring describes the chat.turn() model. --- packages/trigger-sdk/src/v3/chat.test.ts | 29 ++++++++++++++++++++++++ packages/trigger-sdk/src/v3/chat.ts | 15 +++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index 4859852514e..3338a2af964 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -1021,6 +1021,35 @@ describe("TriggerChatTransport", () => { expect(actionBody.payload.metadata).toEqual({ tenant: "t-1" }); }); + it("merges per-action metadata over the transport's clientData", async () => { + let actionBody: any; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + actionBody = JSON.parse(init!.body as string); + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { "chat-act-meta": { publicAccessToken: "p" } }, + clientData: { userId: "u1", scope: "default" } as Record, + }); + + const stream = await transport.sendAction( + "chat-act-meta", + { type: "undo" }, + { metadata: { scope: "action" } } + ); + await drainChunks(stream); + + expect(actionBody.payload.metadata).toEqual({ userId: "u1", scope: "action" }); + }); + it("marks the session streaming and notifies before subscribing", async () => { global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { const urlStr = typeof url === "string" ? url : url.toString(); diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 1110f560812..43fc93445d9 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -1260,11 +1260,11 @@ export class TriggerChatTransport implements ChatTransport { /** * Send a custom action chunk (for `chat.agent`'s `actionSchema` / - * `onAction` hook). Actions are not turns — only `hydrateMessages` - * and `onAction` fire on the agent side. The returned stream - * carries any model response `onAction` produced (when it returns a - * `StreamTextResult`); for `void`-returning side-effect-only actions - * the stream completes immediately with `trigger:turn-complete`. + * `onAction` hook). An action is an edit: only `hydrateMessages` and + * `onAction` fire on the agent side, and the returned stream completes + * with `trigger:turn-complete` once the edit is persisted. When `onAction` + * returns `chat.turn()` the turn's response follows on the same stream. + * Per-action `metadata` is merged over the transport's `clientData`. */ sendAction = async ( chatId: string, @@ -1284,7 +1284,10 @@ export class TriggerChatTransport implements ChatTransport { chatId, trigger: "action" as const, action, - metadata: options?.metadata ?? this.defaultMetadata ?? undefined, + metadata: + this.defaultMetadata || options?.metadata + ? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) } + : undefined, }; const body = this.serializeInputChunk({ kind: "message", payload: wirePayload }); From ee8f449c5695968277c5c570799ddfaf422892f7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 5 Sep 2026 07:23:22 +0100 Subject: [PATCH 37/37] docs(chat): correct three changesets after the chat.turn() redesign The onAction event never carried streamText or tools on main, so the removal belongs to no release note; sendAction did change (an options argument and metadata merge); and the regenerate replacement path the compaction note mentioned no longer exists. --- .changeset/action-stream-into-conversation.md | 2 +- .changeset/steering-messages-accumulator.md | 2 +- .changeset/use-chat-actions.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/action-stream-into-conversation.md b/.changeset/action-stream-into-conversation.md index a3607aaed83..19a9494c34b 100644 --- a/.changeset/action-stream-into-conversation.md +++ b/.changeset/action-stream-into-conversation.md @@ -14,6 +14,6 @@ onAction: async ({ action }) => { }, ``` -Returning a `StreamTextResult`, `string` or `UIMessage` from `onAction` is no longer supported and now fails with an error pointing to `chat.turn()`. A response produced that way skipped every turn guarantee, and its delivery to the browser was unreliable: the frontend never read the stream `transport.sendAction` returned, so a regenerate that appeared to work on the server did not render. The `onAction` event no longer carries `streamText` or `tools`, since the handler no longer calls the model. +Returning a `StreamTextResult`, `string` or `UIMessage` from `onAction` is no longer supported and now fails with an error pointing to `chat.turn()`. A response produced that way skipped every turn guarantee, and its delivery to the browser was unreliable: the frontend never read the stream `transport.sendAction` returned, so a regenerate that appeared to work on the server did not render. History edits made by an action are still persisted as before: platform-managed snapshots are written after the edit, and apps with their own store mirror the edit themselves. diff --git a/.changeset/steering-messages-accumulator.md b/.changeset/steering-messages-accumulator.md index 52bd67bef38..d407b7a68f5 100644 --- a/.changeset/steering-messages-accumulator.md +++ b/.changeset/steering-messages-accumulator.md @@ -4,6 +4,6 @@ Steering messages injected mid-answer are now part of the conversation, both for your hooks and for the model on later turns. Previously they reached the model for the answer they steered and reached the browser, but nothing else: `onTurnComplete` never saw them, so an app storing its own transcript lost the instruction the answer was shaped by, and it vanished from the conversation on reload. The model also forgot the instruction from the next turn onwards, answering as though the message had never been sent, while the chat UI still showed it. This holds when the steered turn fails part-way, and when `pendingMessages.prepare` reshapes the message: later turns now see the same form the steered turn did, not the original message. -Approving a tool call no longer undoes compaction. A tool-approval continuation used to rebuild the model's context from the full conversation, so a chat that had been summarised to fit the context window was sent the whole transcript again on the next call, and could go over the limit it had just been compacted to avoid. The same applied to a regenerated answer that replaced an existing one. +Approving a tool call no longer undoes compaction. A tool-approval continuation used to rebuild the model's context from the full conversation, so a chat that had been summarised to fit the context window was sent the whole transcript again on the next call, and could go over the limit it had just been compacted to avoid. If you worked around this by saving steering messages as they arrive, in `pendingMessages.onReceived` for example, that write now duplicates the one you get from `newUIMessages`. Drop it, or skip messages you have already stored. diff --git a/.changeset/use-chat-actions.md b/.changeset/use-chat-actions.md index b0ff9b0ffd9..90a45785014 100644 --- a/.changeset/use-chat-actions.md +++ b/.changeset/use-chat-actions.md @@ -10,4 +10,4 @@ const { sendAction } = useChatActions({ sendMessage }); sendAction({ type: "regenerate" }); ``` -Previously the frontend docs said `useChat` consumed the stream `transport.sendAction` returns; it never did, so an action's answer was never rendered by an app following them. `transport.sendAction` is unchanged for callers outside `useChat` and still returns a stream the caller must read. +Previously the frontend docs said `useChat` consumed the stream `transport.sendAction` returns; it never did, so an action's answer was never rendered by an app following them. `transport.sendAction` still returns a stream that callers outside `useChat` must read, and now accepts `{ abortSignal, metadata }`, with per-action metadata merged over the transport's `clientData`.