@@ -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 */
41684183async 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