Repository navigation
Open
Description
问题概述
GUI 宿主(Electron,SSE 事件 + message.list 快照双通道渲染)在两类条件下丢失思考过程(reasoning)的流式显示:① 对话变长后;② 切换模型后。模型实际在思考(最终输出正常、延迟与思考量级一致),但界面思考行提前熄灭 / 思考块不出现 / 增量不吐字。经逐层排查,引擎侧有两个促成因素与一个已核实无接缝的关键契约,报告如下(GUI 侧协同缺陷已由宿主自行修复,见文末)。
复现步骤
- 思考型模型开启会话,正常提问:思考块流式逐字出现(基线正常)。
- 触发条件②:把会话切换到另一供应商/模型(尤其是不流式输出 thinking 增量的 OpenAI-compatible 端点),继续提问——思考阶段界面完全沉默,直到思考结束整块一次性出现。
- 触发条件①:在同一会话持续积累历史(多步 agentic 轮次、交错思考),宿主按 delta 驱动的快照刷新(全量分页)在长历史下一跳可达秒级甚至超时;此期间思考块边界处(interleaved thinking 的下一块 started)宿主端已显示的思考文本被清除且无法恢复。
- 任意 SSE 断连重连(网络抖动 / EventFeed 溢出杀订阅者)后正在进行的思考块同样永久失聪:重连后没有任何补发。
根因分析
A. thinking 增量是否流式完全由 provider 决定,引擎无统一保证(对应触发②)
- 事件链:provider 流 →
packages/ai/src/protocols/utils/lifecycle.ts(reasoningStart/reasoningDelta/reasoningEnd,第 36-73 行)→packages/core/src/session/runner/publish-llm-event.ts(case "reasoning-start/delta/end",第 421-437 行)→session.reasoning.*事件。 - 引擎忠实转发 provider 给什么发什么:不流式输出思考增量的供应商(部分 OpenAI-compatible 端点、部分模型配置)在整个思考期不产生任何事件,durable 消息里的 reasoning 部件在
reasoning.ended前恒为空(packages/core/src/session/message-updater.ts:379-402只投影 started/ended;session.reasoning.delta是 ephemeral,packages/schema/src/session-event.ts:446-454)。 - 结果:换到这类模型后,宿主在整段思考期收不到任何可显示信号,思考块在结束时整体弹出。建议:至少在模型目录(model schema)中标注「reasoning 是否流式」能力位,或对不流式的供应商在思考期发布合成的 step 级「thinking」进度信号,让宿主能区分「思考中」与「无响应」。
B. SSE /event 无重放游标 + 溢出即杀订阅者(两类触发的放大器)
packages/server/src/handlers/event.ts:12-36:event.subscribe不接受since/游标参数,连接建立只发server.connected+ 从当前时刻起的实时事件。packages/server/src/event-feed.ts:64-68:订阅者队列(dropping,容量 4096)满时删除并 fail 该订阅者(SubscriberOverflowError),客户端被迫重连。@opencode/client的SubscribeOptions只有signal/onActivity(dist/shared-events.d.ts),无重放请求能力。- 后果:任何一次断连/溢出重连期间发布的全部事件丢失——durable 的
reasoning.started/ended、tool/step 事件也一样(它们只是持久化到 DB 供投影,不向 SSE 客户端补发)。正在流式的思考块自此永久失聪,只能等快照里 ended 后的全文部件。建议:durable 事件支持?since=<event id>重放(事件已持久化,天然可回放),ephemeral 片段(delta)保持 best-effort;这能把「重连后恢复思考显示」从宿主的残尾猜测变成引擎契约。
C. ordinal 契约核查结论:引擎与宿主渲染端已对齐,无接缝 bug(排查记录,供后续改动参考)
- 计数器生命周期:
publish-llm-event.ts的fragments()每实例nextOrdinal = 0(第 121-141 行),publisher 每 attempt 新建(runner/step.ts:74-82);每个 step 一个新 assistantMessageID(runner/llm.ts:207/224/288/292)。⇒ ordinal 语义 = 「per assistant 消息,从 0 严格递增,text/reasoning 各计各的」。 - 同消息换 publisher 的路径(Retry / RecoverFull / Compacted 续跑)全部被
!recorded.outputStarted闸住(step.ts:147-189;text-start/reasoning-start 置outputStarted,publish-llm-event.ts:406/422)⇒ 不存在「同消息 ordinal 归零重计」可观测序列。 - 宿主端按「owner 消息切换优先于 ordinal 比较」处理,双方一致。若未来引入跨 step 复用 assistant 消息或改 publisher 生命周期,请保持这一语义,否则依赖 ordinal 判块边界的客户端会出现陈旧事件误判。
建议修法汇总
- (能力位)model schema 增加 reasoning streaming 能力标注;或对不流式供应商合成 thinking 进度事件。
- (重放)
GET /event支持?since=durable 事件重放;EventFeed 溢出改「反压/断开最旧」策略而非静默杀订阅者。 - (可选)考虑把
reasoning.ended的全文也随 started 后定期投影(现在 ended 前部件恒空),让快照通道在思考进行中也有可显示的中间态。
环境信息
- 引擎:lyra-server-src(opencode v2 fork,main 分支 b5ff8085a2)
- 宿主:Electron GUI,经 @opencode/client(promise API)+ SSE 消费事件;消息快照走 message.list 游标分页
- 平台:Windows 10(win32 10.0.22631 x64)
宿主侧协同修复(仅供参考):GUI 的残尾状态机原先在思考块边界处无条件清空未收割残尾,快照滞后时表现为「思考文本消失」;已改为收割安全清除(仅当快照证明覆盖后才移交),并对思考 delta 的快照刷新降频。
Metadata
Metadata
Assignees
Labels
No labels
Activity
github-actions commentedon Sep 30, 2026
This issue might be a duplicate of existing issues. Please check:
/eventreplay./global/eventignoresLast-Event-ID(related reconnect behavior on a different endpoint)./eventreplay cursor and replay/backpressure semantics.PumpkinTTL commentedon Sep 30, 2026
Confirmed: also reproduces on the official opencode CLI (independent of any third-party UI)
We can confirm this defect is upstream, not a client-side rendering issue — the same thinking-loss behavior occurs when using the official opencode CLI directly.
Reproduction conditions observed:
Consistency with the root-cause analysis in this issue:
ended— so any client (CLI included) shows nothing for the entire thinking phase and the text pops in at once/eventstream has no replay cursor and subscribers are killed on overflow, so a reconnect during an active thinking block loses that block permanentlyUntil upstream fixes the streaming capability signal / durable replay, clients can only mitigate on their side. Happy to provide additional traces if useful.
rekram1-node commentedon Oct 8, 2026
@opencode-agent translate
opencode-agent commentedon Oct 8, 2026
English translation
Title: "Reasoning streaming display lost": the reasoning stream disappears in long conversations or after switching models. Two causes: providers don't reliably stream reasoning, and
/eventhas no replay cursor. (The ordinal contract was checked and both sides agree.)Overview
A GUI host (Electron) renders from two channels: SSE events and
message.listsnapshots. In two situations it loses the streamed display of the reasoning:The model is reasoning the whole time. The final output is correct, and the latency matches the amount of reasoning. But the UI's thinking line goes out early, the thinking block never appears, or the deltas stop arriving. After checking each layer, the reporter found two contributing factors in the engine and one key contract that has no gap. The GUI-side bug was fixed by the host, as described at the end.
Reproduction
startedin interleaved thinking), the thinking text the host already shows is cleared and can't be recovered.EventFeedoverflow killing the subscriber), the thinking block in progress stays blank for good. Nothing is resent after reconnect.Root cause analysis
A. Whether reasoning deltas stream depends entirely on the provider; the engine gives no uniform guarantee (trigger ②)
packages/ai/src/protocols/utils/lifecycle.ts(reasoningStart/reasoningDelta/reasoningEnd, lines 36–73) →packages/core/src/session/runner/publish-llm-event.ts(case "reasoning-start/delta/end", lines 421–437) →session.reasoning.*events.reasoning.ended.message-updater.ts:379-402projects only started/ended, andsession.reasoning.deltais ephemeral (packages/schema/src/session-event.ts:446-454).B. SSE
/eventhas no replay cursor, and overflow kills the subscriber (amplifies both triggers)packages/server/src/handlers/event.ts:12-36:event.subscribetakes nosince/cursor parameter. On connect it sendsserver.connected, then only live events from that point on.packages/server/src/event-feed.ts:64-68: when a subscriber's queue (dropping, capacity 4096) is full, the subscriber is removed and failed (SubscriberOverflowError), which forces the client to reconnect.@opencode/client'sSubscribeOptionshas onlysignal/onActivity(dist/shared-events.d.ts). There is no way to request replay.reasoning.started/endedand tool/step events, which are persisted to the DB for projection but never resent to SSE clients. The thinking block that was streaming stays blank from then on. The full text only shows up in the snapshot afterended.?since=<event id>replay for durable events. They are already persisted, so they can naturally be replayed. Keep ephemeral fragments (deltas) best-effort. This turns "restore the thinking display after reconnect" from host-side guesswork about leftover text into an engine contract.C. Ordinal contract check: engine and host renderer agree, no gap bug (investigation notes for future changes)
fragments()inpublish-llm-event.tsstarts each instance atnextOrdinal = 0(lines 121–141).runner/step.ts:74-82).runner/llm.ts:207/224/288/292).!recorded.outputStarted(step.ts:147-189). text-start and reasoning-start setoutputStarted(publish-llm-event.ts:406/422). So no observable sequence exists where the ordinal resets to zero within the same message.Suggested fixes
GET /eventvia?since=. ChangeEventFeedoverflow to backpressure or dropping the oldest events, instead of silently killing the subscriber.started, rather than only withreasoning.ended(today the part stays empty untilended), so the snapshot channel has a displayable intermediate state while reasoning is in progress.Environment
@opencode/client(promise API) plus SSE, and loads message snapshots throughmessage.listcursor pagination.Host-side fix (for reference only): the GUI's leftover-text state machine used to clear any unharvested leftover text at thinking-block boundaries, unconditionally. When the snapshot lagged, the thinking text disappeared. It now clears only after harvesting, handing off only once the snapshot proves it covers the text. It also refreshes snapshots less often on reasoning deltas.
The reporter's follow-up comment and the bot's comment were already in English. This is a translation only; I didn't verify the code claims, and the report comes from a fork (lyra-server-src), not upstream.