Skip to content

Commit 900418d

Browse files
committed
fix(chat): rebuild the model messages after a steering injection
The UI and model accumulators are maintained separately, and a drained message was appended to the UI one only. The model saw it through the prepareStep return value, which is per-step, so the model lane never learned it existed and every later turn of the run answered without it while the browser, the snapshot and chat.history.* all still showed it. The drain now marks the model lane stale and it is rebuilt from the UI lane at the end of the turn. Flips the it.fails repro in steering-injection.test.ts to a passing test.
1 parent 692c060 commit 900418d

3 files changed

Lines changed: 40 additions & 11 deletions

File tree

.changeset/steering-messages-accumulator.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"@trigger.dev/sdk": patch
33
---
44

5-
Steering messages injected mid-answer are now part of the conversation your hooks see. Previously they reached the model and the browser but not `onTurnComplete`, so an app storing its own transcript lost the instruction the answer was shaped by. It vanished from the conversation on reload, and later turns had no record of it.
5+
Steering messages injected mid-answer are now part of the conversation, both for your hooks and for the model on later turns. Previously they reached the model for the answer they steered and reached the browser, but nothing else: `onTurnComplete` never saw them, so an app storing its own transcript lost the instruction the answer was shaped by, and it vanished from the conversation on reload. The model also forgot the instruction from the next turn onwards, answering as though the message had never been sent, while the chat UI still showed it.
66

77
If you worked around this by saving steering messages as they arrive, in `pendingMessages.onReceived` for example, that write now duplicates the one you get from `newUIMessages`. Drop it, or skip messages you have already stored.

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3561,6 +3561,19 @@ const chatSteeringQueueKey = locals.create<SteeringQueueEntry[]>("chat.steeringQ
35613561
* `onTurnComplete` never learns it existed.
35623562
*/
35633563
const chatTurnNewUIMessagesKey = locals.create<UIMessage[]>("chat.turnNewUIMessages");
3564+
3565+
/**
3566+
* Set when a steering drain appended to the UI accumulator, so the model
3567+
* accumulator gets rebuilt from it at the end of the turn.
3568+
*
3569+
* The two accumulators are maintained separately, and the model one is
3570+
* normally advanced by appending each turn's delta. A drained message is
3571+
* appended to the UI one but reaches the model only through the `prepareStep`
3572+
* return value, which is per-step: without a rebuild the model lane never
3573+
* learns the message exists and every later turn of the run answers without
3574+
* it, while the browser, the snapshot and `chat.history.*` all still show it.
3575+
*/
3576+
const chatModelLaneStaleKey = locals.create<boolean>("chat.modelLaneStale");
35643577
/** @internal — IDs of messages that were successfully injected via prepareStep */
35653578
const chatInjectedMessageIdsKey = locals.create<Set<string>>("chat.injectedMessageIds");
35663579
/** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
@@ -4286,6 +4299,9 @@ async function drainSteeringQueue(
42864299
turnNew.push(m);
42874300
}
42884301
}
4302+
if (claimedUIMessages.length > 0) {
4303+
locals.set(chatModelLaneStaleKey, true);
4304+
}
42894305

42904306
// Write injection confirmation chunk to the stream so the frontend
42914307
// knows which messages were injected and where in the response.
@@ -8600,6 +8616,23 @@ function chatAgent<
86008616
turnBufferedChunks.length = 0;
86018617
}
86028618

8619+
// Bring the model accumulator back in line with the UI one
8620+
// after a steering drain. Placed after response accumulation
8621+
// and before compaction reads `accumulatedMessages`, and
8622+
// outside the `capturedResponseMessage` branches so a turn
8623+
// that captured no response is covered too.
8624+
if (locals.get(chatModelLaneStaleKey)) {
8625+
locals.set(chatModelLaneStaleKey, false);
8626+
try {
8627+
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8628+
} catch (error) {
8629+
logger.warn(
8630+
"chat.agent: toModelMessages failed rebuilding after an injection; the injected message will be missing from the next turn",
8631+
{ error: error instanceof Error ? error.message : String(error) }
8632+
);
8633+
}
8634+
}
8635+
86038636
if (runSignal.aborted) return "exit";
86048637

86058638
// Await deferred background work (e.g. DB writes from onTurnStart)

packages/trigger-sdk/test/steering-injection.test.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -355,18 +355,14 @@ describe("chat.agent injection claims only its own batch", () => {
355355
/**
356356
* Whether an injected message survives into the next turn's model context.
357357
*
358-
* Recorded here because the deployed QA lane finds the two surfaces disagree:
359-
* a `chat.createSession()` recap in the same run recalls a mid-turn steer,
360-
* while the managed `chat.agent` loop denies it. That difference is
361-
* pre-existing and is the surface-specific half of the accumulator gap.
362-
*
363-
* `it.fails` because the managed path does not carry it: turn 2's prompt comes
364-
* back as the original and the following message only, with the injected one
365-
* absent. Held here so the day that changes is noticed, and so the gap has a
366-
* repro that does not need a deployed environment.
358+
* The UI accumulator and the model accumulator are maintained separately, and
359+
* a drained message used to reach only the first: the browser, the snapshot
360+
* and `chat.history.*` all showed it while every later turn of the run
361+
* answered without it. The model lane is now rebuilt from the UI lane at the
362+
* end of a turn that drained, and this is the repro for it.
367363
*/
368364
describe("chat.agent injected message in the next turn's context", () => {
369-
it.fails(
365+
it(
370366
"carries an injected message into the following turn's prompt",
371367
{ timeout: 30_000 },
372368
async () => {

0 commit comments

Comments
 (0)