Skip to content

Commit 3c30ac4

Browse files
alari76claude
andcommitted
fix: separate reasoning from text in OpenCode streaming for Kimi
OpenCode sends Kimi's reasoning content as field=text deltas (not field=reasoning), with part.updated type=reasoning events marking the boundaries. Track a reasoning phase flag so text deltas during reasoning are routed to the reasoning buffer and shown as a thinking indicator instead of being mixed into the response text. Also: don't emit system_init with "opencode (default)" when no model is set — the frontend's model validation effect will set the proper model shortly, avoiding a spurious placeholder model message. Removed the <think> tag stripping approach (reasoning was never sent with tags — it was sent as raw text deltas). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c40ece5 commit 3c30ac4

File tree

2 files changed

+56
-89
lines changed

2 files changed

+56
-89
lines changed

server/opencode-process.ts

Lines changed: 50 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,14 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
275275
private reasoningBuffer = ''
276276
/** Whether we've already emitted a thinking summary from reasoning deltas. */
277277
private emittedReasoningSummary = false
278-
/** Whether we're currently inside a <think> block in streamed text deltas. */
279-
private insideThinkTag = false
280-
/** Buffer for accumulating text inside <think> tags during streaming. */
281-
private thinkTagBuffer = ''
278+
/**
279+
* Whether text deltas currently belong to a reasoning part rather than the
280+
* actual response. Some providers (e.g. Kimi via OpenCode) send reasoning
281+
* content as `field=text` deltas, with `part.updated type=reasoning` events
282+
* marking the boundaries. When true, text deltas are routed to the reasoning
283+
* buffer instead of being emitted as visible text.
284+
*/
285+
private inReasoningPhase = false
282286

283287
constructor(workingDir: string, opts?: Partial<OpenCodeProcessOptions>) {
284288
super()
@@ -349,9 +353,14 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
349353
this.startupTimer = null
350354
}
351355

352-
// Model is stored as "providerID/modelID" — show everything after the first slash
353-
const modelName = this.model?.includes('/') ? this.model.slice(this.model.indexOf('/') + 1) : (this.model || 'opencode (default)')
354-
this.emit('system_init', modelName)
356+
// Model is stored as "providerID/modelID" — show everything after the first slash.
357+
// Only emit system_init when we have an explicit model. If model is undefined,
358+
// the frontend's model validation effect will call setModel shortly, which
359+
// triggers a restart with the correct model — no point showing a placeholder.
360+
if (this.model) {
361+
const modelName = this.model.includes('/') ? this.model.slice(this.model.indexOf('/') + 1) : this.model
362+
this.emit('system_init', modelName)
363+
}
355364
}
356365

357366
/** Subscribe to the OpenCode SSE event stream and map events to CodingProcess events. */
@@ -471,10 +480,9 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
471480
this.deltaBufferFlushed = true
472481
if (this.lastUserInput && this.deltaBuffer.startsWith(this.lastUserInput)) {
473482
const remainder = this.deltaBuffer.slice(this.lastUserInput.length)
474-
if (remainder) { const clean = this.stripThinkTags(remainder); if (clean) this.emit('text', clean) }
483+
if (remainder) this.emit('text', remainder)
475484
} else {
476-
const clean = this.stripThinkTags(this.deltaBuffer)
477-
if (clean) this.emit('text', clean)
485+
this.emit('text', this.deltaBuffer)
478486
}
479487
this.deltaBuffer = ''
480488
}
@@ -495,6 +503,23 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
495503
}
496504
if (field === 'text' && delta) {
497505
this.receivedDeltas = true
506+
507+
// Some providers (e.g. Kimi via OpenCode) send reasoning content as
508+
// field=text deltas. When inReasoningPhase is set (by a preceding
509+
// part.updated type=reasoning event), route to reasoning buffer.
510+
if (this.inReasoningPhase) {
511+
this.reasoningBuffer += delta
512+
if (this.reasoningBuffer.length > 20 && !this.emittedReasoningSummary) {
513+
this.emittedReasoningSummary = true
514+
const match = this.reasoningBuffer.match(/^(.+?[.!?\n])/)
515+
const summary = match && match[1].length <= 120
516+
? match[1].replace(/\n/g, ' ').trim()
517+
: this.reasoningBuffer.slice(0, 80).trim()
518+
this.emit('thinking', summary)
519+
}
520+
break
521+
}
522+
498523
// Buffer initial deltas to detect and strip user echo prefix.
499524
// Some providers echo the user message at the start of the assistant
500525
// response, which causes duplicate display.
@@ -504,19 +529,15 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
504529
this.deltaBufferFlushed = true
505530
if (this.deltaBuffer.startsWith(this.lastUserInput)) {
506531
const remainder = this.deltaBuffer.slice(this.lastUserInput.length)
507-
if (remainder) { const clean = this.stripThinkTags(remainder); if (clean) this.emit('text', clean) }
532+
if (remainder) this.emit('text', remainder)
508533
} else {
509-
const clean = this.stripThinkTags(this.deltaBuffer)
510-
if (clean) this.emit('text', clean)
534+
this.emit('text', this.deltaBuffer)
511535
}
512536
this.deltaBuffer = ''
513537
}
514538
// Still buffering — don't emit yet
515539
} else {
516-
// Strip <think>...</think> tags — some models (e.g. Kimi) embed
517-
// chain-of-thought reasoning in the text output using these tags.
518-
const clean = this.stripThinkTags(delta)
519-
if (clean) this.emit('text', clean)
540+
this.emit('text', delta)
520541
}
521542
} else if (field === 'reasoning' && delta) {
522543
// Accumulate reasoning deltas and emit a thinking summary once we
@@ -548,6 +569,11 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
548569

549570
switch (part.type) {
550571
case 'text': {
572+
// A text part.updated signals that text deltas are now actual
573+
// response text, not reasoning. Clear the reasoning phase flag.
574+
if (this.inReasoningPhase) {
575+
this.inReasoningPhase = false
576+
}
551577
// Text may arrive via message.part.delta (streaming) or as full
552578
// content here (OpenCode >=1.4 message.updated). Only emit if we
553579
// haven't already streamed it via delta events or emitted it from
@@ -559,18 +585,21 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
559585
if (this.lastUserInput && text.startsWith(this.lastUserInput)) {
560586
text = text.slice(this.lastUserInput.length)
561587
}
562-
// Strip <think>...</think> blocks from full text
563-
text = OpenCodeProcess.stripThinkTagsFull(text)
564588
if (text) this.emit('text', text)
565589
}
566590
break
567591
}
568592

569593
case 'reasoning': {
594+
// A reasoning part.updated signals that subsequent text deltas
595+
// are reasoning content, not visible text. Set the phase flag so
596+
// the delta handler routes them to the reasoning buffer.
597+
this.inReasoningPhase = true
570598
// OpenCode uses 'text' field, not 'content'. Reasoning may be
571599
// empty or encrypted (e.g. OpenAI models). Only emit if present.
572600
const content = part.text || ''
573-
if (content.length > 20) {
601+
if (content.length > 20 && !this.emittedReasoningSummary) {
602+
this.emittedReasoningSummary = true
574603
const match = content.match(/^(.+?[.!?\n])/)
575604
const summary = match && match[1].length <= 120
576605
? match[1].replace(/\n/g, ' ').trim()
@@ -760,67 +789,6 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
760789
}
761790
}
762791

763-
/**
764-
* Strip `<think>...</think>` tags from text content. Some models (e.g. Kimi)
765-
* embed chain-of-thought reasoning in the text output using these tags rather
766-
* than using a separate reasoning/thinking field.
767-
* Returns the text with think blocks removed; thinking content is routed to
768-
* the thinking summary emitter.
769-
*/
770-
private stripThinkTags(text: string): string {
771-
// Fast path: no think tags at all
772-
if (!text.includes('<think') && !text.includes('</think') && !this.insideThinkTag) {
773-
return text
774-
}
775-
776-
let result = ''
777-
let i = 0
778-
while (i < text.length) {
779-
if (this.insideThinkTag) {
780-
const closeIdx = text.indexOf('</think>', i)
781-
if (closeIdx === -1) {
782-
// Still inside think block, buffer the rest
783-
this.thinkTagBuffer += text.slice(i)
784-
i = text.length
785-
} else {
786-
this.thinkTagBuffer += text.slice(i, closeIdx)
787-
this.insideThinkTag = false
788-
// Emit thinking summary from accumulated think content
789-
if (this.thinkTagBuffer.length > 20 && !this.emittedReasoningSummary) {
790-
this.emittedReasoningSummary = true
791-
const match = this.thinkTagBuffer.match(/^(.+?[.!?\n])/)
792-
const summary = match && match[1].length <= 120
793-
? match[1].replace(/\n/g, ' ').trim()
794-
: this.thinkTagBuffer.slice(0, 80).trim()
795-
this.emit('thinking', summary)
796-
}
797-
this.thinkTagBuffer = ''
798-
i = closeIdx + '</think>'.length
799-
}
800-
} else {
801-
const openIdx = text.indexOf('<think>', i)
802-
if (openIdx === -1) {
803-
result += text.slice(i)
804-
i = text.length
805-
} else {
806-
result += text.slice(i, openIdx)
807-
this.insideThinkTag = true
808-
i = openIdx + '<think>'.length
809-
}
810-
}
811-
}
812-
return result
813-
}
814-
815-
/**
816-
* Strip `<think>...</think>` blocks from a complete text string (non-streaming).
817-
* Returns the text with all think blocks removed.
818-
*/
819-
private static stripThinkTagsFull(text: string): string {
820-
// Remove complete <think>...</think> blocks (including multiline)
821-
return text.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trimStart()
822-
}
823-
824792
/**
825793
* Detect TodoWrite/TaskCreate/TaskUpdate tool calls and emit todo_update events.
826794
* Mirrors the task-tracking logic in ClaudeProcess.handleTaskTool().
@@ -889,8 +857,7 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
889857
this.deltaBufferFlushed = false
890858
this.reasoningBuffer = ''
891859
this.emittedReasoningSummary = false
892-
this.insideThinkTag = false
893-
this.thinkTagBuffer = ''
860+
this.inReasoningPhase = false
894861

895862
const baseUrl = `http://localhost:${serverState.port}`
896863
// Parse [Attached files: ...] prefix and convert image paths to proper parts.

src/hooks/useChatSocket.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -431,12 +431,12 @@ export function useChatSocket({
431431
}, [send])
432432

433433
const createSession = useCallback((name: string, workingDir: string, useWorktree?: boolean, permissionMode?: PermissionMode, provider?: import('../types').CodingProvider) => {
434-
// Include the current model so the server starts with the right model
435-
// immediately, avoiding a "default model" message followed by a second
436-
// "actual model" message when setModel fires later.
437-
const model = currentModel ?? undefined
438-
send({ type: 'create_session', name, workingDir, model, useWorktree, permissionMode, provider })
439-
}, [send, currentModel])
434+
// Don't pass a model the provider-specific model validation effect
435+
// will call setModel with the correct provider-appropriate model after
436+
// the session is created. Passing a cross-provider model (e.g. a Claude
437+
// model to an OpenCode session) causes a spurious model message.
438+
send({ type: 'create_session', name, workingDir, useWorktree, permissionMode, provider })
439+
}, [send])
440440

441441
const sendInput = useCallback((data: string, displayText?: string) => {
442442
send({ type: 'input', data, ...(displayText ? { displayText } : {}) } as WsClientMessage)

0 commit comments

Comments
 (0)