@@ -137,22 +137,34 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment {
137137 }
138138}
139139
140+ type NarrationChannel = 'thinking' | 'assistant'
141+
140142/**
141143 * Appends narration content to a group, merging into the previous text item.
142- * Distinct segments (e.g. a thinking run followed by a text run) can meet
143- * without any whitespace at the seam, gluing sentences together. The merge
144- * repairs only unambiguous sentence boundaries — trailing punctuation meeting
145- * a fresh alphanumeric start — so a segment split mid-word, mid-URL, or in
146- * text that takes no spaces (CJK) is never corrupted.
144+ * When a thinking run and a text run meet, their contents can glue together
145+ * without any whitespace at the seam. The merge repairs only that semantic
146+ * channel transition, and only at an unambiguous sentence boundary — trailing
147+ * punctuation meeting a fresh alphanumeric start. Same-channel continuations
148+ * (streamed chunks of one run, resume legs) are concatenated verbatim, so a
149+ * token split like `v2.` + `1` is never mutated. `lastChannelByGroup` is the
150+ * caller's per-parse tracker of each group's most recent narration channel.
147151 */
148- function appendTextItem ( group : AgentGroupSegment , content : string ) : void {
152+ function appendTextItem (
153+ group : AgentGroupSegment ,
154+ content : string ,
155+ channel : NarrationChannel ,
156+ lastChannelByGroup : Map < AgentGroupSegment , NarrationChannel >
157+ ) : void {
149158 const lastItem = group . items [ group . items . length - 1 ]
150159 if ( lastItem ?. type === 'text' ) {
151- const needsSpace = / [ . ! ? ; : ] $ / . test ( lastItem . content ) && / ^ [ A - Z a - z 0 - 9 ] / . test ( content )
160+ const isChannelSeam = lastChannelByGroup . get ( group ) !== channel
161+ const needsSpace =
162+ isChannelSeam && / [ . ! ? ; : ] $ / . test ( lastItem . content ) && / ^ [ A - Z a - z 0 - 9 ] / . test ( content )
152163 lastItem . content += ( needsSpace ? ' ' : '' ) + content
153164 } else {
154165 group . items . push ( { type : 'text' , content } )
155166 }
167+ lastChannelByGroup . set ( group , channel )
156168}
157169
158170/**
@@ -166,6 +178,7 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
166178function parseBlocksWithSpanTree ( blocks : ContentBlock [ ] ) : MessageSegment [ ] {
167179 const segments : MessageSegment [ ] = [ ]
168180 const groupsBySpanId = new Map < string , AgentGroupSegment > ( )
181+ const lastNarrationChannel = new Map < AgentGroupSegment , NarrationChannel > ( )
169182 // Stable per-run counters for React keys. The Nth top-level text run / Nth
170183 // mothership group keeps the same key across re-parses (text runs and groups
171184 // are append-only at the top level), so React never remounts the streaming
@@ -272,7 +285,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
272285 }
273286 if ( ! g ) continue
274287 g . isDelegating = false
275- appendTextItem ( g , block . content )
288+ appendTextItem (
289+ g ,
290+ block . content ,
291+ block . type === 'subagent_thinking' ? 'thinking' : 'assistant' ,
292+ lastNarrationChannel
293+ )
276294 continue
277295 }
278296
@@ -288,7 +306,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
288306 if ( ! g ) g = ensureSpanGroup ( block . subagent , block . spanId , block . parentSpanId )
289307 if ( g ) {
290308 g . isDelegating = false
291- appendTextItem ( g , block . content )
309+ appendTextItem ( g , block . content , 'assistant' , lastNarrationChannel )
292310 continue
293311 }
294312 }
@@ -417,6 +435,7 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
417435function parseBlocksLegacy ( blocks : ContentBlock [ ] ) : MessageSegment [ ] {
418436 const segments : MessageSegment [ ] = [ ]
419437 const groupsByKey = new Map < string , AgentGroupSegment > ( )
438+ const lastNarrationChannel = new Map < AgentGroupSegment , NarrationChannel > ( )
420439 let activeGroupKey : string | null = null
421440
422441 const groupKey = ( name : string , parentToolCallId : string | undefined ) =>
@@ -488,12 +507,12 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
488507 const g = findGroupForSubagentChunk ( block . parentToolCallId )
489508 if ( ! g ) continue
490509 g . isDelegating = false
491- const lastItem = g . items [ g . items . length - 1 ]
492- if ( lastItem ?. type === 'text' ) {
493- lastItem . content += block . content
494- } else {
495- g . items . push ( { type : 'text' , content : block . content } )
496- }
510+ appendTextItem (
511+ g ,
512+ block . content ,
513+ block . type === 'subagent_thinking' ? 'thinking' : 'assistant' ,
514+ lastNarrationChannel
515+ )
497516 continue
498517 }
499518
@@ -511,12 +530,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
511530 const g = groupsByKey . get ( resolveGroupKey ( block . subagent , block . parentToolCallId ) )
512531 if ( g ) {
513532 g . isDelegating = false
514- const lastItem = g . items [ g . items . length - 1 ]
515- if ( lastItem ?. type === 'text' ) {
516- lastItem . content += block . content
517- } else {
518- g . items . push ( { type : 'text' , content : block . content } )
519- }
533+ appendTextItem ( g , block . content , 'assistant' , lastNarrationChannel )
520534 continue
521535 }
522536 }
0 commit comments