Skip to content

Commit 62e020d

Browse files
alari76claude
andauthored
fix: strip literal <think> tags leaking into OpenCode response text (#516)
Kimi k2.7 (ollama-cloud) sometimes wraps chain-of-thought in literal <think>...</think> tags inside a visible text part, so part-kind classification can't hide it. Add a streaming-safe filter that strips the tags and routes their contents to the reasoning buffer, handling tags split across deltas. Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
1 parent dc36fb8 commit 62e020d

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

server/opencode-process.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,49 @@ describe('OpenCodeProcess', () => {
359359
expect(shown).not.toContain('greeting')
360360
})
361361

362+
it('strips literal <think>...</think> tags emitted inside a text part', () => {
363+
// Kimi k2.7 sometimes wraps chain-of-thought in literal <think> tags inside
364+
// a visible text part, so part-kind classification can't hide it. The
365+
// streaming filter must drop the tags and their contents.
366+
const textHandler = vi.fn()
367+
const thinkingHandler = vi.fn()
368+
ocp.on('text', textHandler)
369+
ocp.on('thinking', thinkingHandler)
370+
setSessionId(ocp, 'oc-session-1')
371+
372+
callHandleSSE(ocp, {
373+
type: 'message.part.delta',
374+
properties: {
375+
sessionID: 'oc-session-1',
376+
field: 'text',
377+
delta: '<think>Let me consider the options carefully.</think>The answer is 42.',
378+
},
379+
})
380+
const shown = textHandler.mock.calls.map(c => c[0] as string).join('')
381+
expect(shown).toBe('The answer is 42.')
382+
expect(shown).not.toContain('consider')
383+
expect(thinkingHandler).toHaveBeenCalled()
384+
})
385+
386+
it('strips <think> tags split across multiple text deltas', () => {
387+
const textHandler = vi.fn()
388+
ocp.on('text', textHandler)
389+
setSessionId(ocp, 'oc-session-1')
390+
391+
// Tags and contents arrive fragmented, including the tags themselves split
392+
// mid-token (e.g. "<thi" + "nk>", "</thi" + "nk>").
393+
for (const d of ['Before. <thi', 'nk>hidden rea', 'soning here</thi', 'nk>After.']) {
394+
callHandleSSE(ocp, {
395+
type: 'message.part.delta',
396+
properties: { sessionID: 'oc-session-1', field: 'text', delta: d },
397+
})
398+
}
399+
const shown = textHandler.mock.calls.map(c => c[0] as string).join('')
400+
expect(shown).toBe('Before. After.')
401+
expect(shown).not.toContain('hidden')
402+
expect(shown).not.toContain('think')
403+
})
404+
362405
it('maps running tool parts to tool_active events', () => {
363406
const toolActiveHandler = vi.fn()
364407
ocp.on('tool_active', toolActiveHandler)

server/opencode-process.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)