Skip to content

Commit 0cf59a2

Browse files
ericallamclaude
andcommitted
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
1 parent d332a2a commit 0cf59a2

4 files changed

Lines changed: 56 additions & 18 deletions

File tree

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,7 +1602,7 @@ export type ChatTaskPayload<TClientData = unknown> = {
16021602
* to short-circuit the LLM call when an action doesn't need a response.
16031603
* - `"close"`: The chat session is being closed (internal; `run()` is not called).
16041604
*/
1605-
trigger: "submit-message" | "regenerate-message" | "preload" | "action" | "close";
1605+
trigger: "submit-message" | "regenerate-message" | "preload" | "action" | "action-turn" | "close";
16061606

16071607
/** The ID of the message to regenerate (only for `"regenerate-message"`) */
16081608
messageId?: string;
@@ -8234,9 +8234,13 @@ function chatAgent<
82348234
// sees the same `turn` value — actions don't count.
82358235
if (isAction) {
82368236
if (isActionTurn(actionResult)) {
8237-
// The edit is in the accumulators; the turn block below
8238-
// runs on it and does its own persistence, hooks and
8239-
// completion, so nothing more happens here.
8237+
// Persist the edit before the turn starts, so a turn that is
8238+
// cancelled or runs out of memory continues from the edited
8239+
// history rather than from the snapshot the edit replaced.
8240+
// The turn then does its own hooks, completion and snapshot.
8241+
if (actionChangedHistory) {
8242+
await writeSnapshotOutsideTurn("action");
8243+
}
82408244
actionTurn = true;
82418245
} else if (actionResult !== undefined) {
82428246
throw new Error(
@@ -8439,6 +8443,9 @@ function chatAgent<
84398443
);
84408444
runResult = await userRun({
84418445
...restWire,
8446+
// A turn requested by chat.turn() is not the action itself:
8447+
// a run() that short-circuits on "action" must still answer.
8448+
...(actionTurn ? { trigger: "action-turn" as const } : {}),
84428449
messages: preparedMessages,
84438450
clientData,
84448451
continuation,

packages/trigger-sdk/src/v3/chat.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,12 +1010,15 @@ describe("TriggerChatTransport", () => {
10101010
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }],
10111011
abortSignal: undefined,
10121012
body: { action: { type: "regenerate" } },
1013+
metadata: { tenant: "t-1" },
10131014
});
10141015
await drainChunks(stream);
10151016

10161017
expect(actionBody.payload.trigger).toBe("action");
10171018
expect(actionBody.payload.action).toEqual({ type: "regenerate" });
10181019
expect(actionBody.payload.message).toBeUndefined();
1020+
// The request's own metadata rides along, not only the transport defaults.
1021+
expect(actionBody.payload.metadata).toEqual({ tenant: "t-1" });
10191022
});
10201023

10211024
it("marks the session streaming and notifies before subscribing", async () => {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -808,7 +808,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
808808
// action that becomes a turn renders the way a message turn does.
809809
const actionInBody = (body as { action?: unknown } | undefined)?.action;
810810
if (actionInBody !== undefined) {
811-
return this.sendAction(chatId, actionInBody, { abortSignal });
811+
return this.sendAction(chatId, actionInBody, { abortSignal, metadata: mergedMetadata });
812812
}
813813

814814
// First-turn handover routing — when `headStart` is set AND no
@@ -1269,7 +1269,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12691269
sendAction = async (
12701270
chatId: string,
12711271
action: unknown,
1272-
options?: { abortSignal?: AbortSignal }
1272+
options?: { abortSignal?: AbortSignal; metadata?: Record<string, unknown> }
12731273
): Promise<ReadableStream<UIMessageChunk>> => {
12741274
if (this.coordinator) {
12751275
if (this.coordinator.isReadOnly(chatId)) {
@@ -1284,7 +1284,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12841284
chatId,
12851285
trigger: "action" as const,
12861286
action,
1287-
metadata: this.defaultMetadata ?? undefined,
1287+
metadata: options?.metadata ?? this.defaultMetadata ?? undefined,
12881288
};
12891289

12901290
const body = this.serializeInputChunk({ kind: "message", payload: wirePayload });

packages/trigger-sdk/test/action-turn.test.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mockChatAgent } from "../src/v3/test/index.js";
22

33
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
4-
import { simulateReadableStream } from "ai";
4+
import { simulateReadableStream, streamText } from "ai";
55
import { MockLanguageModelV3 } from "ai/test";
66
import { describe, expect, it } from "vitest";
77
import { z } from "zod";
@@ -44,6 +44,9 @@ async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_
4444

4545
function agentWith(onAction: (action: { type: string }) => unknown) {
4646
const prompts: string[] = [];
47+
const triggers: string[] = [];
48+
/** The snapshot as it stood when each run() began. */
49+
const snapshotsAtRun: string[][] = [];
4750
const starts: number[] = [];
4851
const completes: { turn: number; finishReason?: string }[] = [];
4952
let answers = 0;
@@ -60,7 +63,9 @@ function agentWith(onAction: (action: { type: string }) => unknown) {
6063
});
6164
const agent = chat.agent({
6265
id: "action-turn",
63-
system: "AGENT-SYSTEM",
66+
onChatStart: async () => {
67+
chat.prompt.set("AGENT-SYSTEM");
68+
},
6469
actionSchema: z.discriminatedUnion("type", [
6570
z.object({ type: z.literal("regenerate") }),
6671
z.object({ type: z.literal("undo") }),
@@ -73,22 +78,39 @@ function agentWith(onAction: (action: { type: string }) => unknown) {
7378
completes.push({ turn, finishReason });
7479
},
7580
onAction: async ({ action }) => onAction(action) as never,
76-
run: async ({ messages, signal, streamText: bound }) =>
77-
bound({ model, messages, abortSignal: signal }),
81+
run: async ({ messages, signal, trigger }) => {
82+
triggers.push(trigger);
83+
snapshotsAtRun.push((snapshotReader?.()?.messages ?? []).map(textOf));
84+
return streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() });
85+
},
7886
});
79-
return { agent, prompts, starts, completes };
87+
let snapshotReader: (() => { messages: { parts?: unknown[] }[] } | undefined) | undefined;
88+
return {
89+
agent,
90+
prompts,
91+
triggers,
92+
snapshotsAtRun,
93+
starts,
94+
completes,
95+
attach: (h: { getSnapshot: () => { messages: { parts?: unknown[] }[] } | undefined }) => {
96+
snapshotReader = () => h.getSnapshot();
97+
},
98+
};
8099
}
81100

82101
describe("an action that returns chat.turn()", () => {
83102
it("runs a turn on the edited history, with the turn's own machinery", async () => {
84-
const { agent, prompts, starts, completes } = agentWith((action) => {
85-
if (action.type === "regenerate") {
86-
chat.history.slice(0, -1);
87-
return chat.turn();
103+
const { agent, prompts, triggers, snapshotsAtRun, starts, completes, attach } = agentWith(
104+
(action) => {
105+
if (action.type === "regenerate") {
106+
chat.history.slice(0, -1);
107+
return chat.turn();
108+
}
109+
return undefined;
88110
}
89-
return undefined;
90-
});
111+
);
91112
const harness = mockChatAgent(agent, { chatId: "action-turn-regenerate" });
113+
attach(harness);
92114
try {
93115
await harness.sendMessage(userMessage("ask", "u-1"));
94116
await waitFor(() => completes.length >= 1, "turn 0");
@@ -105,6 +127,12 @@ describe("an action that returns chat.turn()", () => {
105127
expect(p).not.toContain("answer-0");
106128
// Its answer replaced the old one in the conversation.
107129
expect(harness.getSnapshot()?.messages.map(textOf)).toEqual(["ask", "answer-1"]);
130+
// run() saw it as a turn requested by an action, not as the action, so a
131+
// handler that returns early on "action" still answers.
132+
expect(triggers[1]).toBe("action-turn");
133+
// And the edit was persisted before the turn began: a turn cut short
134+
// continues from the edited history, not from the snapshot it replaced.
135+
expect(snapshotsAtRun[1]).toEqual(["ask"]);
108136

109137
// And the turn after it is numbered on from there.
110138
await harness.sendMessage(userMessage("more", "u-2"));

0 commit comments

Comments
 (0)