Skip to content

Commit 6683e4b

Browse files
committed
fix(chat): keep a steer in the lanes on the createSession surface
drainSteeringQueue reported what it claimed by pushing into a locals array that only chat.agent populates, so on chat.createSession and chat.MessageAccumulator the push was a silent no-op behind its truthiness guard. A mid-turn steer shaped that turn's answer and then existed nowhere: not in the session's uiMessages, not in its modelMessages, and not deferred to its own turn either. The drain now returns what it claimed alongside what to inject, and each surface files it. Adds absorbSteering to the accumulator, used by both of its drain sites.
1 parent 83fe5ef commit 6683e4b

3 files changed

Lines changed: 391 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Steering messages are now kept in the conversation when you drive turns yourself with `chat.createSession()` or `chat.MessageAccumulator`. Previously a message that arrived mid-answer shaped that answer and then existed nowhere: it was missing from `turn.uiMessages`, so an app persisting from there never stored it, missing from `turn.messages`, so every later turn answered as though it had never been sent, and it was not queued as its own turn either. It now lands in both, the same way it does on `chat.agent`.

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

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4158,21 +4158,36 @@ function chatCompactionStep(
41584158
// Steering queue drain — shared by toStreamTextOptions, session, accumulator
41594159
// ---------------------------------------------------------------------------
41604160

4161+
/** What a steering drain produced: what to send now, and what it consumed. */
4162+
type DrainedSteering = {
4163+
/** Model messages to add to this step's prompt. */
4164+
injected: ModelMessage[];
4165+
/** The UI messages the drain consumed, for the caller to record. */
4166+
claimed: UIMessage[];
4167+
};
4168+
4169+
const EMPTY_DRAIN: DrainedSteering = { injected: [], claimed: [] };
4170+
41614171
/**
41624172
* Drain the steering queue as a batch. Calls `shouldInject` once with all
41634173
* pending messages. If it returns true, calls `prepareMessages` once to
41644174
* transform the batch, then clears the queue.
4165-
* Returns the model messages to inject (empty if none).
4175+
* Returns the model messages to inject and the UI messages actually claimed.
4176+
*
4177+
* `claimed` is returned rather than only published to locals because each
4178+
* surface files it somewhere different: `chat.agent` has an accumulator in
4179+
* locals, while `chat.createSession` keeps its own. Publishing to locals alone
4180+
* is silently a no-op for any surface that never set the key.
41664181
* @internal
41674182
*/
41684183
async function drainSteeringQueue(
41694184
config: PendingMessagesOptions,
41704185
messages: ModelMessage[],
41714186
steps: CompactionStep[],
41724187
queueOverride?: SteeringQueueEntry[]
4173-
): Promise<ModelMessage[]> {
4188+
): Promise<DrainedSteering> {
41744189
const queue = queueOverride ?? locals.get(chatSteeringQueueKey);
4175-
if (!queue || queue.length === 0) return [];
4190+
if (!queue || queue.length === 0) return EMPTY_DRAIN;
41764191

41774192
const ctx = locals.get(chatTurnContextKey);
41784193
const stepNumber = steps.length - 1;
@@ -4198,7 +4213,7 @@ async function drainSteeringQueue(
41984213
// Call shouldInject once for the whole batch
41994214
const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
42004215

4201-
if (!shouldInject) return [];
4216+
if (!shouldInject) return EMPTY_DRAIN;
42024217

42034218
const textOfUIMessage = (m: UIMessage) =>
42044219
(m.parts ?? [])
@@ -4248,7 +4263,7 @@ async function drainSteeringQueue(
42484263
if (at !== -1) queue.splice(at, 1);
42494264
}
42504265

4251-
if (claimed.length === 0) return [];
4266+
if (claimed.length === 0) return EMPTY_DRAIN;
42524267

42534268
/**
42544269
* Give the claim back if the transform fails. `prepare` is caller code and
@@ -4299,7 +4314,7 @@ async function drainSteeringQueue(
42994314
turnNew.push(m);
43004315
}
43014316
}
4302-
if (claimedUIMessages.length > 0) {
4317+
if (claimedUIMessages.length > 0 && currentUIMessages) {
43034318
locals.set(chatModelLaneStaleKey, true);
43044319
}
43054320

@@ -4344,7 +4359,7 @@ async function drainSteeringQueue(
43444359
}
43454360
}
43464361

4347-
return injected;
4362+
return { injected, claimed: claimedUIMessages };
43484363
},
43494364
{
43504365
attributes: {
@@ -4883,7 +4898,7 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
48834898

48844899
// 2. Pending message injection (steering)
48854900
if (taskPendingMessages) {
4886-
const injected = await drainSteeringQueue(
4901+
const { injected } = await drainSteeringQueue(
48874902
taskPendingMessages,
48884903
resultMessages ?? messages,
48894904
steps
@@ -10648,6 +10663,28 @@ class ChatMessageAccumulator {
1064810663
this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
1064910664
}
1065010665

10666+
/**
10667+
* Record the messages a steering drain consumed.
10668+
*
10669+
* The drain only puts them in this step's prompt, so without this they
10670+
* shape one answer and then exist in neither lane: not in `uiMessages`,
10671+
* which is what an app persists from, and not in `modelMessages`, which is
10672+
* what every later turn sends. The UI lane is authoritative and the model
10673+
* lane is its conversion, matching how `chat.agent` reconciles the two.
10674+
*/
10675+
async absorbSteering(claimed: UIMessage[]): Promise<void> {
10676+
if (claimed.length === 0) return;
10677+
let added = false;
10678+
for (const m of claimed) {
10679+
if (!this.uiMessages.some((existing) => existing.id === m.id)) {
10680+
this.uiMessages.push(m);
10681+
added = true;
10682+
}
10683+
}
10684+
if (!added) return;
10685+
this.modelMessages = await toModelMessages(this.uiMessages);
10686+
}
10687+
1065110688
/**
1065210689
* Get and clear unconsumed steering messages.
1065310690
*/
@@ -10688,7 +10725,13 @@ class ChatMessageAccumulator {
1068810725

1068910726
// 2. Pending message injection
1069010727
if (pm && queue.length > 0) {
10691-
const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
10728+
const { injected, claimed } = await drainSteeringQueue(
10729+
pm,
10730+
resultMessages ?? messages,
10731+
steps,
10732+
queue
10733+
);
10734+
await this.absorbSteering(claimed);
1069210735
if (injected.length > 0) {
1069310736
resultMessages = [...(resultMessages ?? messages), ...injected];
1069410737
}
@@ -11458,12 +11501,13 @@ function createChatSession<TClientData = unknown>(
1145811501
}
1145911502

1146011503
if (sessionPendingMessages) {
11461-
const injected = await drainSteeringQueue(
11504+
const { injected, claimed } = await drainSteeringQueue(
1146211505
sessionPendingMessages,
1146311506
resultMessages ?? stepMsgs,
1146411507
steps,
1146511508
turnSteeringQueue
1146611509
);
11510+
await accumulator.absorbSteering(claimed);
1146711511
if (injected.length > 0) {
1146811512
resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
1146911513
}

0 commit comments

Comments
 (0)