Skip to content

Commit 0144c1a

Browse files
author
金双
committed
fix: merge per-token reasoning chunks into single part for GLM models
Some models (e.g., zhipu/glm) emit a separate reasoning-start/delta/end cycle for every single token, causing each token to render as an independent 'Thinking:' line in the TUI. Fix by tracking the last reasoning part and reusing it when consecutive reasoning-start events arrive, instead of creating a new part each time. The merged part is finalized at text-start or finish-step. Also normalize newlines in ReasoningPart render as defense-in-depth.
1 parent d0a4088 commit 0144c1a

3 files changed

Lines changed: 42 additions & 5 deletions

File tree

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
323323
const part = draft[result.index]
324324
const field = event.properties.field as keyof typeof part
325325
const existing = part[field] as string | undefined
326-
;(part[field] as string) = (existing ?? "") + event.properties.delta
326+
const delta = event.properties.delta
327+
;(part[field] as string) = (existing ?? "") + delta
327328
}),
328329
)
329330
break

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1436,7 +1436,17 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
14361436
const content = createMemo(() => {
14371437
// Filter out redacted reasoning chunks from OpenRouter
14381438
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
1439-
return props.part.text.replace("[REDACTED]", "").trim()
1439+
let text = props.part.text.replace("[REDACTED]", "").trim()
1440+
// Normalize newlines for models that stream each token with its own newline
1441+
// (e.g., zhipu/glm). Preserve paragraph breaks (\n\n) but collapse single
1442+
// newlines that are just token boundaries into spaces.
1443+
text = text
1444+
.replace(/\r\n/g, "\n")
1445+
.replace(/\n{2,}/g, "\x00PARA\x00") // Protect paragraph breaks
1446+
.replace(/\n/g, " ") // Single newlines → space
1447+
.replace(/\x00PARA\x00/g, "\n\n") // Restore paragraph breaks
1448+
.replace(/ {2,}/g, " ") // Collapse multiple spaces
1449+
return text
14401450
})
14411451
return (
14421452
<Show when={content() && ctx.showThinking()}>

packages/opencode/src/session/processor.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,35 @@ export namespace SessionProcessor {
211211
})
212212

213213
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
214+
// Finalize a merged reasoning part (for models that emit per-token reasoning chunks)
215+
function* finalizeReasoning() {
216+
const entry = (ctx as any)._lastReasoningEntry
217+
if (!entry) return
218+
entry.text = entry.text.trimEnd()
219+
entry.time = { ...entry.time, end: Date.now() }
220+
yield* session.updatePart(entry)
221+
;(ctx as any)._lastReasoningPartId = undefined
222+
;(ctx as any)._lastReasoningEntry = undefined
223+
}
224+
214225
switch (value.type) {
215226
case "start":
216227
yield* status.set(ctx.sessionID, { type: "busy" })
217228
return
218229

219230
case "reasoning-start":
220231
if (value.id in ctx.reasoningMap) return
232+
// Some models (e.g., zhipu/glm) emit a separate reasoning-start/delta/end
233+
// cycle for every single token. Merge consecutive reasoning chunks into
234+
// one part to avoid per-token line rendering in the TUI.
235+
if ((ctx as any)._lastReasoningPartId) {
236+
// Reuse the previous reasoning part — map this new id to the same part
237+
const prev = (ctx as any)._lastReasoningEntry
238+
if (prev) {
239+
ctx.reasoningMap[value.id] = prev
240+
return
241+
}
242+
}
221243
ctx.reasoningMap[value.id] = {
222244
id: PartID.ascending(),
223245
messageID: ctx.assistantMessage.id,
@@ -227,6 +249,8 @@ export namespace SessionProcessor {
227249
time: { start: Date.now() },
228250
metadata: value.providerMetadata,
229251
}
252+
;(ctx as any)._lastReasoningPartId = ctx.reasoningMap[value.id].id
253+
;(ctx as any)._lastReasoningEntry = ctx.reasoningMap[value.id]
230254
yield* session.updatePart(ctx.reasoningMap[value.id])
231255
return
232256

@@ -245,10 +269,10 @@ export namespace SessionProcessor {
245269

246270
case "reasoning-end":
247271
if (!(value.id in ctx.reasoningMap)) return
248-
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
249-
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
272+
// Don't trimEnd or finalize yet — more reasoning chunks may follow.
273+
// Just clean up the map entry for this id but keep the part reference
274+
// alive via _lastReasoningEntry for potential reuse.
250275
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
251-
yield* session.updatePart(ctx.reasoningMap[value.id])
252276
delete ctx.reasoningMap[value.id]
253277
return
254278

@@ -351,6 +375,7 @@ export namespace SessionProcessor {
351375
return
352376

353377
case "finish-step": {
378+
yield* finalizeReasoning()
354379
const usage = Session.getUsage({
355380
model: ctx.model,
356381
usage: value.usage,
@@ -398,6 +423,7 @@ export namespace SessionProcessor {
398423
}
399424

400425
case "text-start":
426+
yield* finalizeReasoning()
401427
ctx.currentText = {
402428
id: PartID.ascending(),
403429
messageID: ctx.assistantMessage.id,

0 commit comments

Comments
 (0)