Skip to content

Commit 82c0824

Browse files
authored
Merge pull request #385 from Multiplier-Labs/fix/opencode-model-and-thinking
fix: resolve duplicate model messages and mixed thinking text for OpenCode
2 parents 7fd1c8c + 0540611 commit 82c0824

File tree

2 files changed

+85
-7
lines changed

2 files changed

+85
-7
lines changed

server/opencode-process.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ 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 = ''
278282

279283
constructor(workingDir: string, opts?: Partial<OpenCodeProcessOptions>) {
280284
super()
@@ -467,9 +471,10 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
467471
this.deltaBufferFlushed = true
468472
if (this.lastUserInput && this.deltaBuffer.startsWith(this.lastUserInput)) {
469473
const remainder = this.deltaBuffer.slice(this.lastUserInput.length)
470-
if (remainder) this.emit('text', remainder)
474+
if (remainder) { const clean = this.stripThinkTags(remainder); if (clean) this.emit('text', clean) }
471475
} else {
472-
this.emit('text', this.deltaBuffer)
476+
const clean = this.stripThinkTags(this.deltaBuffer)
477+
if (clean) this.emit('text', clean)
473478
}
474479
this.deltaBuffer = ''
475480
}
@@ -496,15 +501,19 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
496501
this.deltaBufferFlushed = true
497502
if (this.deltaBuffer.startsWith(this.lastUserInput)) {
498503
const remainder = this.deltaBuffer.slice(this.lastUserInput.length)
499-
if (remainder) this.emit('text', remainder)
504+
if (remainder) { const clean = this.stripThinkTags(remainder); if (clean) this.emit('text', clean) }
500505
} else {
501-
this.emit('text', this.deltaBuffer)
506+
const clean = this.stripThinkTags(this.deltaBuffer)
507+
if (clean) this.emit('text', clean)
502508
}
503509
this.deltaBuffer = ''
504510
}
505511
// Still buffering — don't emit yet
506512
} else {
507-
this.emit('text', delta)
513+
// Strip <think>...</think> tags — some models (e.g. Kimi) embed
514+
// chain-of-thought reasoning in the text output using these tags.
515+
const clean = this.stripThinkTags(delta)
516+
if (clean) this.emit('text', clean)
508517
}
509518
} else if (field === 'reasoning' && delta) {
510519
// Accumulate reasoning deltas and emit a thinking summary once we
@@ -543,6 +552,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
543552
if (this.lastUserInput && text.startsWith(this.lastUserInput)) {
544553
text = text.slice(this.lastUserInput.length)
545554
}
555+
// Strip <think>...</think> blocks from full text
556+
text = OpenCodeProcess.stripThinkTagsFull(text)
546557
if (text) this.emit('text', text)
547558
}
548559
break
@@ -736,6 +747,67 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
736747
}
737748
}
738749

750+
/**
751+
* Strip `<think>...</think>` tags from text content. Some models (e.g. Kimi)
752+
* embed chain-of-thought reasoning in the text output using these tags rather
753+
* than using a separate reasoning/thinking field.
754+
* Returns the text with think blocks removed; thinking content is routed to
755+
* the thinking summary emitter.
756+
*/
757+
private stripThinkTags(text: string): string {
758+
// Fast path: no think tags at all
759+
if (!text.includes('<think') && !text.includes('</think') && !this.insideThinkTag) {
760+
return text
761+
}
762+
763+
let result = ''
764+
let i = 0
765+
while (i < text.length) {
766+
if (this.insideThinkTag) {
767+
const closeIdx = text.indexOf('</think>', i)
768+
if (closeIdx === -1) {
769+
// Still inside think block, buffer the rest
770+
this.thinkTagBuffer += text.slice(i)
771+
i = text.length
772+
} else {
773+
this.thinkTagBuffer += text.slice(i, closeIdx)
774+
this.insideThinkTag = false
775+
// Emit thinking summary from accumulated think content
776+
if (this.thinkTagBuffer.length > 20 && !this.emittedReasoningSummary) {
777+
this.emittedReasoningSummary = true
778+
const match = this.thinkTagBuffer.match(/^(.+?[.!?\n])/)
779+
const summary = match && match[1].length <= 120
780+
? match[1].replace(/\n/g, ' ').trim()
781+
: this.thinkTagBuffer.slice(0, 80).trim()
782+
this.emit('thinking', summary)
783+
}
784+
this.thinkTagBuffer = ''
785+
i = closeIdx + '</think>'.length
786+
}
787+
} else {
788+
const openIdx = text.indexOf('<think>', i)
789+
if (openIdx === -1) {
790+
result += text.slice(i)
791+
i = text.length
792+
} else {
793+
result += text.slice(i, openIdx)
794+
this.insideThinkTag = true
795+
i = openIdx + '<think>'.length
796+
}
797+
}
798+
}
799+
return result
800+
}
801+
802+
/**
803+
* Strip `<think>...</think>` blocks from a complete text string (non-streaming).
804+
* Returns the text with all think blocks removed.
805+
*/
806+
private static stripThinkTagsFull(text: string): string {
807+
// Remove complete <think>...</think> blocks (including multiline)
808+
return text.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trimStart()
809+
}
810+
739811
/**
740812
* Detect TodoWrite/TaskCreate/TaskUpdate tool calls and emit todo_update events.
741813
* Mirrors the task-tracking logic in ClaudeProcess.handleTaskTool().
@@ -804,6 +876,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
804876
this.deltaBufferFlushed = false
805877
this.reasoningBuffer = ''
806878
this.emittedReasoningSummary = false
879+
this.insideThinkTag = false
880+
this.thinkTagBuffer = ''
807881

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

src/hooks/useChatSocket.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,12 @@ export function useChatSocket({
433433
}, [send])
434434

435435
const createSession = useCallback((name: string, workingDir: string, useWorktree?: boolean, permissionMode?: PermissionMode, provider?: import('../types').CodingProvider) => {
436-
send({ type: 'create_session', name, workingDir, useWorktree, permissionMode, provider })
437-
}, [send])
436+
// Include the current model so the server starts with the right model
437+
// immediately, avoiding a "default model" message followed by a second
438+
// "actual model" message when setModel fires later.
439+
const model = currentModel ?? undefined
440+
send({ type: 'create_session', name, workingDir, model, useWorktree, permissionMode, provider })
441+
}, [send, currentModel])
438442

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

0 commit comments

Comments
 (0)