Skip to content

「思考流式显示丢失」:长对话/切换模型后 reasoning 流式显示缺失——provider 流式能力无保证 + /event 无重放游标(ordinal 契约已核实对齐) #52377

Open

Description

@PumpkinTTL

问题概述

GUI 宿主(Electron,SSE 事件 + message.list 快照双通道渲染)在两类条件下丢失思考过程(reasoning)的流式显示:① 对话变长后;② 切换模型后。模型实际在思考(最终输出正常、延迟与思考量级一致),但界面思考行提前熄灭 / 思考块不出现 / 增量不吐字。经逐层排查,引擎侧有两个促成因素与一个已核实无接缝的关键契约,报告如下(GUI 侧协同缺陷已由宿主自行修复,见文末)。

复现步骤

  1. 思考型模型开启会话,正常提问:思考块流式逐字出现(基线正常)。
  2. 触发条件②:把会话切换到另一供应商/模型(尤其是不流式输出 thinking 增量的 OpenAI-compatible 端点),继续提问——思考阶段界面完全沉默,直到思考结束整块一次性出现。
  3. 触发条件①:在同一会话持续积累历史(多步 agentic 轮次、交错思考),宿主按 delta 驱动的快照刷新(全量分页)在长历史下一跳可达秒级甚至超时;此期间思考块边界处(interleaved thinking 的下一块 started)宿主端已显示的思考文本被清除且无法恢复。
  4. 任意 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 判块边界的客户端会出现陈旧事件误判。

建议修法汇总

  1. (能力位)model schema 增加 reasoning streaming 能力标注;或对不流式供应商合成 thinking 进度事件。
  2. (重放)GET /event 支持 ?since= durable 事件重放;EventFeed 溢出改「反压/断开最旧」策略而非静默杀订阅者。
  3. (可选)考虑把 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 的快照刷新降频。

Activity

  1. github-actions commented on Sep 30, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. PumpkinTTL commented on Sep 30, 2026

    @PumpkinTTL
    Author

    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:

    • Long conversations (multi-step sessions with large context)
    • Switching models mid-session, especially to providers/models that do not stream thinking deltas

    Consistency with the root-cause analysis in this issue:

    • For providers that don't emit thinking deltas, the durable reasoning part stays empty until ended — so any client (CLI included) shows nothing for the entire thinking phase and the text pops in at once
    • The SSE /event stream has no replay cursor and subscribers are killed on overflow, so a reconnect during an active thinking block loses that block permanently

    Until upstream fixes the streaming capability signal / durable replay, clients can only mitigate on their side. Happy to provide additional traces if useful.

  3. rekram1-node commented on Oct 8, 2026

    @rekram1-node
    Contributor

    @opencode-agent translate

  4. opencode-agent commented on Oct 8, 2026

    @opencode-agent
    Contributor

    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 /event has 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.list snapshots. In two situations it loses the streamed display of the reasoning:

    1. after the conversation gets long
    2. after switching models

    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

    1. Start a session with a reasoning model and ask a question. The thinking block streams in character by character (normal baseline).
    2. Trigger ②: switch the session to another provider or model, especially an OpenAI-compatible endpoint that doesn't stream thinking deltas, and keep asking. The UI shows nothing during reasoning, then the whole block appears at once when reasoning ends.
    3. Trigger ①: build up history in the same session (multi-step agentic turns, interleaved thinking). The host refreshes snapshots on each delta, paging through the full history. With long history, one refresh can take seconds or time out. During that window, at a thinking-block boundary (the next block's started in interleaved thinking), the thinking text the host already shows is cleared and can't be recovered.
    4. After any SSE disconnect and reconnect (network jitter, or EventFeed overflow 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 ②)

    • Event chain: provider stream → 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.
    • No events from some providers: the engine forwards exactly what the provider sends. Providers that don't stream reasoning deltas (some OpenAI-compatible endpoints and some model configs) produce no events at all during reasoning.
    • Empty durable part: the reasoning part in the durable message stays empty until reasoning.ended. message-updater.ts:379-402 projects only started/ended, and session.reasoning.delta is ephemeral (packages/schema/src/session-event.ts:446-454).
    • Result: after switching to such a model, the host gets no displayable signal for the whole reasoning phase, and the block pops in at the end.
    • Suggestion: at least add a "reasoning streams" capability flag to the model schema. Alternatively, for non-streaming providers, publish a synthetic step-level "thinking" progress signal so hosts can tell "thinking" apart from "unresponsive".

    B. SSE /event has no replay cursor, and overflow kills the subscriber (amplifies both triggers)

    • packages/server/src/handlers/event.ts:12-36: event.subscribe takes no since/cursor parameter. On connect it sends server.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's SubscribeOptions has only signal/onActivity (dist/shared-events.d.ts). There is no way to request replay.
    • Consequence: every event published during a disconnect or overflow reconnect is lost. That includes durable reasoning.started/ended and 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 after ended.
    • Suggestion: support ?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)

    • Counter lifecycle:
      • fragments() in publish-llm-event.ts starts each instance at nextOrdinal = 0 (lines 121–141).
      • A new publisher is created for each attempt (runner/step.ts:74-82).
      • Each step gets a new assistantMessageID (runner/llm.ts:207/224/288/292).
      • So the ordinal means: per assistant message, strictly increasing from 0, counted separately for text and reasoning.
    • No reset within a message: the paths that swap publishers on the same message (Retry / RecoverFull / Compacted continuation) are all gated by !recorded.outputStarted (step.ts:147-189). text-start and reasoning-start set outputStarted (publish-llm-event.ts:406/422). So no observable sequence exists where the ordinal resets to zero within the same message.
    • Host matches: the host checks for a change of owner message before comparing ordinals, so both sides agree.
    • Caution: if assistant messages are ever reused across steps, or the publisher lifecycle changes, keep these semantics. Otherwise clients that rely on ordinals to find block boundaries will misread stale events.

    Suggested fixes

    1. (Capability flag) Add a reasoning-streaming capability to the model schema, or synthesize thinking progress events for non-streaming providers.
    2. (Replay) Support durable event replay on GET /event via ?since=. Change EventFeed overflow to backpressure or dropping the oldest events, instead of silently killing the subscriber.
    3. (Optional) Periodically project the reasoning text after started, rather than only with reasoning.ended (today the part stays empty until ended), so the snapshot channel has a displayable intermediate state while reasoning is in progress.

    Environment

    • Engine: lyra-server-src (an opencode v2 fork, main branch at b5ff8085a2)
    • Host: Electron GUI. It consumes events through @opencode/client (promise API) plus SSE, and loads message snapshots through message.list cursor pagination.
    • Platform: Windows 10 (win32 10.0.22631 x64)

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions