diff --git a/server/opencode-process.test.ts b/server/opencode-process.test.ts index f0e1e4cb..664e47fe 100644 --- a/server/opencode-process.test.ts +++ b/server/opencode-process.test.ts @@ -359,6 +359,49 @@ describe('OpenCodeProcess', () => { expect(shown).not.toContain('greeting') }) + it('strips literal ... tags emitted inside a text part', () => { + // Kimi k2.7 sometimes wraps chain-of-thought in literal tags inside + // a visible text part, so part-kind classification can't hide it. The + // streaming filter must drop the tags and their contents. + const textHandler = vi.fn() + const thinkingHandler = vi.fn() + ocp.on('text', textHandler) + ocp.on('thinking', thinkingHandler) + setSessionId(ocp, 'oc-session-1') + + callHandleSSE(ocp, { + type: 'message.part.delta', + properties: { + sessionID: 'oc-session-1', + field: 'text', + delta: 'Let me consider the options carefully.The answer is 42.', + }, + }) + const shown = textHandler.mock.calls.map(c => c[0] as string).join('') + expect(shown).toBe('The answer is 42.') + expect(shown).not.toContain('consider') + expect(thinkingHandler).toHaveBeenCalled() + }) + + it('strips tags split across multiple text deltas', () => { + const textHandler = vi.fn() + ocp.on('text', textHandler) + setSessionId(ocp, 'oc-session-1') + + // Tags and contents arrive fragmented, including the tags themselves split + // mid-token (e.g. "", ""). + for (const d of ['Before. hidden rea', 'soning hereAfter.']) { + callHandleSSE(ocp, { + type: 'message.part.delta', + properties: { sessionID: 'oc-session-1', field: 'text', delta: d }, + }) + } + const shown = textHandler.mock.calls.map(c => c[0] as string).join('') + expect(shown).toBe('Before. After.') + expect(shown).not.toContain('hidden') + expect(shown).not.toContain('think') + }) + it('maps running tool parts to tool_active events', () => { const toolActiveHandler = vi.fn() ocp.on('tool_active', toolActiveHandler) diff --git a/server/opencode-process.ts b/server/opencode-process.ts index 0195bfd8..e05049d0 100644 --- a/server/opencode-process.ts +++ b/server/opencode-process.ts @@ -460,6 +460,16 @@ export class OpenCodeProcess extends EventEmitter implement private partClassifyInflight = new Set() /** Most recent assistant messageID seen on a delta — used to classify stragglers at turn end. */ private lastDeltaMessageId = '' + /** + * Some models (e.g. Kimi k2.7 via ollama-cloud) emit chain-of-thought wrapped + * in literal `...` tags INSIDE a visible text part, so part-kind + * classification can't hide it. These two fields drive a streaming-safe filter + * that strips think tags even when they're split across deltas: + * `thinkActive` is true while inside an open ``; `thinkCarry` holds a + * trailing partial tag (e.g. `) { super() @@ -734,6 +744,86 @@ export class OpenCodeProcess extends EventEmitter implement * detected and stripped before display. */ private emitTextDelta(delta: string): void { + const visible = this.stripThinkTags(delta) + if (visible) this.emitVisibleText(visible) + } + + /** + * Strip literal `...` chain-of-thought from a text delta, + * streaming-safely. Content inside the tags is routed to the reasoning buffer + * (surfaced only as a thinking summary, never as transcript text). Handles tags + * split across deltas by holding a trailing partial tag in `this.thinkCarry` + * until the next delta arrives. Returns the visible (non-think) text. + */ + private stripThinkTags(input: string): string { + let s = this.thinkCarry + input + this.thinkCarry = '' + let visible = '' + while (s.length > 0) { + if (this.thinkActive) { + const close = s.indexOf('') + if (close === -1) { + const tail = this.partialTagTail(s, '') + if (tail > 0) { + this.appendReasoningDelta(s.slice(0, s.length - tail)) + this.thinkCarry = s.slice(s.length - tail) + } else { + this.appendReasoningDelta(s) + } + s = '' + } else { + this.appendReasoningDelta(s.slice(0, close)) + this.thinkActive = false + s = s.slice(close + ''.length) + } + } else { + const open = s.indexOf('') + if (open === -1) { + const tail = this.partialTagTail(s, '') + if (tail > 0) { + visible += s.slice(0, s.length - tail) + this.thinkCarry = s.slice(s.length - tail) + } else { + visible += s + } + s = '' + } else { + visible += s.slice(0, open) + this.thinkActive = true + s = s.slice(open + ''.length) + } + } + } + return visible + } + + /** + * Length of the longest non-empty suffix of `s` that is a proper prefix of + * `tag` — i.e. how much trailing text might be the start of a split tag and + * must be held back until the next delta. Returns 0 if none. + */ + private partialTagTail(s: string, tag: string): number { + const max = Math.min(s.length, tag.length - 1) + for (let len = max; len > 0; len--) { + if (tag.startsWith(s.slice(s.length - len))) return len + } + return 0 + } + + /** + * Flush any text held in `thinkCarry` at turn end. A leftover carry while + * outside a think block was a partial `` that never completed, so it's + * real visible text. Carry while inside a think block is unterminated reasoning + * and stays hidden. + */ + private flushThinkCarry(): void { + if (this.thinkCarry && !this.thinkActive) { + this.emitVisibleText(this.thinkCarry) + } + this.thinkCarry = '' + } + + private emitVisibleText(delta: string): void { if (!this.deltaBufferFlushed && this.lastUserInput) { this.deltaBuffer += delta if (this.deltaBuffer.length >= this.lastUserInput.length) { @@ -880,6 +970,7 @@ export class OpenCodeProcess extends EventEmitter implement /** Emit the turn result and dispatch any message queued mid-turn. */ private finalizeTurn(): void { + this.flushThinkCarry() this.flushDeltaBuffer() this.emit('result', '', false) // Send the next queued message (received mid-turn) after result handlers run. @@ -1516,6 +1607,8 @@ export class OpenCodeProcess extends EventEmitter implement this.partDeltaBuffers.clear() this.partClassifyInflight.clear() this.lastDeltaMessageId = '' + this.thinkActive = false + this.thinkCarry = '' this.startTurnWatchdog() const baseUrl = `http://localhost:${serverState.port}`