@@ -460,6 +460,16 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
460460 private partClassifyInflight = new Set < string > ( )
461461 /** Most recent assistant messageID seen on a delta — used to classify stragglers at turn end. */
462462 private lastDeltaMessageId = ''
463+ /**
464+ * Some models (e.g. Kimi k2.7 via ollama-cloud) emit chain-of-thought wrapped
465+ * in literal `<think>...</think>` tags INSIDE a visible text part, so part-kind
466+ * classification can't hide it. These two fields drive a streaming-safe filter
467+ * that strips think tags even when they're split across deltas:
468+ * `thinkActive` is true while inside an open `<think>`; `thinkCarry` holds a
469+ * trailing partial tag (e.g. `<thi`) until the next delta completes or refutes it.
470+ */
471+ private thinkActive = false
472+ private thinkCarry = ''
463473
464474 constructor ( workingDir : string , opts ?: Partial < OpenCodeProcessOptions > ) {
465475 super ( )
@@ -734,6 +744,86 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
734744 * detected and stripped before display.
735745 */
736746 private emitTextDelta ( delta : string ) : void {
747+ const visible = this . stripThinkTags ( delta )
748+ if ( visible ) this . emitVisibleText ( visible )
749+ }
750+
751+ /**
752+ * Strip literal `<think>...</think>` chain-of-thought from a text delta,
753+ * streaming-safely. Content inside the tags is routed to the reasoning buffer
754+ * (surfaced only as a thinking summary, never as transcript text). Handles tags
755+ * split across deltas by holding a trailing partial tag in `this.thinkCarry`
756+ * until the next delta arrives. Returns the visible (non-think) text.
757+ */
758+ private stripThinkTags ( input : string ) : string {
759+ let s = this . thinkCarry + input
760+ this . thinkCarry = ''
761+ let visible = ''
762+ while ( s . length > 0 ) {
763+ if ( this . thinkActive ) {
764+ const close = s . indexOf ( '</think>' )
765+ if ( close === - 1 ) {
766+ const tail = this . partialTagTail ( s , '</think>' )
767+ if ( tail > 0 ) {
768+ this . appendReasoningDelta ( s . slice ( 0 , s . length - tail ) )
769+ this . thinkCarry = s . slice ( s . length - tail )
770+ } else {
771+ this . appendReasoningDelta ( s )
772+ }
773+ s = ''
774+ } else {
775+ this . appendReasoningDelta ( s . slice ( 0 , close ) )
776+ this . thinkActive = false
777+ s = s . slice ( close + '</think>' . length )
778+ }
779+ } else {
780+ const open = s . indexOf ( '<think>' )
781+ if ( open === - 1 ) {
782+ const tail = this . partialTagTail ( s , '<think>' )
783+ if ( tail > 0 ) {
784+ visible += s . slice ( 0 , s . length - tail )
785+ this . thinkCarry = s . slice ( s . length - tail )
786+ } else {
787+ visible += s
788+ }
789+ s = ''
790+ } else {
791+ visible += s . slice ( 0 , open )
792+ this . thinkActive = true
793+ s = s . slice ( open + '<think>' . length )
794+ }
795+ }
796+ }
797+ return visible
798+ }
799+
800+ /**
801+ * Length of the longest non-empty suffix of `s` that is a proper prefix of
802+ * `tag` — i.e. how much trailing text might be the start of a split tag and
803+ * must be held back until the next delta. Returns 0 if none.
804+ */
805+ private partialTagTail ( s : string , tag : string ) : number {
806+ const max = Math . min ( s . length , tag . length - 1 )
807+ for ( let len = max ; len > 0 ; len -- ) {
808+ if ( tag . startsWith ( s . slice ( s . length - len ) ) ) return len
809+ }
810+ return 0
811+ }
812+
813+ /**
814+ * Flush any text held in `thinkCarry` at turn end. A leftover carry while
815+ * outside a think block was a partial `<think>` that never completed, so it's
816+ * real visible text. Carry while inside a think block is unterminated reasoning
817+ * and stays hidden.
818+ */
819+ private flushThinkCarry ( ) : void {
820+ if ( this . thinkCarry && ! this . thinkActive ) {
821+ this . emitVisibleText ( this . thinkCarry )
822+ }
823+ this . thinkCarry = ''
824+ }
825+
826+ private emitVisibleText ( delta : string ) : void {
737827 if ( ! this . deltaBufferFlushed && this . lastUserInput ) {
738828 this . deltaBuffer += delta
739829 if ( this . deltaBuffer . length >= this . lastUserInput . length ) {
@@ -880,6 +970,7 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
880970
881971 /** Emit the turn result and dispatch any message queued mid-turn. */
882972 private finalizeTurn ( ) : void {
973+ this . flushThinkCarry ( )
883974 this . flushDeltaBuffer ( )
884975 this . emit ( 'result' , '' , false )
885976 // Send the next queued message (received mid-turn) after result handlers run.
@@ -1516,6 +1607,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
15161607 this . partDeltaBuffers . clear ( )
15171608 this . partClassifyInflight . clear ( )
15181609 this . lastDeltaMessageId = ''
1610+ this . thinkActive = false
1611+ this . thinkCarry = ''
15191612 this . startTurnWatchdog ( )
15201613
15211614 const baseUrl = `http://localhost:${ serverState . port } `
0 commit comments