Skip to content

Commit a0a07bb

Browse files
committed
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.
1 parent dd06745 commit a0a07bb

1 file changed

Lines changed: 122 additions & 0 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { mockChatAgent } from "../src/v3/test/index.js";
2+
3+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
4+
import { simulateReadableStream } from "ai";
5+
import { MockLanguageModelV3 } from "ai/test";
6+
import { describe, expect, it } from "vitest";
7+
import { z } from "zod";
8+
import { chat } from "../src/v3/ai.js";
9+
10+
/**
11+
* A one-shot instruction and an action in between.
12+
*
13+
* `turn--` marks an action as not-a-turn, so an action and the message after
14+
* it share a turn number. The consumed-instruction stash is keyed on that
15+
* number, so an action that builds options consumes the injection and the next
16+
* real turn reads the same stash back.
17+
*/
18+
19+
const USAGE = {
20+
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
21+
outputTokens: { total: 1, text: 1, reasoning: undefined },
22+
};
23+
24+
function userMessage(text: string, id: string) {
25+
return { id, role: "user" as const, parts: [{ type: "text" as const, text }] };
26+
}
27+
28+
async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) {
29+
const start = Date.now();
30+
while (Date.now() - start < timeoutMs) {
31+
if (check()) return;
32+
await new Promise((r) => setTimeout(r, 10));
33+
}
34+
throw new Error(`waitFor timed out: ${label}`);
35+
}
36+
37+
function textChunks(text: string): LanguageModelV3StreamPart[] {
38+
return [
39+
{ type: "text-start", id: "t1" },
40+
{ type: "text-delta", id: "t1", delta: text },
41+
{ type: "text-end", id: "t1" },
42+
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE },
43+
];
44+
}
45+
46+
describe("a one-shot instruction across an action", () => {
47+
it(
48+
"reaches each turn once and is not replayed by the turn after an action",
49+
{ timeout: 30_000 },
50+
async () => {
51+
/** One entry per model call, in order, saying whether it carried the instruction. */
52+
const sawInstruction: { label: string; saw: boolean }[] = [];
53+
54+
const makeModel = (label: string) =>
55+
new MockLanguageModelV3({
56+
doStream: async ({ prompt }) => {
57+
sawInstruction.push({
58+
label,
59+
saw: JSON.stringify(prompt).includes("INSTRUCTION-ONE-SHOT"),
60+
});
61+
return {
62+
stream: simulateReadableStream({ chunks: textChunks("ok"), initialDelayInMs: 5 }),
63+
};
64+
},
65+
});
66+
67+
const turnModel = makeModel("turn");
68+
const actionModel = makeModel("action");
69+
70+
const agent = chat.agent({
71+
id: "instructions-action-replay",
72+
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("ping") })]),
73+
onTurnComplete: async ({ turn }) => {
74+
// Injecting from inside the run, because the lane lives in run locals.
75+
if (turn === 0)
76+
chat.inject([{ role: "system", content: "INSTRUCTION-ONE-SHOT" }] as never);
77+
},
78+
onAction: async ({ action, streamText: bound }) => {
79+
if (action.type !== "ping") return;
80+
return bound({
81+
model: actionModel,
82+
messages: [{ role: "user", content: "regenerate" }],
83+
});
84+
},
85+
run: async ({ messages, signal, streamText: bound }) =>
86+
bound({ model: turnModel, messages, abortSignal: signal }),
87+
});
88+
89+
const harness = mockChatAgent(agent, { chatId: "instructions-action-replay" });
90+
try {
91+
// Turn 1, nothing injected yet, then inject for the next turn.
92+
await harness.sendMessage(userMessage("m1", "u-1"));
93+
await waitFor(() => sawInstruction.length >= 1, "turn 1");
94+
95+
// An action lands before the next message.
96+
await harness.sendAction({ type: "ping" });
97+
await waitFor(() => sawInstruction.length >= 2, "action");
98+
99+
// Then the real turn the injection was meant for.
100+
await harness.sendMessage(userMessage("m2", "u-2"));
101+
await waitFor(() => sawInstruction.length >= 3, "turn 2");
102+
103+
// And one more, which must not see it again.
104+
await harness.sendMessage(userMessage("m3", "u-3"));
105+
await waitFor(() => sawInstruction.length >= 4, "turn 3");
106+
107+
const carriers = sawInstruction.filter((e) => e.saw).map((e) => e.label);
108+
// The action sees it: it is pending context, and an action is not a turn,
109+
// so the action reading it must not use it up.
110+
expect(sawInstruction[1]!).toEqual({ label: "action", saw: true });
111+
// And the turn it was actually injected for still gets it.
112+
expect(sawInstruction[2]!).toEqual({ label: "turn", saw: true });
113+
// The turn after that does not: one-shot means one turn.
114+
expect(sawInstruction[3]!).toEqual({ label: "turn", saw: false });
115+
// And it is never carried by more than one real turn.
116+
expect(carriers.filter((l) => l === "turn")).toHaveLength(1);
117+
} finally {
118+
await harness.close();
119+
}
120+
}
121+
);
122+
});

0 commit comments

Comments
 (0)