Skip to content

Commit 4f535ac

Browse files
committed
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.
1 parent c9fe897 commit 4f535ac

2 files changed

Lines changed: 187 additions & 20 deletions

File tree

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

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3578,7 +3578,14 @@ const chatTurnNewUIMessagesKey = locals.create<UIMessage[]>("chat.turnNewUIMessa
35783578
* summary and deliberately leaves the UI lane whole, so reconverting the UI
35793579
* lane restores every message the summary replaced.
35803580
*/
3581-
const chatPendingSteerKey = locals.create<UIMessage[]>("chat.pendingSteer");
3581+
const chatPendingSteerKey = locals.create<PendingSteer[]>("chat.pendingSteer");
3582+
3583+
/**
3584+
* A consumed steering message in both forms: the UI message for display and
3585+
* persistence, and the model messages `pendingMessages.prepare` produced for
3586+
* it, which is what the model actually saw and what later turns must see too.
3587+
*/
3588+
type PendingSteer = { ui: UIMessage; model: ModelMessage[] };
35823589
/** @internal — IDs of messages that were successfully injected via prepareStep */
35833590
const chatInjectedMessageIdsKey = locals.create<Set<string>>("chat.injectedMessageIds");
35843591
/** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -4173,6 +4180,16 @@ type DrainedSteering = {
41734180

41744181
const EMPTY_DRAIN: DrainedSteering = { injected: [], claimed: [] };
41754182

4183+
/**
4184+
* The model messages to record for one claimed message. Without `prepare`
4185+
* each entry's own conversion is used. With it, `prepare` returned one list
4186+
* for the whole batch, so the first claimed message carries all of it and the
4187+
* rest carry none, which keeps the total exactly what the model received.
4188+
*/
4189+
function modelFormOf(m: UIMessage, batch: UIMessage[], injected: ModelMessage[]): ModelMessage[] {
4190+
return batch[0] === m ? injected : [];
4191+
}
4192+
41764193
/**
41774194
* Drain the steering queue as a batch. Calls `shouldInject` once with all
41784195
* pending messages. If it returns true, calls `prepareMessages` once to
@@ -4322,7 +4339,9 @@ async function drainSteeringQueue(
43224339
if (claimedUIMessages.length > 0 && currentUIMessages) {
43234340
const pendingSteer = locals.get(chatPendingSteerKey) ?? [];
43244341
for (const m of claimedUIMessages) {
4325-
if (!pendingSteer.some((existing) => existing.id === m.id)) pendingSteer.push(m);
4342+
if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
4343+
pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
4344+
}
43264345
}
43274346
locals.set(chatPendingSteerKey, pendingSteer);
43284347
}
@@ -6681,6 +6700,19 @@ function chatAgent<
66816700
// durable snapshot + `session.out` replay (or `hydrateMessages` if
66826701
// registered) — the wire is delta-only now, no longer a seed.
66836702
let accumulatedMessages: ModelMessage[] = [];
6703+
/**
6704+
* Give the model accumulator the steering messages a drain consumed,
6705+
* in the form the model actually received. Appended, never reconverted
6706+
* from the UI lane, so a model-only compaction summary survives. Called
6707+
* on both the success and the error path, before the response or the
6708+
* partial joins the lane, so the order stays steer-then-answer.
6709+
*/
6710+
const reconcilePendingSteer = () => {
6711+
const pending = locals.get(chatPendingSteerKey);
6712+
if (!pending || pending.length === 0) return;
6713+
locals.set(chatPendingSteerKey, []);
6714+
for (const entry of pending) accumulatedMessages.push(...entry.model);
6715+
};
66846716

66856717
// Accumulated UI messages for persistence. Mirrors the model accumulator
66866718
// but in frontend-friendly UIMessage format (with parts, id, etc.).
@@ -8558,20 +8590,7 @@ function chatAgent<
85588590
// before the response is appended so the order stays
85598591
// steer-then-answer. Outside the `capturedResponseMessage`
85608592
// branches below, so a turn that captured no response is covered.
8561-
const pendingSteer = locals.get(chatPendingSteerKey);
8562-
if (pendingSteer && pendingSteer.length > 0) {
8563-
locals.set(chatPendingSteerKey, []);
8564-
try {
8565-
accumulatedMessages.push(
8566-
...(await toModelMessages(pendingSteer.map(stripProviderMetadata)))
8567-
);
8568-
} catch (error) {
8569-
logger.warn(
8570-
"chat.agent: toModelMessages failed for an injected message; it will be missing from the next turn",
8571-
{ error: error instanceof Error ? error.message : String(error) }
8572-
);
8573-
}
8574-
}
8593+
reconcilePendingSteer();
85758594

85768595
// Append the assistant's response (partial or complete) to the accumulator.
85778596
// The onFinish callback fires even on abort/stop, so partial responses
@@ -10705,11 +10724,15 @@ class ChatMessageAccumulator {
1070510724
* the UI lane whole: a reconversion would restore everything the summary
1070610725
* replaced.
1070710726
*/
10708-
async absorbSteering(claimed: UIMessage[]): Promise<void> {
10727+
async absorbSteering(claimed: UIMessage[], injected?: ModelMessage[]): Promise<void> {
1070910728
const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
1071010729
if (fresh.length === 0) return;
1071110730
this.uiMessages.push(...fresh);
10712-
this.modelMessages.push(...(await toModelMessages(fresh)));
10731+
// Record what the model received. Only when the whole batch is new is
10732+
// `injected` known to describe exactly these messages.
10733+
this.modelMessages.push(
10734+
...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh))
10735+
);
1071310736
}
1071410737

1071510738
/**
@@ -10758,7 +10781,7 @@ class ChatMessageAccumulator {
1075810781
steps,
1075910782
queue
1076010783
);
10761-
await this.absorbSteering(claimed);
10784+
await this.absorbSteering(claimed, injected);
1076210785
if (injected.length > 0) {
1076310786
resultMessages = [...(resultMessages ?? messages), ...injected];
1076410787
}
@@ -11534,7 +11557,7 @@ function createChatSession<TClientData = unknown>(
1153411557
steps,
1153511558
turnSteeringQueue
1153611559
);
11537-
await accumulator.absorbSteering(claimed);
11560+
await accumulator.absorbSteering(claimed, injected);
1153811561
if (injected.length > 0) {
1153911562
resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
1154011563
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { mockChatAgent } from "../src/v3/test/index.js";
2+
3+
import { sessionStreams } from "@trigger.dev/core/v3";
4+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
5+
import { simulateReadableStream, stepCountIs, streamText, tool } from "ai";
6+
import { MockLanguageModelV3 } from "ai/test";
7+
import { describe, expect, it } from "vitest";
8+
import { z } from "zod";
9+
import { chat } from "../src/v3/ai.js";
10+
11+
/**
12+
* `pendingMessages.prepare` decides how a steer is presented to the model.
13+
* The steered turn gets that form. Later turns have to get the same form,
14+
* or the model's memory of the instruction differs from what it acted on.
15+
*/
16+
17+
const USAGE = {
18+
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
19+
outputTokens: { total: 1, text: 1, reasoning: undefined },
20+
};
21+
const userMessage = (text: string, id: string) => ({
22+
id,
23+
role: "user" as const,
24+
parts: [{ type: "text" as const, text }],
25+
});
26+
function deferred() {
27+
let resolve!: () => void;
28+
const promise = new Promise<void>((res) => {
29+
resolve = res;
30+
});
31+
return { promise, resolve };
32+
}
33+
async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) {
34+
const start = Date.now();
35+
while (Date.now() - start < timeoutMs) {
36+
if (check()) return;
37+
await new Promise((r) => setTimeout(r, 10));
38+
}
39+
throw new Error(`waitFor timed out: ${label}`);
40+
}
41+
const textChunks = (text: string): LanguageModelV3StreamPart[] => [
42+
{ type: "text-start", id: "t1" },
43+
{ type: "text-delta", id: "t1", delta: text },
44+
{ type: "text-end", id: "t1" },
45+
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE },
46+
];
47+
const toolCallChunks = (callId: string): LanguageModelV3StreamPart[] => [
48+
{ type: "tool-call", toolCallId: callId, toolName: "gate", input: JSON.stringify({ q: "go" }) },
49+
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" }, usage: USAGE },
50+
];
51+
type SeqReader = { lastSeqNum: (chatId: string, dir: "in" | "out") => number | undefined };
52+
async function sendAndLand(
53+
harness: { sendMessage: (m: ReturnType<typeof userMessage>) => Promise<unknown> },
54+
chatId: string,
55+
text: string,
56+
id: string
57+
) {
58+
const seqs = sessionStreams as unknown as SeqReader;
59+
const before = seqs.lastSeqNum(chatId, "in") ?? -1;
60+
void harness.sendMessage(userMessage(text, id));
61+
await waitFor(() => (seqs.lastSeqNum(chatId, "in") ?? -1) > before, `append ${id}`);
62+
}
63+
64+
describe("a steer transformed by pendingMessages.prepare", () => {
65+
it("reaches later turns in the transformed form", { timeout: 30_000 }, async () => {
66+
const chatId = "steer-prepare-transform";
67+
const toolGate = deferred();
68+
let toolEntered = false;
69+
const prompts: string[] = [];
70+
71+
const gateTool = tool({
72+
description: "blocks until the test opens it",
73+
inputSchema: z.object({ q: z.string() }),
74+
execute: async () => {
75+
toolEntered = true;
76+
await toolGate.promise;
77+
return "ok";
78+
},
79+
});
80+
81+
let step = 0;
82+
const model = new MockLanguageModelV3({
83+
doStream: async ({ prompt }) => {
84+
prompts.push(JSON.stringify(prompt));
85+
const isToolStep = step++ % 2 === 0;
86+
return {
87+
stream: simulateReadableStream({
88+
chunks: isToolStep ? toolCallChunks(`tc-${step}`) : textChunks("done"),
89+
initialDelayInMs: 10,
90+
chunkDelayInMs: 2,
91+
}),
92+
};
93+
},
94+
});
95+
96+
const agent = chat.agent({
97+
id: "steer-prepare-transform",
98+
pendingMessages: {
99+
shouldInject: () => true,
100+
// Present the steer to the model as an operator note, not a user turn.
101+
prepare: async ({ messages }) => [
102+
{
103+
role: "system",
104+
content: `[OPERATOR-NOTE] ${messages.map((m) => (m.parts as { text?: string }[]).map((p) => p.text ?? "").join("")).join(" ")}`,
105+
},
106+
],
107+
},
108+
run: async ({ messages, signal }) =>
109+
streamText({
110+
model,
111+
messages,
112+
abortSignal: signal,
113+
...chat.toStreamTextOptions(),
114+
tools: { gate: gateTool },
115+
stopWhen: stepCountIs(5),
116+
}),
117+
});
118+
119+
const harness = mockChatAgent(agent, { chatId });
120+
try {
121+
const first = harness.sendMessage(userMessage("m1", "u-1"));
122+
await waitFor(() => toolEntered, "tool entered");
123+
await sendAndLand(harness, chatId, "steer-me", "u-2");
124+
toolGate.resolve();
125+
await first;
126+
await waitFor(() => prompts.length >= 2, "turn 1 second step");
127+
128+
// The steered turn saw the transformed form.
129+
expect(prompts[1]!).toContain("[OPERATOR-NOTE] steer-me");
130+
const promptsAfterTurn1 = prompts.length;
131+
132+
await harness.sendMessage(userMessage("m3", "u-3"));
133+
await waitFor(() => prompts.length > promptsAfterTurn1, "turn 2 prompt built");
134+
135+
// And so does the next one. Reconverting the UI message gives the raw
136+
// user turn instead, so the model remembers a different instruction
137+
// from the one it acted on.
138+
expect(prompts[promptsAfterTurn1]!).toContain("[OPERATOR-NOTE] steer-me");
139+
} finally {
140+
toolGate.resolve();
141+
await harness.close();
142+
}
143+
});
144+
});

0 commit comments

Comments
 (0)