Skip to content

Commit 62fd77c

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

3 files changed

Lines changed: 187 additions & 88 deletions

File tree

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

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6710,20 +6710,15 @@ function chatAgent<
67106710
const reconcilePendingSteer = (options?: {
67116711
/** This turn's model delta, as `onTurnComplete.newMessages` reports it. */
67126712
turnNew?: ModelMessage[];
6713-
/**
6714-
* UI message ids already in the lane because a `chat.history` edit
6715-
* rebuilt it from the UI lane this turn. Their model form is not
6716-
* appended again, or later turns would get the steer twice.
6717-
*/
6718-
alreadyInLane?: Set<string>;
6719-
}) => {
6713+
}): PendingSteer[] => {
67206714
const pending = locals.get(chatPendingSteerKey);
6721-
if (!pending || pending.length === 0) return;
6715+
if (!pending || pending.length === 0) return [];
67226716
locals.set(chatPendingSteerKey, []);
67236717
for (const entry of pending) {
6724-
if (!options?.alreadyInLane?.has(entry.ui.id)) accumulatedMessages.push(...entry.model);
6718+
accumulatedMessages.push(...entry.model);
67256719
options?.turnNew?.push(...entry.model);
67266720
}
6721+
return pending;
67276722
};
67286723

67296724
// Accumulated UI messages for persistence. Mirrors the model accumulator
@@ -8567,13 +8562,27 @@ function chatAgent<
85678562
// during this turn. The updated messages become the new base, and the
85688563
// response gets appended on top.
85698564
const runOverride = locals.get(chatOverrideMessagesKey);
8570-
let rebuiltFromUiIds: Set<string> | undefined;
85718565
if (runOverride) {
85728566
locals.set(chatOverrideMessagesKey, undefined);
85738567
accumulatedUIMessages = [...runOverride] as TUIMessage[];
8574-
accumulatedMessages = await toModelMessages(runOverride);
8568+
/**
8569+
* Steers the drain consumed are left out of the rebuild and
8570+
* appended by the reconciliation below instead, so the lane
8571+
* gets the form the model actually received rather than a
8572+
* reconversion of the UI message, and gets it once. A steer
8573+
* the edit removed is dropped from the pending list too, so
8574+
* the edit is honoured.
8575+
*/
8576+
const overrideIds = new Set(runOverride.map((m) => m.id));
8577+
const pending = (locals.get(chatPendingSteerKey) ?? []).filter((e) =>
8578+
overrideIds.has(e.ui.id)
8579+
);
8580+
locals.set(chatPendingSteerKey, pending);
8581+
const pendingIds = new Set(pending.map((e) => e.ui.id));
8582+
accumulatedMessages = await toModelMessages(
8583+
runOverride.filter((m) => !pendingIds.has(m.id))
8584+
);
85758585
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
8576-
rebuiltFromUiIds = new Set(runOverride.map((m) => m.id));
85778586
}
85788587

85798588
// Check if compaction set a model-only override (preserves UI messages).
@@ -8616,10 +8625,7 @@ function chatAgent<
86168625
// before the response is appended so the order stays
86178626
// steer-then-answer. Outside the `capturedResponseMessage`
86188627
// branches below, so a turn that captured no response is covered.
8619-
reconcilePendingSteer({
8620-
turnNew: turnNewModelMessages,
8621-
alreadyInLane: rebuiltFromUiIds,
8622-
});
8628+
reconcilePendingSteer({ turnNew: turnNewModelMessages });
86238629

86248630
// Append the assistant's response (partial or complete) to the accumulator.
86258631
// The onFinish callback fires even on abort/stop, so partial responses
@@ -9281,14 +9287,28 @@ function chatAgent<
92819287

92829288
let erroredNewModelMessages: ModelMessage[] = [];
92839289

9284-
reconcilePendingSteer();
9290+
const reconciledSteer = reconcilePendingSteer();
92859291

92869292
if (!responseCommitted) {
92879293
try {
92889294
if (erroredNewUIMessages.length > 0) {
9289-
erroredNewModelMessages = await toModelMessages(
9290-
erroredNewUIMessages.map((m) => stripProviderMetadata(m))
9295+
/**
9296+
* Built in order from the recorded forms rather than by
9297+
* converting the UI list, so a steer appears in the delta as
9298+
* the model received it (what `prepare` produced), matching the
9299+
* lane. The wire message and partial are converted as before.
9300+
*/
9301+
const steerModelById = new Map(
9302+
reconciledSteer.map((e) => [e.ui.id, e.model] as const)
92919303
);
9304+
for (const m of erroredNewUIMessages) {
9305+
const recorded = steerModelById.get(m.id);
9306+
if (recorded) erroredNewModelMessages.push(...recorded);
9307+
else
9308+
erroredNewModelMessages.push(
9309+
...(await toModelMessages([stripProviderMetadata(m)]))
9310+
);
9311+
}
92929312
}
92939313
if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
92949314
if (partialIdx === -1) {

packages/trigger-sdk/test/steering-error-path.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ describe("chat.agent steering on a turn that fails", () => {
9292
const chatId = "steer-error-path";
9393
const toolGate = deferred();
9494
let toolEntered = false;
95-
const events: { newUIMessages: UIMessage[]; messages: unknown[]; finishReason?: string }[] = [];
95+
const events: {
96+
newUIMessages: UIMessage[];
97+
messages: unknown[];
98+
newMessages: unknown[];
99+
finishReason?: string;
100+
}[] = [];
96101
const promptsSawSteer: boolean[] = [];
97102

98103
const gateTool = tool({
@@ -132,11 +137,22 @@ describe("chat.agent steering on a turn that fails", () => {
132137

133138
const agent = chat.agent({
134139
id: "steer-error-path",
135-
pendingMessages: { shouldInject: () => true },
136-
onTurnComplete: async ({ newUIMessages, messages, finishReason }) => {
140+
pendingMessages: {
141+
shouldInject: () => true,
142+
prepare: async ({ messages }) => [
143+
{
144+
role: "system",
145+
content: `[OPERATOR-NOTE] ${messages
146+
.map((m) => (m.parts as { text?: string }[]).map((p) => p.text ?? "").join(""))
147+
.join(" ")}`,
148+
},
149+
],
150+
},
151+
onTurnComplete: async ({ newUIMessages, messages, newMessages, finishReason }) => {
137152
events.push({
138153
newUIMessages: [...(newUIMessages ?? [])],
139154
messages: [...messages],
155+
newMessages: [...(newMessages ?? [])],
140156
finishReason,
141157
});
142158
},
@@ -176,7 +192,11 @@ describe("chat.agent steering on a turn that fails", () => {
176192
// does the prompt the next turn actually sends. Reconciling only on the
177193
// success path leaves it pending, so the next turn misses it and it
178194
// lands a slot late at the end of that turn.
179-
expect(JSON.stringify(events[0]!.messages)).toContain("steer-me");
195+
expect(JSON.stringify(events[0]!.messages)).toContain("[OPERATOR-NOTE] steer-me");
196+
// The per-turn delta carries the same form, not a reconversion of the UI
197+
// message: append-only model persistence from `newMessages` would
198+
// otherwise store a different instruction from the one the model acted on.
199+
expect(JSON.stringify(events[0]!.newMessages)).toContain("[OPERATOR-NOTE] steer-me");
180200
const promptsBefore = promptsSawSteer.length;
181201
await harness.sendMessage(userMessage("m3", "u-3"));
182202
await waitFor(() => promptsSawSteer.length > promptsBefore, "turn 2 prompt built");

packages/trigger-sdk/test/steering-history-edit-once.test.ts

Lines changed: 124 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -65,77 +65,136 @@ async function sendAndLand(
6565
}
6666
const countOf = (hay: string, needle: string) => hay.split(needle).length - 1;
6767

68-
describe("a history edit after a steer was drained", () => {
69-
it("sends the steer to later turns exactly once", { timeout: 30_000 }, async () => {
70-
const chatId = "steer-history-edit-once";
71-
const toolGate = deferred();
72-
let toolEntered = false;
73-
const prompts: string[] = [];
68+
type Variant = { prepare?: boolean; compact?: boolean; deleteSteer?: boolean };
7469

75-
const gateTool = tool({
76-
description: "blocks until the test opens it",
77-
inputSchema: z.object({ q: z.string() }),
78-
execute: async () => {
79-
toolEntered = true;
80-
await toolGate.promise;
81-
return "ok";
82-
},
83-
});
70+
/** One steered turn with a history edit from onInjected, then a follow-up turn. Returns turn 2's prompt. */
71+
async function runVariant(chatId: string, v: Variant): Promise<string> {
72+
const toolGate = deferred();
73+
let toolEntered = false;
74+
const prompts: string[] = [];
75+
let compacted = 0;
8476

85-
let step = 0;
86-
const model = new MockLanguageModelV3({
87-
doStream: async ({ prompt }) => {
88-
prompts.push(JSON.stringify(prompt));
89-
const isToolStep = step++ % 2 === 0;
90-
return {
91-
stream: simulateReadableStream({
92-
chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"),
93-
initialDelayInMs: 10,
94-
chunkDelayInMs: 2,
95-
}),
96-
};
97-
},
98-
});
77+
const gateTool = tool({
78+
description: "blocks until the test opens it",
79+
inputSchema: z.object({ q: z.string() }),
80+
execute: async () => {
81+
toolEntered = true;
82+
await toolGate.promise;
83+
return "ok";
84+
},
85+
});
9986

100-
const agent = chat.agent({
101-
id: "steer-history-edit-once",
102-
pendingMessages: {
103-
shouldInject: () => true,
104-
// An identity rewrite is enough: any chat.history write sets the
105-
// override that is applied by rebuilding from the UI lane.
106-
onInjected: () => {
107-
chat.history.set(chat.history.all());
108-
},
109-
},
110-
run: async ({ messages, signal }) =>
111-
streamText({
112-
model,
113-
messages,
114-
abortSignal: signal,
115-
...chat.toStreamTextOptions(),
116-
tools: { gate: gateTool },
117-
stopWhen: stepCountIs(5),
87+
let step = 0;
88+
const model = new MockLanguageModelV3({
89+
doStream: async ({ prompt }) => {
90+
prompts.push(JSON.stringify(prompt));
91+
const isToolStep = step++ % 2 === 0;
92+
return {
93+
stream: simulateReadableStream({
94+
chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"),
95+
initialDelayInMs: 10,
96+
chunkDelayInMs: 2,
11897
}),
119-
});
98+
};
99+
},
100+
});
101+
102+
const agent = chat.agent({
103+
id: chatId,
104+
pendingMessages: {
105+
shouldInject: () => true,
106+
...(v.prepare
107+
? {
108+
prepare: async ({ messages }) => [
109+
{
110+
role: "system" as const,
111+
content: `[OPERATOR-NOTE] ${messages.map((m) => (m.parts as { text?: string }[]).map((p) => p.text ?? "").join("")).join(" ")}`,
112+
},
113+
],
114+
}
115+
: {}),
116+
onInjected: () => {
117+
chat.history.set(chat.history.all().filter((m) => !(v.deleteSteer && m.id === "u-2")));
118+
},
119+
},
120+
...(v.compact
121+
? {
122+
compaction: {
123+
shouldCompact: () => compacted === 0,
124+
summarize: async () => {
125+
compacted++;
126+
return "SUMMARY-OF-EVERYTHING";
127+
},
128+
},
129+
}
130+
: {}),
131+
run: async ({ messages, signal }) =>
132+
streamText({
133+
model,
134+
messages,
135+
abortSignal: signal,
136+
...chat.toStreamTextOptions(),
137+
tools: { gate: gateTool },
138+
stopWhen: stepCountIs(5),
139+
}),
140+
});
141+
142+
const harness = mockChatAgent(agent, { chatId });
143+
try {
144+
const first = harness.sendMessage(userMessage("m1", "u-1"));
145+
await waitFor(() => toolEntered, "tool entered");
146+
await sendAndLand(harness, chatId, "steer-me", "u-2");
147+
toolGate.resolve();
148+
await first;
149+
await waitFor(() => prompts.length >= 2, "turn 1 done");
150+
if (v.compact) await waitFor(() => compacted > 0, "compaction ran");
151+
const promptsAfterTurn1 = prompts.length;
152+
await harness.sendMessage(userMessage("m3", "u-3"));
153+
await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built");
154+
return prompts[promptsAfterTurn1]!;
155+
} finally {
156+
toolGate.resolve();
157+
await harness.close();
158+
}
159+
}
160+
161+
describe("a history edit after a steer was drained", () => {
162+
it("sends the steer to later turns exactly once", { timeout: 30_000 }, async () => {
163+
const turn2 = await runVariant("steer-history-edit-once", {});
164+
expect(countOf(turn2, '"steer-me"')).toBe(1);
165+
});
120166

121-
const harness = mockChatAgent(agent, { chatId });
122-
try {
123-
const first = harness.sendMessage(userMessage("m1", "u-1"));
124-
await waitFor(() => toolEntered, "tool entered");
125-
await sendAndLand(harness, chatId, "steer-me", "u-2");
126-
toolGate.resolve();
127-
await first;
128-
await waitFor(() => prompts.length >= 2, "turn 1 done");
129-
const promptsAfterTurn1 = prompts.length;
167+
it("keeps the prepared form, once", { timeout: 30_000 }, async () => {
168+
/**
169+
* The rebuild converts the UI message, which is the raw form. If the raw
170+
* form is what stays, the model's memory of the instruction differs from
171+
* the one it acted on. If both stay, it is there twice.
172+
*/
173+
const turn2 = await runVariant("steer-history-edit-prepared", { prepare: true });
174+
expect(countOf(turn2, "[OPERATOR-NOTE] steer-me")).toBe(1);
175+
expect(countOf(turn2, '"steer-me"')).toBe(0);
176+
});
130177

131-
await harness.sendMessage(userMessage("m3", "u-3"));
132-
await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built");
178+
it("keeps the steer when compaction also replaces the lane", { timeout: 30_000 }, async () => {
179+
/**
180+
* A model-only compaction replaces the rebuilt lane, raw steer included.
181+
* If reconciliation then withholds the prepared form because the rebuild
182+
* "already had it", the steer is gone from the model lane altogether.
183+
*/
184+
const turn2 = await runVariant("steer-history-edit-compacted", {
185+
prepare: true,
186+
compact: true,
187+
});
188+
expect(turn2).toContain("SUMMARY-OF-EVERYTHING");
189+
expect(countOf(turn2, "[OPERATOR-NOTE] steer-me")).toBe(1);
190+
});
133191

134-
const turn2 = prompts[promptsAfterTurn1]!;
135-
expect(countOf(turn2, '"steer-me"')).toBe(1);
136-
} finally {
137-
toolGate.resolve();
138-
await harness.close();
139-
}
192+
it("does not bring back a steer the edit removed", { timeout: 30_000 }, async () => {
193+
/** The edit is the app's decision. Reconciliation must not undo it. */
194+
const turn2 = await runVariant("steer-history-edit-deleted", {
195+
prepare: true,
196+
deleteSteer: true,
197+
});
198+
expect(turn2).not.toContain("steer-me");
140199
});
141200
});

0 commit comments

Comments
 (0)