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}`