Skip to content

Commit f6bfe94

Browse files
committed
test(sdk): cover injection at a prepareStep boundary
Injection had no unit coverage. A prepareStep boundary only exists on a turn that takes more than one step, and nothing in this package produced one, so every steering test asserted arrival and none could reach the drain. Both bugs found in that code during review were found by reading it, not by running it. `twoStepModel` gives a turn a real boundary: step one calls a tool, the tool blocks on a gate the test holds, and step two answers. Holding the tool open is what makes it deterministic, since a message appended while the gate is shut is queued before `prepareStep` runs with no reliance on stream timing. Three cases, each checked against the commit that introduced the bug it guards rather than only observed to pass: - a mid-turn message is injected and not also answered as its own turn - a message arriving after the batch was assembled is left for a later turn, which fails at 3dd60c2 - a claim is returned when `prepare` throws, which fails at e3e6ad7 The middle one needed two attempts. Asserting the late message eventually gets answered passes on the bug, because without the snapshot its model messages are injected into the first turn, so the text appears either way. A second turn-complete is the real discriminator.
1 parent 41e132d commit f6bfe94

1 file changed

Lines changed: 353 additions & 0 deletions

File tree

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
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+
* A `prepareStep` boundary, which is where pending messages are injected, only
13+
* exists on a turn that takes more than one step. Nothing else in this package
14+
* produced one, so injection had no unit coverage at all: every steering test
15+
* asserted arrival and none could reach the drain.
16+
*
17+
* `twoStepModel` gives a turn a real boundary. Step one calls a tool, the tool
18+
* blocks on a gate the test controls, and step two answers. Holding the tool
19+
* open is what makes the boundary deterministic: a message appended while the
20+
* gate is shut is guaranteed to be queued before `prepareStep` runs, with no
21+
* reliance on stream timing.
22+
*/
23+
24+
const USAGE = {
25+
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
26+
outputTokens: { total: 1, text: 1, reasoning: undefined },
27+
};
28+
29+
function userMessage(text: string, id: string) {
30+
return { id, role: "user" as const, parts: [{ type: "text" as const, text }] };
31+
}
32+
33+
function textChunks(text: string): LanguageModelV3StreamPart[] {
34+
return [
35+
{ type: "text-start", id: "t1" },
36+
{ type: "text-delta", id: "t1", delta: text },
37+
{ type: "text-end", id: "t1" },
38+
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE },
39+
];
40+
}
41+
42+
function toolCallChunks(callId: string): LanguageModelV3StreamPart[] {
43+
return [
44+
{ type: "tool-input-start", id: callId, toolName: "gate" },
45+
{ type: "tool-input-delta", id: callId, delta: JSON.stringify({ q: "x" }) },
46+
{ type: "tool-input-end", id: callId },
47+
{ type: "tool-call", toolCallId: callId, toolName: "gate", input: JSON.stringify({ q: "x" }) },
48+
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: USAGE },
49+
];
50+
}
51+
52+
function lastUserText(prompt: { role: string; content: unknown }[]): string {
53+
const users = prompt.filter((m) => m.role === "user");
54+
const last = users[users.length - 1];
55+
return Array.isArray(last?.content)
56+
? (last.content as { type: string; text?: string }[])
57+
.filter((p) => p.type === "text")
58+
.map((p) => p.text ?? "")
59+
.join("")
60+
: "";
61+
}
62+
63+
/** Emits a tool call on each turn's first step, then answers on the second. */
64+
function twoStepModel() {
65+
let step = 0;
66+
return new MockLanguageModelV3({
67+
doStream: async ({ prompt }) => {
68+
const isToolStep = step++ % 2 === 0;
69+
return {
70+
stream: simulateReadableStream({
71+
chunks: isToolStep
72+
? toolCallChunks(`tc-${step}`)
73+
: textChunks(`ANSWER(${lastUserText(prompt)})`),
74+
initialDelayInMs: 10,
75+
chunkDelayInMs: 2,
76+
}),
77+
};
78+
},
79+
});
80+
}
81+
82+
function makeGate() {
83+
let open: () => void = () => {};
84+
const promise = new Promise<void>((resolve) => {
85+
open = resolve;
86+
});
87+
return { promise, open };
88+
}
89+
90+
function streamedText(harness: { allChunks: unknown[] }): string {
91+
return (harness.allChunks as { type?: string; delta?: string }[])
92+
.filter((c) => c.type === "text-delta")
93+
.map((c) => c.delta ?? "")
94+
.join("");
95+
}
96+
97+
function injectedChunks(harness: { allRawChunks: unknown[] }) {
98+
return (harness.allRawChunks as { type?: string; data?: { messageIds?: string[] } }[]).filter(
99+
(c) => c.type === "data-pending-message-injected"
100+
);
101+
}
102+
103+
function injectedIds(harness: { allRawChunks: unknown[] }): string[] {
104+
return injectedChunks(harness).flatMap((c) => c.data?.messageIds ?? []);
105+
}
106+
107+
function turnCompleteCount(harness: { allRawChunks: unknown[] }): number {
108+
return (harness.allRawChunks as { type?: string }[]).filter(
109+
(c) => c.type === "trigger:turn-complete"
110+
).length;
111+
}
112+
113+
async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) {
114+
const start = Date.now();
115+
while (Date.now() - start < timeoutMs) {
116+
if (check()) return;
117+
await new Promise((r) => setTimeout(r, 10));
118+
}
119+
throw new Error(`waitFor timed out: ${label}`);
120+
}
121+
122+
type SeqReader = { lastSeqNum(sessionId: string, io: "in" | "out"): number | undefined };
123+
124+
/** Appends a message and resolves once the channel has actually taken it. */
125+
async function sendAndLand(
126+
harness: { sendMessage: (m: ReturnType<typeof userMessage>) => Promise<unknown> },
127+
chatId: string,
128+
text: string,
129+
id: string
130+
) {
131+
const seqs = sessionStreams as unknown as SeqReader;
132+
const before = seqs.lastSeqNum(chatId, "in") ?? -1;
133+
void harness.sendMessage(userMessage(text, id));
134+
await waitFor(() => (seqs.lastSeqNum(chatId, "in") ?? -1) > before, `append ${id}`);
135+
}
136+
137+
describe("chat.agent injection at a prepareStep boundary", () => {
138+
it(
139+
"injects a message that arrived mid-turn and does not answer it again",
140+
{ timeout: 30_000 },
141+
async () => {
142+
const chatId = "inject-basic";
143+
const gate = makeGate();
144+
let toolEntered = false;
145+
const injectedBatches: string[][] = [];
146+
147+
const gateTool = tool({
148+
description: "blocks until the test opens it",
149+
inputSchema: z.object({ q: z.string() }),
150+
execute: async () => {
151+
toolEntered = true;
152+
await gate.promise;
153+
return "ok";
154+
},
155+
});
156+
157+
const agent = chat.agent({
158+
id: "steering-injection.basic",
159+
pendingMessages: {
160+
shouldInject: () => true,
161+
onInjected: ({ messages }) => {
162+
injectedBatches.push(messages.map((m) => m.id));
163+
},
164+
},
165+
run: async ({ messages, signal }) =>
166+
streamText({
167+
model: twoStepModel(),
168+
messages,
169+
abortSignal: signal,
170+
// Spread first so the prepareStep it supplies survives and nothing
171+
// below is clobbered by it.
172+
...chat.toStreamTextOptions(),
173+
tools: { gate: gateTool },
174+
stopWhen: stepCountIs(5),
175+
}),
176+
});
177+
178+
const harness = mockChatAgent(agent, { chatId });
179+
try {
180+
const first = harness.sendMessage(userMessage("m1", "u-1"));
181+
await waitFor(() => toolEntered, "tool entered");
182+
183+
await sendAndLand(harness, chatId, "m2", "u-2");
184+
gate.open();
185+
await first;
186+
187+
await waitFor(() => injectedBatches.length > 0, "onInjected fired");
188+
189+
expect(injectedBatches[0]).toEqual(["u-2"]);
190+
expect(injectedIds(harness)).toContain("u-2");
191+
// Answered inside turn 1, which is what injection means.
192+
expect(streamedText(harness)).toContain("ANSWER(m2)");
193+
194+
// And consumed by it, so it must not also get a turn of its own. A
195+
// second turn-complete would mean the record was left on the channel.
196+
await new Promise((r) => setTimeout(r, 400));
197+
expect(turnCompleteCount(harness)).toBe(1);
198+
} finally {
199+
gate.open();
200+
await harness.close();
201+
}
202+
}
203+
);
204+
});
205+
206+
describe("chat.agent injection claims only its own batch", () => {
207+
/**
208+
* `shouldInject` and `prepare` can await, so a record can arrive after the
209+
* batch was assembled. The callbacks never saw it and could not have injected
210+
* it, so consuming it with the batch would lose a message that should have
211+
* become a later turn.
212+
*/
213+
it(
214+
"leaves a message that arrived after the batch was assembled",
215+
{ timeout: 30_000 },
216+
async () => {
217+
const chatId = "inject-late-arrival";
218+
const toolGate = makeGate();
219+
const injectGate = makeGate();
220+
let toolEntered = false;
221+
let injectAsked = false;
222+
const injectedBatches: string[][] = [];
223+
224+
const gateTool = tool({
225+
description: "blocks until the test opens it",
226+
inputSchema: z.object({ q: z.string() }),
227+
execute: async () => {
228+
toolEntered = true;
229+
await toolGate.promise;
230+
return "ok";
231+
},
232+
});
233+
234+
const agent = chat.agent({
235+
id: "steering-injection.late-arrival",
236+
pendingMessages: {
237+
shouldInject: async () => {
238+
injectAsked = true;
239+
await injectGate.promise;
240+
return true;
241+
},
242+
onInjected: ({ messages }) => {
243+
injectedBatches.push(messages.map((m) => m.id));
244+
},
245+
},
246+
run: async ({ messages, signal }) =>
247+
streamText({
248+
model: twoStepModel(),
249+
messages,
250+
abortSignal: signal,
251+
...chat.toStreamTextOptions(),
252+
tools: { gate: gateTool },
253+
stopWhen: stepCountIs(5),
254+
}),
255+
});
256+
257+
const harness = mockChatAgent(agent, { chatId });
258+
try {
259+
const first = harness.sendMessage(userMessage("m1", "u-1"));
260+
await waitFor(() => toolEntered, "tool entered");
261+
262+
// m2 is the batch: queued before the boundary, so the callback sees it.
263+
await sendAndLand(harness, chatId, "m2", "u-2");
264+
toolGate.open();
265+
await waitFor(() => injectAsked, "shouldInject called");
266+
267+
// m3 lands while the callback is parked, so it is not in the batch.
268+
await sendAndLand(harness, chatId, "m3", "u-3");
269+
injectGate.open();
270+
await first;
271+
272+
await waitFor(() => injectedBatches.length > 0, "onInjected fired");
273+
expect(injectedBatches.flat()).toEqual(["u-2"]);
274+
expect(injectedIds(harness)).not.toContain("u-3");
275+
276+
/**
277+
* The discriminator. Without the snapshot, m3 is swept into the same
278+
* drain: its model messages reach the model, so `ANSWER(m3)` still
279+
* appears, but inside turn 1 and without being reported as injected.
280+
* Asserting on the text alone therefore passes on the bug. A second
281+
* turn-complete is what distinguishes "m3 got its own turn" from "m3
282+
* was silently consumed by turn 1".
283+
*/
284+
await waitFor(() => turnCompleteCount(harness) >= 2, "m3 got its own turn");
285+
expect(streamedText(harness)).toContain("ANSWER(m3)");
286+
} finally {
287+
toolGate.open();
288+
injectGate.open();
289+
await harness.close();
290+
}
291+
}
292+
);
293+
294+
/**
295+
* `prepare` is caller code. Claiming happens before it runs, so a throw would
296+
* consume the messages and leave them unanswered unless the claim is returned.
297+
*/
298+
it("gives the claim back when prepare throws", { timeout: 30_000 }, async () => {
299+
const chatId = "inject-prepare-throws";
300+
const toolGate = makeGate();
301+
let toolEntered = false;
302+
let prepareCalls = 0;
303+
304+
const gateTool = tool({
305+
description: "blocks until the test opens it",
306+
inputSchema: z.object({ q: z.string() }),
307+
execute: async () => {
308+
toolEntered = true;
309+
await toolGate.promise;
310+
return "ok";
311+
},
312+
});
313+
314+
const agent = chat.agent({
315+
id: "steering-injection.prepare-throws",
316+
pendingMessages: {
317+
shouldInject: () => true,
318+
prepare: () => {
319+
prepareCalls++;
320+
throw new Error("synthetic prepare failure");
321+
},
322+
},
323+
run: async ({ messages, signal }) =>
324+
streamText({
325+
model: twoStepModel(),
326+
messages,
327+
abortSignal: signal,
328+
...chat.toStreamTextOptions(),
329+
tools: { gate: gateTool },
330+
stopWhen: stepCountIs(5),
331+
}),
332+
});
333+
334+
const harness = mockChatAgent(agent, { chatId });
335+
try {
336+
const first = harness.sendMessage(userMessage("m1", "u-1")).catch(() => undefined);
337+
await waitFor(() => toolEntered, "tool entered");
338+
339+
await sendAndLand(harness, chatId, "m2", "u-2");
340+
toolGate.open();
341+
await first;
342+
343+
await waitFor(() => prepareCalls > 0, "prepare called");
344+
// Nothing was injected, and the message must not have been eaten by the
345+
// failed transform: it is still owed and gets answered by a later turn.
346+
expect(injectedIds(harness)).not.toContain("u-2");
347+
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"), "m2 answered later");
348+
} finally {
349+
toolGate.open();
350+
await harness.close();
351+
}
352+
});
353+
});

0 commit comments

Comments
 (0)