Skip to content

Commit 692c060

Browse files
committed
fix(chat): keep an injection made during the turn that consumed the lane
Consuming the instructions lane marked the blocks read but left them in it, so an injection made in that turn's onTurnComplete queued behind them and the next turn's clear destroyed both. Turn 1 carried its instruction and every turn after it silently carried none, which is worse than the per-read draining it replaced. The consumed blocks now move to turn-scoped state, so a second options build in the same turn still sees them while the lane holds only what is pending. Also guards the stash lookup: outside a turn both sides of the turn comparison are undefined, so the optional-chained check matched and dereferenced nothing.
1 parent e2737f6 commit 692c060

2 files changed

Lines changed: 92 additions & 24 deletions

File tree

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

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,10 +2712,18 @@ const chatBackgroundQueueKey = locals.create<ModelMessage[]>("chat.backgroundQue
27122712
const chatInjectedInstructionsKey = locals.create<SystemModelMessage[]>(
27132713
"chat.injectedInstructions"
27142714
);
2715-
/** The turn that consumed the instructions lane, so a second read in the same turn still sees it. */
2716-
const chatInstructionsConsumedTurnKey = locals.create<number | undefined>(
2717-
"chat.injectedInstructionsConsumedTurn"
2718-
);
2715+
/**
2716+
* What a turn already consumed from the instructions lane, so a second
2717+
* `toStreamTextOptions()` call in the same turn sees the same blocks.
2718+
*
2719+
* Consumed blocks are moved here rather than left in the pending lane: leaving
2720+
* them there means an injection made during the consumed turn sits behind them,
2721+
* and clearing the lane on the next turn destroys both.
2722+
*/
2723+
const chatInstructionsConsumedKey = locals.create<{
2724+
turn: number;
2725+
blocks: SystemModelMessage[];
2726+
}>("chat.injectedInstructionsConsumed");
27192727

27202728
/**
27212729
* Run-scoped pipe counter. Stored in locals so concurrent runs in the
@@ -4734,28 +4742,34 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
47344742
* caching, and the addition reads as a later amendment. A changed prefix does
47354743
* cost the first call its cache hit, on turns that actually injected.
47364744
*/
4745+
/**
4746+
* Consumed once per turn, not once per read, and moved out of the lane rather
4747+
* than marked read in place.
4748+
*
4749+
* Per turn, because a `run()` that builds options twice (a cheap classifier
4750+
* pass and then the answer) has to see the injection in both, and draining on
4751+
* read hands it to whichever call ran first. Moved out, because blocks left in
4752+
* the lane sit in front of anything injected during the same turn, and
4753+
* clearing the lane on the next turn then destroys both. Outside a turn there
4754+
* is no turn to scope the stash to, so the lane drains on read there.
4755+
*/
47374756
const injectedInstructions = locals.get(chatInjectedInstructionsKey);
4738-
if (injectedInstructions && injectedInstructions.length > 0) {
4739-
/**
4740-
* Consumed once per turn, not once per read. A `run()` that builds options
4741-
* twice, a cheap classifier pass and then the answer, has to see the
4742-
* injection in both: draining on read hands it to whichever call ran first
4743-
* and drops it from the rest without saying so. Outside a turn there is no
4744-
* turn to scope that to, so the lane drains on read there instead.
4745-
*/
4746-
const currentTurn = locals.get(chatTurnContextKey)?.turn;
4747-
const consumedTurn = locals.get(chatInstructionsConsumedTurnKey);
4748-
4749-
let blocks: SystemModelMessage[];
4750-
if (currentTurn === undefined) {
4751-
blocks = injectedInstructions.splice(0);
4752-
} else if (consumedTurn !== undefined && consumedTurn !== currentTurn) {
4753-
injectedInstructions.length = 0;
4754-
blocks = [];
4755-
} else {
4756-
locals.set(chatInstructionsConsumedTurnKey, currentTurn);
4757-
blocks = injectedInstructions;
4757+
const currentTurn = locals.get(chatTurnContextKey)?.turn;
4758+
const consumedThisTurn =
4759+
currentTurn === undefined ? undefined : locals.get(chatInstructionsConsumedKey);
4760+
4761+
let injectedBlocks: SystemModelMessage[] = [];
4762+
if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
4763+
injectedBlocks = consumedThisTurn.blocks;
4764+
} else if (injectedInstructions && injectedInstructions.length > 0) {
4765+
injectedBlocks = injectedInstructions.splice(0);
4766+
if (currentTurn !== undefined) {
4767+
locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
47584768
}
4769+
}
4770+
4771+
if (injectedBlocks.length > 0) {
4772+
const blocks = injectedBlocks;
47594773

47604774
const injectedText = blocks
47614775
.map((block) => (typeof block.content === "string" ? block.content : ""))

packages/trigger-sdk/test/inject-system-instructions.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,4 +289,58 @@ describe("chat.inject with a system role", () => {
289289
await harness.close();
290290
}
291291
});
292+
293+
it("gives each turn only its own injection, over consecutive turns", async () => {
294+
/**
295+
* Consuming the lane has to move the blocks out of it, not mark them read in
296+
* place. Left in place, an injection made during the consumed turn queues
297+
* behind them and the next turn's clear destroys both: turn 1 gets its
298+
* instruction and every turn after it silently gets none.
299+
*/
300+
const model = new MockLanguageModelV3({
301+
doStream: async () => ({ stream: textStream("ok") }),
302+
});
303+
304+
let n = 0;
305+
306+
const agent = chat.agent({
307+
id: "inject-system-consecutive",
308+
onTurnComplete: async () => {
309+
n++;
310+
chat.inject([{ role: "system", content: `INJECT-${n}` }]);
311+
},
312+
run: async ({ messages, signal }) =>
313+
streamText({
314+
...chat.toStreamTextOptions(),
315+
model,
316+
messages,
317+
abortSignal: signal,
318+
}),
319+
});
320+
321+
const harness = mockChatAgent(agent, { chatId: "inject-system-consecutive" });
322+
323+
try {
324+
for (const id of ["u1", "u2", "u3", "u4"]) {
325+
await harness.sendMessage({ id, role: "user", parts: [{ type: "text", text: id }] });
326+
await new Promise((r) => setTimeout(r, 40));
327+
}
328+
329+
const injectionsSeenOn = (turn: number) => {
330+
const system = JSON.stringify(
331+
model.doStreamCalls[turn]!.prompt.filter((m) => m.role === "system")
332+
);
333+
return ["INJECT-1", "INJECT-2", "INJECT-3"].filter((key) => system.includes(key));
334+
};
335+
336+
// Turn 0 predates any injection; after that each turn carries exactly the
337+
// one injected at the end of the turn before it.
338+
expect(injectionsSeenOn(0)).toEqual([]);
339+
expect(injectionsSeenOn(1)).toEqual(["INJECT-1"]);
340+
expect(injectionsSeenOn(2)).toEqual(["INJECT-2"]);
341+
expect(injectionsSeenOn(3)).toEqual(["INJECT-3"]);
342+
} finally {
343+
await harness.close();
344+
}
345+
});
292346
});

0 commit comments

Comments
 (0)