Skip to content

fix(riff-backend): 修复 web 终端 stdout 日志糊成墙(SSE 逐行日志丢尾换行) - #805

Merged
deepcoldy merged 1 commit into
masterfrom
fix/riff-backend-stdout-log-line-separator
Aug 10, 2026
Merged

fix(riff-backend): 修复 web 终端 stdout 日志糊成墙(SSE 逐行日志丢尾换行)#805
deepcoldy merged 1 commit into
masterfrom
fix/riff-backend-stdout-log-line-separator

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

codex_app_server 路径下,飞书 web 终端的日志正文"糊成墙"——多条 stdout 日志首尾相接、零分隔,不可读(申晗截图复现,截图里能看到 thread.started/turn.started,确认走的是 main 上的 app_server 新路径,不是老 exec)。

根因:SSE 逐行日志丢尾换行

riff 端逐行、无尾换行地存/发 stdout 日志:

  1. appServer.ts emitNormalized:logger.info(JSON.stringify(event), {group:'stdout'}) 存的是裸行(带 \n 的那份喂给另一个 sink,不进日志流)。codex_app_server 下这条 message 就是一条 JSON.stringify(event)(thread.started / turn.started / item.* …)。
  2. runner logger 存 message 时只做 mask/truncate,不加换行
  3. SSE event: logtext = log.message 原样

到本仓 relay 这端,emitText 只把已有 \r?\n 归一化(\n\r\n),从不给每条日志补分隔。于是 case 'log' 的 stdout 分支 emitText(text) 把逐条裸行首尾相接 → 糊成墙。

修法

src/adapters/backend/riff-backend.tscase 'log',group === 'stdout' 分支:

- this.emitText(text);
+ this.emitText(`${text}\n`);

因 riff 逐行、无尾换行存,补 \n 精确不重复

两个刻意的边界

  • 没用 emitLine:emitLine 会包 ANSI 色 + 前后 \r\n 空行(状态行语义),会把每条 stdout 撑成双倍行距。stdout 原始流只需要"补一个分隔",所以走 emitText(text+'\n')
  • output/chunk 路径故意不动:event: output 的 chunk 是字节流、可能半行语义,给它补 \n 会把一行劈两段。只有 log 事件是逐行语义,补换行才安全。

影响面

只影响 web 终端实时日志流的渲染(把"糊成墙"降为"每行一个规整 JSON 事件"可读)。不改任何数据、不动终报路径、不动 chunk 流。

注:补 \n 后仍是裸 JSON-per-line,不是人类可读时间线——那是可选的后续打磨(relay 里 parse thread/turn/item 渲成时间线),不在本 PR 范围。

测试

新增 3 个回归测试(test/riff-backend.test.ts):

  • 两条连续 stdout log 分行不糊(断言不含 }{,各自 …\r\n)
  • 单条不双空行(hellohello\r\n)
  • chunk 路径保持原样(par+tialpartial,无注入换行)

riff-backend.test.ts 50/50 绿,tsc --noEmit 对改动文件干净。

riff ships each stdout log line bare (no trailing newline): the runner
logger persists message verbatim and the SSE `log` event carries it as
`text` unchanged. For the codex_app_server adapter that message is one
JSON.stringify(event) per line (thread.started/turn.started/item.*). Since
emitText only normalizes existing newlines and never adds a separator,
consecutive events concatenated into one unreadable wall in the web
terminal.

Emit `text + '\n'` on the stdout-log path only. Non-duplicating because
the stored line has no trailing newline. The output/chunk path is left
raw — those chunks may be partial lines and must not get a synthetic
newline.

Adds 3 regression tests (separation / no double-space / chunk path raw).

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review 结论:未发现阻塞问题,代码层面 LGTM。当前账号与 PR 作者相同,GitHub 不允许 self-approve,因此以 review comment 记录结论。

核验结论:

  • codex_app_server 上游确实按逐行、无尾换行落库:emitNormalized 先生成 line = JSON.stringify(event)logger.info(line, { group: 'stdout' }) 持久化裸行;带 \n${line}\n 只送 onOutput,不进入日志表。
  • Logger.createRootLog 仅 mask/truncate,不补行尾;runnerLogToTaskLogEventtext 直接设为 log.messageRunnerTaskStreamPoller 再原样序列化进 SSE。因此到 botmux 的 event:log stdout text 没有行尾,补一个 \n 不会重复。
  • 普通 exec 路径同样由 readlineline 事件去掉行尾后,以裸 line + { group } 落库,和上述契约一致。
  • 使用 emitText 而非 emitLine 是正确的:前者只做 \n -> \r\n 归一化;后者会增加 ANSI 及前后 CRLF,确实会造成额外空行。
  • event:output 是 chunk 语义,保持原样是必要边界;新增 chunk 拼接测试覆盖到了这一点。

独立验证:

  • pnpm exec vitest run --project unit test/riff-backend.test.ts:50/50 通过。
  • pnpm exec tsc --noEmit:通过。
  • git diff --check:通过。

当前 GitHub build 红灯来自 codex-app-runner.integration 超时和 worker-argv-reaction-status.integration 时序断言;base master 的 7cd22db CI 已以相同两组用例失败,且本 PR 单跑 codex-app-runner.integration 42/42 通过,判断与本 diff 无关。worker-argv-reaction-status.integration 在本地 base/PR 环境均可复现同样失败。

@deepcoldy
deepcoldy merged commit 33b9b03 into master Aug 10, 2026
5 of 6 checks passed
@deepcoldy
deepcoldy deleted the fix/riff-backend-stdout-log-line-separator branch August 10, 2026 07:50
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.12.0

deepcoldy added a commit that referenced this pull request Aug 10, 2026
…inal (#812)

* feat(riff-backend): render route-B display projection in the web terminal

riff's feat/riff-agent-log-display attaches a per-line `display: TaskLogDisplay`
to stdout log SSE events — a stateless, human-readable projection of a codex
app-server event (回答 / 思路 / 命令 …). Consume it:

- display present → render a timeline row via emitDisplay(): [回答]/[思路]/
  [命令] <cmd> (exit N)/[工具]/[错误]/… with kind-driven color (failed command
  or error → red, completion → green, reasoning/usage dimmed). The Chinese label
  comes from riff's localized `title` when present, else a kind→label fallback.
- display absent → keep the #805 raw fallback (emitText(text + '\n')).
- defensive backstop: if a bare codex lifecycle event (thread.started/
  item.started/…) slips through un-projected (riff normally downgrades these to
  channel:'raw' so a default subscription never sees them), suppress it rather
  than re-wall. Narrow: only single-line JSON with a known no-content `type` —
  never plain shell output that merely contains braces.

The output/chunk (event:output) path stays untouched.

Tests: +5 (display→row, command+exit+color, absent→fallback, noise suppressed,
braces-not-suppressed); updated the char-wall separation test to use content-
bearing lines since lifecycle markers are now suppressed. riff-backend 55/55,
tsc clean.

* fix(riff-backend): address codex review on route-B display rendering

Codex review of #812 found 2 user-visible blockers + 4 alignment items, all
confirmed against riff's committed contract (feat/riff-agent-log-display):

blockers:
- command projection has NO `text` (only {command,status,exitCode,summary});
  the old code fell back to `summary` and printed '命令执行完成' as a fake output
  line under every command. Command branch now renders ONLY its header; the real
  captured stdout rides a SEPARATE stdout event on the verbatim passthrough path.
- reusing emitLine double-spaced the timeline (leading+trailing CRLF per row) and
  left internal \n un-normalized (xterm stair-step). Added emitTimelineRow: no
  leading CRLF, exactly one trailing CRLF, all internal newlines → CRLF.

alignment:
- noise set now matches riff's CODEX_NOISE_EVENT_TYPES exactly (+response.completed
  /response.done).
- added stage/trace to the label table (were falling to English default).
- reasoning/usage now use a real ANSI dim (\x1b[2m) instead of no-op 'plain'.
- running command (no exit code) is neutral, not green — green only for
  completed/exit 0, red for failure.

Tests: 61/61 (+6: completed/failed/running command color, dim reasoning, no
double-space, multi-line CRLF normalization, response.* noise). tsc clean.

* fix(riff-backend): render captured command output below the command header

Resolves the cross-end blocker from codex re-review of #812: riff surfaces only
events[last] (the command header) as the single display, so the command's stdout
was being dropped entirely — the timeline showed '[命令执行] echo hello (exit 0)'
with no output.

riff !1177 (commit a2433315) chose option A: collapseCommandOutputIntoPrimary
folds the (32KB-truncated) command stdout/stderr into commandDisplay.text. This
consumer now renders display.text below the header, verbatim + CRLF-normalized.
Not in conflict with the earlier blocker-1 fix: the summary field ('命令执行完成')
is still never rendered (we read text, not summary); text is now the real output.

Tests 63/63 (+2: command output folded into text is rendered; multi-line output
CRLF-normalized). tsc clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant