@@ -458,6 +458,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
458458 private partDeltaBuffers = new Map < string , string [ ] > ( )
459459 /** partIDs with an in-flight classifyPart REST lookup (dedup guard). */
460460 private partClassifyInflight = new Set < string > ( )
461+ /** Most recent assistant messageID seen on a delta — used to classify stragglers at turn end. */
462+ private lastDeltaMessageId = ''
461463
462464 constructor ( workingDir : string , opts ?: Partial < OpenCodeProcessOptions > ) {
463465 super ( )
@@ -786,35 +788,62 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
786788 }
787789
788790 /**
789- * Resolve a part's kind via REST when the SSE stream gives no type signal.
791+ * Resolve part kinds via REST when the SSE stream gives no type signal.
790792 * OpenCode's delta stream uses `field=text` for both reasoning and text
791793 * parts, and some providers (e.g. Kimi/ollama-cloud) never emit
792- * message.part.updated, so the part must be fetched to know whether its
793- * deltas are visible response text or hidden reasoning. Defaults to showing
794- * the content on any failure — better to display than to silently hide.
794+ * message.part.updated, so the message must be fetched to learn whether a
795+ * part's deltas are visible response text or hidden reasoning.
796+ *
797+ * Fetches the whole message (`GET /session/{sid}/message/{mid}`) and records
798+ * the kind for EVERY part it contains, so a single round-trip classifies all
799+ * of a message's parts. If the target part isn't listed yet (a delta can
800+ * briefly outrace the parts snapshot), the in-flight guard is released so the
801+ * next delta retries — its deltas stay buffered (hidden) until resolved.
795802 */
796803 private async classifyPart ( messageID : string , partID : string ) : Promise < void > {
797804 if ( this . partKinds . has ( partID ) || this . partClassifyInflight . has ( partID ) ) return
798805 this . partClassifyInflight . add ( partID )
799- let kind : 'reasoning' | 'text' | 'other' = 'text'
800806 try {
801807 const baseUrl = `http://localhost:${ serverState . port } `
802808 const res = await fetch (
803- `${ baseUrl } /session/${ this . opencodeSessionId } /message/${ messageID } /part/ ${ partID } ` ,
809+ `${ baseUrl } /session/${ this . opencodeSessionId } /message/${ messageID } ` ,
804810 {
805811 headers : { ...authHeaders ( ) , 'x-opencode-directory' : this . workingDir } ,
806812 signal : AbortSignal . timeout ( 10_000 ) ,
807813 } ,
808814 )
809815 if ( res . ok ) {
810- const part = await res . json ( ) as { type ?: string }
811- kind = part . type === 'reasoning' ? 'reasoning' : part . type === 'text' ? 'text' : 'other'
816+ const msg = await res . json ( ) as { parts ?: Array < { id ?: string ; type ?: string } > }
817+ for ( const part of msg . parts ?? [ ] ) {
818+ if ( ! part . id || this . partKinds . has ( part . id ) ) continue
819+ const kind = part . type === 'reasoning' ? 'reasoning' : part . type === 'text' ? 'text' : 'other'
820+ this . recordPartKind ( part . id , kind )
821+ }
812822 }
813823 } catch {
814- // Network/timeout — fall back to showing the content as text .
824+ // Network/timeout — leave unresolved; a later delta retries the lookup .
815825 }
816826 this . partClassifyInflight . delete ( partID )
817- this . recordPartKind ( partID , kind )
827+ }
828+
829+ /**
830+ * At turn end, resolve any parts whose kind never came back during streaming,
831+ * then flush whatever is still buffered. A final classification keeps reasoning
832+ * hidden; anything that still can't be resolved (total REST failure) is shown
833+ * as text rather than silently dropping the answer.
834+ */
835+ private async flushPendingParts ( ) : Promise < void > {
836+ if ( this . partDeltaBuffers . size === 0 ) return
837+ if ( this . lastDeltaMessageId ) {
838+ for ( const pid of [ ...this . partDeltaBuffers . keys ( ) ] ) {
839+ this . partClassifyInflight . delete ( pid )
840+ await this . classifyPart ( this . lastDeltaMessageId , pid )
841+ }
842+ }
843+ for ( const [ pid , buf ] of [ ...this . partDeltaBuffers . entries ( ) ] ) {
844+ this . partDeltaBuffers . delete ( pid )
845+ for ( const d of buf ) this . emitTextDelta ( d )
846+ }
818847 }
819848
820849 /** Flush any buffered text deltas that haven't been emitted yet (e.g. turn ended before buffer threshold). */
@@ -840,6 +869,17 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
840869 this . turnComplete = true
841870 this . turnInFlight = false
842871 this . clearTurnWatchdog ( )
872+ // If any part deltas are still buffered awaiting classification, resolve and
873+ // flush them before finalizing so their text isn't lost or misordered.
874+ if ( this . partDeltaBuffers . size > 0 ) {
875+ void this . flushPendingParts ( ) . finally ( ( ) => { this . finalizeTurn ( ) } )
876+ } else {
877+ this . finalizeTurn ( )
878+ }
879+ }
880+
881+ /** Emit the turn result and dispatch any message queued mid-turn. */
882+ private finalizeTurn ( ) : void {
843883 this . flushDeltaBuffer ( )
844884 this . emit ( 'result' , '' , false )
845885 // Send the next queued message (received mid-turn) after result handlers run.
@@ -905,7 +945,10 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
905945 if ( buf ) buf . push ( delta )
906946 else this . partDeltaBuffers . set ( partID , [ delta ] )
907947 const messageID = properties . messageID as string | undefined
908- if ( messageID ) void this . classifyPart ( messageID , partID )
948+ if ( messageID ) {
949+ this . lastDeltaMessageId = messageID
950+ void this . classifyPart ( messageID , partID )
951+ }
909952 } else if ( field === 'reasoning' && delta ) {
910953 // Some providers send reasoning on a dedicated `field=reasoning`
911954 // stream — always hidden from the transcript.
@@ -1472,6 +1515,7 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
14721515 this . partKinds . clear ( )
14731516 this . partDeltaBuffers . clear ( )
14741517 this . partClassifyInflight . clear ( )
1518+ this . lastDeltaMessageId = ''
14751519 this . startTurnWatchdog ( )
14761520
14771521 const baseUrl = `http://localhost:${ serverState . port } `
0 commit comments