@@ -35,7 +35,21 @@ export function createAgentSteerQueue({
3535 queue . value = queue . value . filter ( ( item : QueuedMessage ) => item . id !== id ) ;
3636 }
3737
38- function injectSteerMessage ( text : string ) {
38+ // Break the assistant's live stream at the steer point: freeze whatever streamed so far
39+ // (e.g. the current reasoning block), drop the steer message in, and let the rest stream
40+ // into a fresh assistant message after it — so the conversation reads
41+ // "reasoning block → steer → new reasoning block" instead of the steer landing next to
42+ // the user's original prompt.
43+ //
44+ // This reaches into the AI SDK's in-flight streaming state (`chat.activeResponse`):
45+ // - reasoning/text parts are looked up by id reference in `active*Parts`, so pointing
46+ // those entries at fresh (empty) parts redirects subsequent deltas past the steer;
47+ // - the stream writer pushes a *new* message whenever `state.message.id` no longer
48+ // matches the last displayed message, so swapping in a new `message` splits the bubble
49+ // without duplicating the frozen block.
50+ // If that internal shape isn't present (not streaming, or a future SDK change), we fall
51+ // back to inserting the steer just above the streaming assistant.
52+ async function injectSteerMessage ( text : string ) {
3953 const chat = currentChat . value ;
4054 if ( ! chat ) {
4155 return ;
@@ -46,6 +60,11 @@ export function createAgentSteerQueue({
4660 parts : [ { type : 'text' , text, state : 'done' } ] ,
4761 } ;
4862 const messages = chat . messages ;
63+
64+ if ( breakStreamWithSteer ( chat , messages , steerMessage ) ) {
65+ return ;
66+ }
67+
4968 const lastMessage = messages [ messages . length - 1 ] ;
5069 if ( lastMessage ?. role === 'assistant' ) {
5170 messages . splice ( messages . length - 1 , 0 , steerMessage ) ;
@@ -54,6 +73,58 @@ export function createAgentSteerQueue({
5473 }
5574 }
5675
76+ function breakStreamWithSteer ( chat : Chat < any > , messages : any [ ] , steerMessage : IMessage ) : boolean {
77+ try {
78+ const streamingState = ( chat as any ) . activeResponse ?. state ;
79+ const streamingMessage = streamingState ?. message ;
80+ const lastIndex = messages . length - 1 ;
81+ const lastMessage = messages [ lastIndex ] ;
82+ const isStreamingIntoLastAssistant =
83+ streamingMessage ?. role === 'assistant'
84+ && Array . isArray ( streamingMessage . parts )
85+ && streamingMessage . parts . length > 0
86+ && lastMessage ?. role === 'assistant' ;
87+
88+ if ( ! isStreamingIntoLastAssistant ) {
89+ return false ;
90+ }
91+
92+ // 1) Freeze block 1 as a fresh, decoupled assistant message. Copying the parts (and
93+ // reassigning the array slot) detaches it from the SDK's live parts and forces a
94+ // re-render so the block stops showing as "streaming".
95+ const frozenParts = streamingMessage . parts . map ( ( part : any ) => ( {
96+ ...part ,
97+ state : part ?. state === 'streaming' ? 'done' : part ?. state ,
98+ } ) ) ;
99+ messages [ lastIndex ] = { ...streamingMessage , parts : frozenParts } ;
100+ console . log ( 'Frozen parts:' , frozenParts ) ;
101+ // 2) Redirect the SDK's in-flight reasoning/text parts into a fresh continuation
102+ // message so subsequent deltas (resolved by id reference) land after the steer.
103+ const continuation : any = { id : crypto . randomUUID ( ) , role : 'assistant' , parts : [ ] } ;
104+ const redirectActiveParts = ( activeParts : Record < string , any > | undefined ) => {
105+ if ( ! activeParts ) {
106+ return ;
107+ }
108+ for ( const id of Object . keys ( activeParts ) ) {
109+ const continued = { ...activeParts [ id ] , text : '' , state : 'streaming' } ;
110+ activeParts [ id ] = continued ;
111+ continuation . parts . push ( continued ) ;
112+ }
113+ } ;
114+ redirectActiveParts ( streamingState . activeReasoningParts ) ;
115+ redirectActiveParts ( streamingState . activeTextParts ) ;
116+
117+ // 3) Hand the SDK the continuation as its streaming message; its next write() pushes
118+ // it as a new assistant bubble. Slot the steer between the frozen block and it.
119+ streamingState . message = continuation ;
120+ messages . push ( steerMessage ) ;
121+ return true ;
122+ } catch ( error ) {
123+ console . error ( 'Steer stream-break failed, inserting steer inline instead' , error ) ;
124+ return false ;
125+ }
126+ }
127+
57128 // Persist the steer by appending it onto the running turn's prompt (display only — the
58129 // LLM already has it via the running turn + checkpointer, so this never re-enters the
59130 // model context). The backend adds the marker; the reload path splits it back out.
0 commit comments