Skip to content

feat(web-shell): expose assistant turn settlement lifecycle - #11251

Open
yiliang114 wants to merge 32 commits into
mainfrom
codex/issue-10389-turn-settlement
Open

feat(web-shell): expose assistant turn settlement lifecycle#11251
yiliang114 wants to merge 32 commits into
mainfrom
codex/issue-10389-turn-settlement

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds an optional host callback for daemon-authoritative assistant turn settlement. It reports the session and prompt identity, completed/cancelled/failed outcome, stop reason, error details, and the final retained assistant message when available. Delivery occurs after the terminal transcript update is committed, suppresses duplicate terminals per mounted session provider, supports Split View panes, and leaves the existing session-change callback unchanged.

Why it's needed

Embedding hosts currently infer completion from rendered loading state and scan the transcript for a response. That loses authoritative prompt identity and is ambiguous across cancellation, failure, reconnect catch-up, session switching, and history replay.

Reviewer Test Plan

How to verify

  • Mount Web Shell with the new callback and submit prompts that complete, are cancelled, and fail. Confirm each live terminal reports the expected session id, prompt id, outcome, stop reason or error, and any retained final assistant message.
  • Deliver the same terminal event twice to one mounted provider and confirm the callback fires once.
  • Load persisted history containing terminal events and confirm it remains silent; disconnect an active live stream before its terminal and confirm reconnect catch-up publishes that terminal.
  • Open Split View and confirm each pane forwards settlement for its own live session.
  • Confirm the existing session-change turn-complete notification remains unchanged.

Evidence (Before & After)

N/A — host integration API with no visual change.

Tested on

OS Status
🍏 macOS ⚠️ not run
🪟 Windows ⚠️ not run
🐧 Linux ⚠️ not run

Environment (optional)

No local validation result is claimed. Earlier commands stopped during unrelated missing-dependency/module resolution; the change was then handled only by static diff review.

Risk & Scope

  • Main risk or tradeoff: deduplication is intentionally scoped to each mounted session provider; hosts mounting the same session more than once should use (sessionId, promptId) as the durable idempotency key.
  • Not validated / out of scope: artifact and workspace projection settlement; cross-provider durable deduplication.
  • Breaking changes / migration notes: none; the callback is optional and existing callbacks are unchanged.

Linked Issues

Closes #10389

中文说明

本 PR 做了什么

新增一个可选的宿主回调,用 daemon 权威终态报告 assistant turn 已结束。事件包含 session 与 prompt 标识、completed/cancelled/failed 结果、stop reason、错误信息,以及存在时最终保留的 assistant 消息。回调在终态 transcript 更新提交后触发;同一个已挂载 session provider 内会抑制重复终态;Split View 各 pane 也会转发;现有 session-change 回调保持不变。

为什么需要

目前嵌入宿主只能根据渲染层 loading 状态推断完成,再扫描 transcript 查找回复。这种方式没有权威 prompt 标识,并且在取消、失败、断线重连补齐、session 切换和历史回放场景下存在歧义。

Reviewer Test Plan

如何验证

  • 使用新回调挂载 Web Shell,分别提交正常完成、取消和失败的 prompt。确认每个 live 终态都报告正确的 session id、prompt id、结果、stop reason 或错误,以及存在时最终保留的 assistant 消息。
  • 向同一个已挂载 provider 重复投递同一终态事件,确认回调只触发一次。
  • 加载包含终态事件的持久化历史,确认不会触发回调;在 active live stream 收到终态前断开,确认重连补齐会发布该终态。
  • 打开 Split View,确认每个 pane 会转发其自身 live session 的 settlement。
  • 确认现有 session-change 的 turn-complete 通知行为保持不变。

证据(Before & After)

N/A —— 这是宿主集成 API,没有视觉变化。

已测试平台

OS 状态
🍏 macOS ⚠️ 未运行
🪟 Windows ⚠️ 未运行
🐧 Linux ⚠️ 未运行

环境(可选)

本次不声明任何本地验证结果。此前命令在无关的依赖缺失 / 模块解析阶段即停止;之后仅通过静态 diff review 处理本改动。

风险与范围

  • 主要风险或取舍:去重有意限定在每个已挂载 session provider 内;如果宿主多次挂载同一个 session,应使用 (sessionId, promptId) 作为持久幂等键。
  • 未验证 / 范围外:artifact 与 workspace projection 的 settlement;跨 provider 的持久去重。
  • 破坏性变更 / 迁移说明:无;新回调是可选项,现有回调保持不变。

关联 Issue

Closes #10389

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR — re-run of the gate at the current head.

Template looks good ✓ — every required section is present, including the bilingual block and the Risk & Scope tradeoffs.

Problem: real and tracked, not theoretical hardening. This is a feat backed by the still-open feature request #10389 (type/feature-request, priority/P2, scope/web-shell). The gap is concrete: hosts today infer completion from rendered loading state and then scan the transcript, which cannot distinguish completed / cancelled / failed and carries no daemon prompt id.

Direction: aligned. Web Shell is an embeddable surface that already exposes a family of host callbacks (onSessionChange, onSessionArtifactsChange, onSlashCommand, onBrandResolved), and this is the same pattern applied to a gap the existing turn_complete session-change event genuinely does not fill — authoritative prompt identity and outcome. Claude Code's CHANGELOG has no direct counterpart, but host-facing turn lifecycle is a live area there (Stop hooks scoped to an agent's lifecycle), so the direction is not out of step.

Size: Stage 0 does not apply. Every changed file is under packages/web-shell/ plus one design doc — no packages/core/src/**, no packages/*/src/{auth,providers,models,config,tools,services}/**, and only a single package is touched, so this is not a core-module change and not cross-package. Breakdown for reference: 449 production lines (438 added / 11 deleted), 1056 test lines, 55 doc lines. Under both the 500-line escalation and the 1000-line large-PR advisory.

Approach: scope feels right, and a 1056:449 test-to-production ratio is unusually thorough for this repo. One design question, which turns out to be the same thing as the blocking finding in Stage 2: delivery for the journal-repair case is routed through the replay admission gate instead of retaining the terminal the provider already observed live. Holding the observed settlement in a ref and publishing it once the repair finishes — whether it succeeds or fails — looks materially simpler than the current path and would not need an admission key at all. Worth a look before the code review settles it.

Nothing here reads as scope creep: the diff is one feature, its tests, its design doc, and the README row for it. No drive-by refactors.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献!这是针对当前 head 的 gate 重跑。

模板完整 ✓ —— 所有必需章节都在,包括中文说明与 Risk & Scope 取舍。

问题: 真实且已有跟踪,不是理论性加固。这是一个 feat,背后是仍然 open 的功能请求 #10389type/feature-requestpriority/P2scope/web-shell)。缺口很具体:宿主目前只能靠渲染层 loading 状态推断完成,再扫描 transcript,既无法区分 completed / cancelled / failed,也拿不到 daemon 的 prompt id。

方向: 对齐。Web Shell 本身就是可嵌入组件,已经有一组宿主回调(onSessionChangeonSessionArtifactsChangeonSlashCommandonBrandResolved),本 PR 是把同一套模式用在现有 turn_complete session-change 事件确实填不上的缺口上——权威的 prompt 标识与终态。Claude Code 的 CHANGELOG 没有直接对应项,但宿主侧 turn 生命周期在那边也是活跃方向(按 agent 生命周期作用域的 Stop hooks),所以方向并不偏离。

规模: Stage 0 不适用。所有改动文件都在 packages/web-shell/ 下,外加一个设计文档——没有触及 packages/core/src/**,也没有 packages/*/src/{auth,providers,models,config,tools,services}/**,且只涉及单个 package,因此既不是核心模块改动也不是跨包改动。明细供参考:生产代码 449 行(新增 438 / 删除 11)、测试 1056 行文档 55 行。低于 500 行升级阈值与 1000 行大 PR 提醒阈值。

方案: 范围合理,测试与生产代码 1056:449 的比例在本仓库里算是相当扎实的。有一个设计疑问,它和 Stage 2 的阻塞性发现其实是同一件事:journal repair 场景下的投递被绕到了 replay admission gate,而不是保留 provider 已经在 live 流上观测到的那个终态。把已观测到的 settlement 存进 ref、等 repair 结束后(无论成功还是失败)再发布,看起来比现在这条路径明显更简单,而且完全不需要 admission key。建议在代码审查定论之前先考虑一下。

diff 里没有夹带无关改动:就是一个功能、它的测试、它的设计文档,以及 README 里对应的一行。没有顺手重构。

风险: 无升级风险信号——改动文件都没有命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 77d8a56d2562199ee018950de224609c6ca0d484 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Code review

Reviewed the full production diff at 77d8a56d2562199ee018950de224609c6ca0d484 (449 production lines across 8 files). The projection module is the strongest part of this PR: selecting the final assistant block by identity at the block layer rather than reconstructing ownership from the render adapter is the right call, and the reasoning is recorded where a future maintainer will find it. Reusing the existing getPromptSettledKey for the dedup key instead of adding a parallel helper is also right. One Critical blocks.

🔴 Critical — a live-observed terminal is silently dropped when it is also a journal-repair target

DaemonSessionProvider.tsx. There are exactly two real publishPromptSettlement call sites, and a terminal that is a repair target can fall through both.

The live path withholds it (:3638):

if (settlement && !repairTargetsTerminal) {
  publishPromptSettlement(settlement);
}

The only re-delivery path is the replay loop, and it is gated on this provider's own admission key (:2917-2923):

if (
  replaySettlement &&
  locallyBoundPromptIdsRef.current
    .get(replaySettlement.sessionId)
    ?.has(replaySettlement.promptId)
) {
  publishPromptSettlement(replaySettlement);
}

bindPrompt has exactly one caller — onPromptAdmitted (:4578) — which fires only for a prompt this provider submitted (actions.ts:1174, :1341, both after accepted.promptId returns from its own submit). A repair, by contrast, is armed purely from truncation detection (:2810, if (replayTarget && nextCheckpoint)), with no reference to who submitted anything. So a prompt this provider merely attached to is never bound, and the replay gate cannot fire for it.

Then activeSession.consumeReplaySnapshot() (:2939) releases the snapshot and SSE resumes from lastEventId, so the terminal is never re-delivered live either. The host waits forever for a turn whose completion the transcript already visibly shows — precisely the failure the comment at :2906-2909 says this code exists to prevent, fixed for the admitted case only.

This is not a new objection, and the bot has form on getting it wrong here. The same mechanism was recorded as a deferred Critical [fails-closed] in round 7 at DaemonSessionProvider.tsx:3619"the repair-deferred live settlement has no fallback publisher, so a terminal the client observed live is never delivered" — and re-raised in round 10 at :3638 as "the repair-suppressed live settlement has no completion-path publish". Both were carried as probes and never ruled on. Worse, an earlier pass of this same gate argued the opposite: that because publishing is the only thing that unbinds a prompt, a suppressed settlement leaves it bound, so the repair's reload republishes it from the replay branch. That reasoning holds for a prompt this provider admitted and silently fails for one it did not — which is the whole finding. Treat this comment as superseding that earlier conclusion; doudouOUC's review at this head is the first confirmed ruling, and I reached the same conclusion independently from the code.

Simplest reachable trigger — no Split View required:

  1. A prompt is already running in session S (submitted from another tab, the CLI, or a previous page load).
  2. This provider attaches to S. restoredActivePrompt is set from activeSession.hasActivePrompt === true (:2432-2434) — no binding is recorded, and nothing on that path calls onPromptAdmitted.
  3. S's live journal is truncated against the retention window → repair armed with target.promptId = X.
  4. X's turn_complete arrives live → repairTargetsTerminal is true → settlement withheld at :3638.
  5. terminalSeen = true, tryLiveJournalRepair runs, reloads with replaySource: 'memory' (:1604).
  6. X's terminal comes back through replayEvents → gate at :2917 is false → not published.
  7. Snapshot consumed, SSE resumes after it. onAssistantTurnSettled never fires.

The second provider case the design doc explicitly sanctions (main chat + Split View pane on one session) hits the same path for whichever pane did not submit.

Two further loss paths for the same withheld terminal, independent of the admission gate: the repair failure branch (:1606-1621) adds a notice and clears liveJournalRepairRef.current without ever publishing the terminal it already observed — and a known outcome should not be erased just because no final message was retained. The early-return guards in tryLiveJournalRepair (:1588-1599: disposed, aborted, pendingSessionLoadRef, history loading, session switched, hasCurrentSessionActivePromptRef) each also leave it undelivered, since nothing re-arms publication afterwards.

This contradicts the contract the PR itself documents in docs/design/web-shell/web-shell-assistant-turn-settlement.md: "Each mounted DaemonSessionProvider publishes every prompt terminal observed on its live SSE stream after the terminal transcript projection is committed." The intentionally-silent history-load case is a different thing and is correctly tested; this is a terminal observed live.

Suggested shape: retain the withheld settlement (a ref keyed by session) and publish it when the repair episode ends — on successful replay publication, on the failure branch, and on the abort/dispose guards — instead of depending on the replay admission gate. That keeps "publish after the terminal projection is committed" (the reason for withholding) without making delivery conditional on submitter ownership, which the design doc already says hosts must not rely on.

Test gap: none of the 22 new test cases touches the repair path. repairTargetsTerminal appears only in the production hunk; the new provider tests cover replay-of-a-bound-prompt, epoch-reset-discards-binding, replayed-cancel, first-attach-stays-silent, and turn_error code defaulting. The existing truncated-live-turn fixture supplies the restored-turn shape but does not assert settlement delivery, so a suite that is green today stays green with the delivery missing.

Non-blocking (deferred, not counted against the verdict)

Per the repo's five-round rule, these are recorded so nothing is silently dropped, and are not reasons to hold the PR:

  • Public-contract addition. This widens a published package's surface: a new WebShellProps member plus WebShellAssistantTurnSettledEvent / WebShellAssistantTurnOutcome exported from the barrel. Stage 1c says a public-contract change escalates rather than waves through. It is noted here because the verdict is not an approval, so nothing is being waved through — but a maintainer should own this API shape deliberately, not inherit it from a green board.
  • The new design doc has no .zh-CN.md counterpart. AGENTS.md asks for both, but only 33 of 578 files under docs/design/ actually have a Chinese pair, so this is a translation gap — a Suggestion, not a Critical.
  • publishedPromptSettlementsRef grows for the provider's mounted lifetime and is never pruned. Keys are small and the mount is bounded by a page session, so this is a slow leak at worst; worth a note in the design doc rather than code.

Test evidence — the PR's own CI

This was an unattended run, so per the gate's rules no PR-derived code was built, executed, or checked out. The evidence below is the PR's own CI at the reviewed commit, read through the API.

Qwen Code CI completed success, and every leg that ran is green. The only red check is review-pr, which belongs to the 🧐 Qwen Pull Request Review workflow on the pull_request_target event — bot review orchestration, not part of the PR's test suite. I verified that from the run metadata rather than from anything the log asserts. Its practical consequence matters more than its colour: there is no bot review of head 77d8a56d at all. The ten standing CHANGES_REQUESTED reviews are all pinned to older heads, the newest to febc864f. So the confirmed Critical above was filed by a reviewer against a commit this gate had never formally reviewed.

Two ways the green board does not cover this change

Worth stating plainly, because "CI is green" reads as stronger here than it is:

  1. The integration lane that would exercise the repair path is skipped. Integration Tests (CLI, No Sandbox) did not run at this head. That lane (test:integration:cli:sandbox:none, ci.yml:2227) is where integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts lives — a test that mounts DaemonSessionProvider and drives exactly the live-journal truncation-and-recovery area the Critical sits in. It has not run for this PR.
  2. No PR lane typechecks this package. The only typecheck anywhere in the PR lanes is npm run typecheck:integration (ci.yml:2046), which covers the integration-tests tree, not packages/web-shell's client sources; no lane runs a full npm run typecheck. So green CI does not establish that the changed package compiles at this head. Combined with the author's own statement that no local validation was run, compilation at 77d8a56d is currently unattested by anyone.
Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Lint & Static (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Capture web-shell visuals (ubuntu-latest, Node 22.x) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Classify PR success
Integration Tests (CLI, No Sandbox) skipped
Test (macos-latest, Node 22.x) skipped
Test (windows-latest, Node 22.x) skipped
review-pr failure (bot review orchestration, not the PR suite)

No PR-CI workflow run is still pending on this head, so this table is settled rather than a snapshot mid-flight. The macOS and Windows Test legs are skipped, so the unit suite ran on ubuntu-latest / Node 22.x only — that is this run's matrix behaviour, not something the PR caused, but it means the cross-platform claim rests on one OS.

Not verified: the delivery behaviour itself. The author states plainly in the PR body that no local validation was run — all three "Tested on" rows are ⚠️ not run, and the environment note says "No local validation result is claimed", with earlier commands stopping on unrelated dependency resolution. That is an honest disclosure and I am treating it as the author's statement, not as evidence either way. A green suite proves the tests that ran pass; it does not prove the untested path holds, and the Critical above sits exactly in the untested path — the one whose integration lane was skipped and whose package no lane typechecks.

Sandboxed verification would settle this: @qwen-code /verify — that a live-observed terminal which is also a journal-repair target actually reaches onAssistantTurnSettled for a prompt this provider did not submit is not observable from the diff, and this PR's suite passes unchanged with that delivery missing entirely. The author has write access, so /verify runs directly rather than as a sponsored run; /tmux is the wrong lane here, since this is a Web Shell embedding API with no TUI surface.

中文说明

代码审查

77d8a56d2562199ee018950de224609c6ca0d484 上审查了完整的生产代码 diff(8 个文件、449 行生产代码)。projection 模块是这个 PR 里最扎实的部分:在 block 层按身份选取最终 assistant block,而不是从 render adapter 反推归属,这个选择是对的,而且推理过程写在了后来维护者能找到的地方。去重 key 复用已有的 getPromptSettledKey 而不是另写一个平行 helper,也是对的。有一个 Critical 阻塞合并。

🔴 Critical —— 当 live 观测到的终态同时是 journal repair 的目标时,它会被静默丢弃

DaemonSessionProvider.tsx。真正的 publishPromptSettlement 调用点只有两个,而一个作为 repair 目标的终态可以从这两个之间漏下去。

live 路径会把它扣住(:3638):if (settlement && !repairTargetsTerminal) 才发布。

唯一的补发路径是 replay 循环,而它以本 provider 自己的 admission key 为门槛(:2917-2923):要求 locallyBoundPromptIdsRef 里存在该 (sessionId, promptId)

bindPrompt 只有一个调用方——onPromptAdmitted:4578)——而它只在本 provider 自己提交的 prompt 上触发(actions.ts:1174:1341,都在自己的 submit 返回 accepted.promptId 之后)。相反,repair 完全由截断检测触发(:2810if (replayTarget && nextCheckpoint)),与谁提交无关。所以一个本 provider 只是附着上去的 prompt 永远不会被 bind,replay 门槛对它就永远不会成立。

随后 activeSession.consumeReplaySnapshot():2939)释放快照,SSE 从 lastEventId 之后继续,因此这个终态也不会再以 live 方式重新送达。宿主会永远等一个 transcript 上已经显示完成的 turn —— 而这恰好是 :2906-2909 注释声称本段代码存在的目的所要防止的失败,只是仅对 admitted 的情形修好了。

这不是一个新提出的异议,而且 bot 在这件事上有过判断失误。 同一机制在第七轮就被记录为一个延后的 Critical [fails-closed],位置在 DaemonSessionProvider.tsx:3619——"repair 延后的 live settlement 没有兜底发布者,因此客户端已在 live 流上观测到的终态永不会投递"——第十轮又在 :3638 重新提出:"repair 抑制掉的 live settlement 没有完成路径上的发布"。两次都是作为 probe 携带、从未被裁定。更糟的是,本 gate 早先的一轮曾给出相反的论证:因为发布是唯一会解绑 prompt 的动作,被抑制的 settlement 会让 prompt 保持 bound,于是 repair 的 reload 会落到 replay 分支并从那里补发。这个推理对本 provider 自己 admitted 的 prompt 成立,但对它没有 admitted 的 prompt 静默失效——而那正是整个发现所在。请把本评论视为对那个早先结论的推翻;doudouOUC 在该 head 上的 review 是第一个确认性裁定,而我是从代码独立得到同一结论的。

最简可复现路径 —— 不需要 Split View:

  1. session S 里已经有一个 prompt 在跑(由另一个 tab、CLI 或上一次页面加载提交)。
  2. 本 provider 附着到 SrestoredActivePromptactiveSession.hasActivePrompt === true 置位(:2432-2434)——不会记录任何 binding,这条路径上也没有任何东西调用 onPromptAdmitted
  3. S 的 live journal 相对保留窗口被截断 → repair 以 target.promptId = X 装配。
  4. Xturn_complete 从 live 流到达 → repairTargetsTerminal 为真 → settlement 在 :3638 被扣住。
  5. terminalSeen = truetryLiveJournalRepair 执行,以 replaySource: 'memory' 重新加载(:1604)。
  6. X 的终态经 replayEvents 回来 → :2917 的门槛为假 → 不发布。
  7. 快照被消费,SSE 从其之后继续。onAssistantTurnSettled 永不触发。

设计文档明确允许的第二 provider 情形(主聊天 + Split View pane 挂同一 session),对没有提交该 prompt 的那个 pane 会走到同一条路径。

同一个被扣住的终态还有两条额外的丢失路径,与 admission gate 无关:repair 失败分支(:1606-1621)只是加了一条 notice 并清空 liveJournalRepairRef.current,从不发布它已经观测到的终态——而已知的结局不应该因为没保留下最终消息就被抹掉。tryLiveJournalRepair 的提前返回守卫(:1588-1599:disposed、aborted、pendingSessionLoadRef、history loading、session 已切换、hasCurrentSessionActivePromptRef)同样会让它无人补发,因为之后没有任何东西重新装配发布。

这与 PR 自己在 docs/design/web-shell/web-shell-assistant-turn-settlement.md 里写下的契约相矛盾:"每个已挂载的 DaemonSessionProvider 都会发布其在 live SSE 流上观测到的每一个 prompt 终态,且在终态 transcript projection 提交之后。" 有意保持静默的 history-load 情形是另一回事,并且已被正确测试;而这里是在 live 流上观测到的终态。

建议的形态: 把被扣住的 settlement 保留下来(按 session 存进一个 ref),在 repair episode 结束时发布——replay 发布成功时、失败分支上、以及 abort/dispose 守卫处——而不是依赖 replay admission gate。这样既保住了"在终态 projection 提交后再发布"(也就是扣住它的原因),又不会让投递取决于提交者归属,而设计文档本身已经说明宿主不应依赖该归属。

测试缺口: 22 个新增测试用例没有一个触及 repair 路径。repairTargetsTerminal 只出现在生产代码 hunk 里;新增的 provider 测试覆盖了 bound prompt 的 replay、epoch reset 丢弃 binding、replay 的 cancelled、首次附着保持静默、以及 turn_error code 默认值。已有的 truncated-live-turn fixture 提供了 restored-turn 的形状,但没有断言 settlement 投递,因此今天绿色的测试套件在这段投递完全缺失时依然是绿色的。

非阻塞(已记录,不计入结论)

按仓库的五轮规则,以下记录下来以免被静默丢弃,它们不构成压住 PR 的理由:

  • 公开契约的新增。 这扩大了一个已发布 package 的接口面:新增一个 WebShellProps 成员,并从 barrel 导出 WebShellAssistantTurnSettledEvent / WebShellAssistantTurnOutcome。Stage 1c 规定公开契约类改动应当上升而不是直接放行。之所以记在这里,是因为本次结论不是批准,所以并没有任何东西被放行——但这套 API 形态应当由维护者有意识地拍定,而不是从一块绿板上顺带继承下来。
  • 新设计文档没有对应的 .zh-CN.md。AGENTS.md 要求两者都有,但 docs/design/ 下 578 个文件里实际只有 33 个有中文版本,所以这是翻译缺口——属于 Suggestion,不是 Critical。
  • publishedPromptSettlementsRef 在 provider 挂载期间只增不减,从不清理。key 很小、挂载周期也受页面会话限制,因此最多是缓慢泄漏;更适合在设计文档里记一笔,而不是改代码。

测试证据 —— PR 自身的 CI

本次为无人值守运行,因此按 gate 规则没有构建、执行或检出任何 PR 派生代码。下面的证据是在被审查 commit 上通过 API 读取的 PR 自身 CI 结果。

Qwen Code CIsuccess 完成,所有实际运行的环节都是绿的。唯一红的是 review-pr,它属于 🧐 Qwen Pull Request Review workflow、pull_request_target 事件——是 bot 的 review 编排,不属于 PR 的测试套件。这一点我是从 run 元数据核实的,而不是采信日志里的任何自述。它的实际后果比颜色本身更重要:head 77d8a56d 上根本不存在 bot 的 review。 十个仍在生效的 CHANGES_REQUESTED 全部钉在更旧的 head 上,最新的一个是 febc864f。也就是说,上面那个已确认的 Critical 是由一位 reviewer 针对一个本 gate 从未正式审查过的 commit 提出的。

绿板并未覆盖本改动的两处

需要明确说出来,因为这里的"CI 是绿的"读起来比实际情况更强:

  1. 本可以覆盖 repair 路径的 integration 环节被跳过了。 Integration Tests (CLI, No Sandbox) 在该 head 上没有运行。那个环节(test:integration:cli:sandbox:noneci.yml:2227)正是 integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts 所在的通道——该测试会挂载 DaemonSessionProvider,驱动的恰好就是 Critical 所在的 live journal 截断与恢复区域。它在本 PR 上没有跑过。
  2. 没有任何 PR 环节对该 package 做类型检查。 PR 环节里唯一的 typecheck 是 npm run typecheck:integrationci.yml:2046),它覆盖的是 integration-tests 目录,而不是 packages/web-shell 的 client 源码;没有任何环节运行完整的 npm run typecheck。因此绿色的 CI 并不能证明被改动的 package 在该 head 上可以编译。再加上作者自己声明没有做本地验证,77d8a56d 的编译状态目前无人佐证。

CI 表格见上方英文区(已由 region marker 包裹,便于 CI 落定后就地更新)。本 head 上没有仍在 pending 的 PR-CI workflow run,因此该表是已落定的结果,而非中途快照。macOS 与 Windows 的 Test 环节被跳过,所以单元测试只在 ubuntu-latest / Node 22.x 上跑过——这是本次 run 的矩阵行为,不是 PR 造成的,但意味着跨平台结论只有一个 OS 支撑。

未验证: 投递行为本身。作者在 PR 正文中明确说明没有做本地验证——三行 "Tested on" 全是 ⚠️ not run,环境说明写着"本次不声明任何本地验证结果",此前命令在无关的依赖解析阶段就停止了。这是诚实的披露,我把它当作作者的陈述,而非任何一方的证据。绿色的套件证明跑过的那些测试通过,并不能证明未测路径成立;而上面那个 Critical 恰好落在未测路径里——它的 integration 环节被跳过,它所在的 package 也没有任何环节做类型检查。

沙箱验证可以定这件事:@qwen-code /verify —— "一个既是 journal repair 目标、又在 live 流上被观测到的终态,对本 provider 未曾提交的 prompt 是否真的能到达 onAssistantTurnSettled",这一点无法从 diff 观察出来,而本 PR 的测试套件在这段投递完全缺失时依然原样通过。作者具备 write 权限,因此 /verify 可直接运行,无需 sponsored run;/tmux 在这里不是合适的通道,因为这是 Web Shell 嵌入 API,没有 TUI 界面。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 77d8a56d2562199ee018950de224609c6ca0d484 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — the feature is well-constructed and honestly documented, but its central promise breaks in a reachable case, and that case is the one part of the delivery path with no test, no integration lane, and no typecheck.

Stepping back: this is good work. The block-layer selection in the projection module, the refusal to spread internal fields into the host contract, the reuse of getPromptSettledKey, the comments that record why rather than what, and 1056 lines of test against 449 lines of production code — this is not a volume PR, and it does not read like one. The PR body is also unusually candid: it declares that no local validation was run rather than dressing up a static review as testing. I would rather read that than a fabricated ✅.

Against my own independent proposal for the same problem (emit from the provider that observes the terminal, after the transcript commit, dedupe on (sessionId, promptId), stay silent on history load), the PR matches on every point but one. Where it diverges is the journal-repair case, and the divergence is the defect: it withholds a terminal it already observed live, then makes re-delivery conditional on an admission key that only exists for prompts this provider submitted. Everything downstream of that follows mechanically — the snapshot is consumed, SSE resumes past it, and the host never hears about a turn the transcript already shows as finished.

What makes this a blocker rather than a nit is that it contradicts the contract the PR writes down for itself. The design doc promises every live-observed terminal is published; the code publishes every live-observed terminal except the repair target, and republishes that one only for the submitter. The multi-provider case the same doc explicitly sanctions is one of the ways to be a non-submitter, and a plain page reload during someone else's running turn is another — no exotic interleaving needed.

I checked this against the code at the reviewed commit rather than taking the prior review's word for it: two real publish sites, one bind site, repair armed purely from truncation detection, restoredActivePrompt set from the daemon's hasActivePrompt with no binding, snapshot consumed immediately after the gated replay loop, and the repair failure branch clearing the ref without publishing. I could not find a path that delivers the withheld terminal to a non-submitter. If one exists, it is not in the diff and I would want it named.

I also want to correct this gate's own record. An earlier pass of this triage argued the opposite conclusion — that suppressing the settlement leaves the prompt bound, so the repair's reload republishes it. That is true for an admitted prompt and false for any other, and the distinction is the entire bug. The mechanism was flagged as a deferred Critical [fails-closed] in round 7 at :3619 and again in round 10 at :3638, and was never ruled on in either round. So this is not a fresh objection that twelve rounds missed; it is one the process kept carrying without deciding, and the deferral is what let it survive. Stage 2 above supersedes the earlier reasoning.

It also fails in the direction that is hardest to notice. The suite is green, CI is green, and none of the 22 new tests touches repairTargetsTerminal. The integration lane that drives live-journal recovery against a mounted DaemonSessionProvider was skipped at this head, and no PR lane typechecks packages/web-shell at all — so compilation here is unattested by anyone, CI included. The missing delivery is invisible to every gate currently watching this PR. That is the specific reason a green board is not reassuring.

On the round count: this PR has been through roughly twelve rounds and the repo's own guidance is to land only Critical fixes past round five and defer the rest. I have applied that — the Suggestions in Stage 2 (public-contract ownership, missing .zh-CN.md design doc, the unpruned dedup set) are recorded and explicitly deferred, not held against the verdict. What remains is a correctness defect in the feature's stated contract, squarely the category that still counts at this stage. I am not asking for another broad pass; I am asking for this one path to deliver, and for a test that fails when it does not.

Verdict: request changes. No approval, and no deferred-approval instruction to the finalize job — the verdict is not approve, so there is nothing for CI to unblock.

No new review was submitted on this re-run. The PR already carries ten standing CHANGES_REQUESTED reviews from this bot and reviewDecision is already CHANGES_REQUESTED, so the merge gate is in place and unchanged; the gate's re-run rule is to update the staged comments rather than stack a duplicate review on a PR that is already blocked. Two practical notes for whoever picks this up: all ten are pinned to older heads — the newest to febc864f — so none of them is a review of the code under discussion, and they have survived every force-push, meaning this PR cannot merge as things stand even if the bot approved it. Dismissing them is a maintainer call, not one the bot should make unilaterally.

@yiliang114 — the fix looks small relative to everything already done here: retain the withheld settlement and publish it when the repair episode ends (success, failure branch, and the abort/dispose guards) instead of routing it through the replay admission gate, then add a case to the existing truncated-live-turn fixture asserting the host receives it. Given the lane gaps above, @qwen-code /verify on the next head is worth more than another review round — it is the only thing here that can actually observe the delivery. Happy to re-run the gate after that.

中文说明

Confidence: 2/5 —— 功能本身构建得不错、文档也诚实,但它的核心承诺在一个可达场景下失效,而这个场景恰好是整条投递路径上唯一既没有测试、也没有 integration 环节、也没有类型检查的部分。

退一步看:这是高质量的工作。projection 模块在 block 层做选取、拒绝把内部字段 spread 进宿主契约、复用 getPromptSettledKey、注释记录的是"为什么"而不是"做了什么",以及 449 行生产代码配 1056 行测试——这不是靠量堆出来的 PR,读起来也不像。PR 正文也异常坦诚:直接声明没有做本地验证,而不是把静态 review 包装成测试。比起一个伪造的 ✅,我更愿意读到这样的说明。

对照我自己针对同一问题的独立方案(由观测到终态的 provider 发出、在 transcript 提交之后、按 (sessionId, promptId) 去重、history load 保持静默),这个 PR 在每一点上都吻合,只有一点例外。分歧出现在 journal repair 场景,而这个分歧就是缺陷本身:它把自己已经在 live 流上观测到的终态扣住,然后让补发取决于一个只有"本 provider 自己提交的 prompt"才会有的 admission key。之后的一切都是机械推导——快照被消费、SSE 从其之后继续、宿主永远收不到一个 transcript 上已显示完成的 turn 的通知。

之所以这是阻塞项而不是小瑕疵,是因为它与 PR 为自己写下的契约相矛盾。设计文档承诺每个 live 观测到的终态都会发布;代码发布的是每个 live 观测到的终态除了 repair 目标,而那一个只对提交者补发。同一份文档明确允许的多 provider 情形,正是成为"非提交者"的途径之一;而在别人正在跑的 turn 期间刷新一次页面,是另一条途径——不需要什么罕见的交错。

我是对照被审查 commit 的代码核实这一点的,而不是采信先前 review 的说法:两个真正的发布点、一个 bind 点、repair 完全由截断检测装配、restoredActivePrompt 由 daemon 的 hasActivePrompt 置位且不记录 binding、被 gate 的 replay 循环之后立即消费快照、repair 失败分支清空 ref 而不发布。我找不到任何能把被扣住的终态投递给非提交者的路径。如果存在,它不在这个 diff 里,我希望它被点名。

我还要更正本 gate 自己的记录。 早先一轮 triage 给出过相反的结论——认为抑制 settlement 会让 prompt 保持 bound,于是 repair 的 reload 会把它补发出来。这对 admitted 的 prompt 成立,对其它任何 prompt 都不成立,而这个区别正是整个 bug。该机制在第七轮就被标记为延后的 Critical [fails-closed]:3619),第十轮再次(:3638),两轮都从未被裁定。所以这并不是十二轮都漏掉的新异议,而是流程一直携带、却始终不做决定的那一类;正是"延后"让它活了下来。上面的 Stage 2 推翻早先那段推理。

它的失效方向也是最难被发现的那种。测试套件是绿的,CI 是绿的,22 个新增测试没有一个触及 repairTargetsTerminal。本可以针对已挂载的 DaemonSessionProvider 驱动 live journal 恢复的 integration 环节在该 head 上被跳过,而且没有任何 PR 环节对 packages/web-shell 做类型检查——因此这里的编译状态连 CI 在内都无人佐证。这段缺失的投递对当前所有盯着这个 PR 的 gate 都是隐形的。这正是绿板无法让人安心的具体原因。

关于轮次:这个 PR 已经走了大约十二轮,而仓库自己的指引是超过五轮之后只落 Critical 修复、其余延后。我按此执行——Stage 2 里的几条 Suggestion(公开契约的归属、缺 .zh-CN.md 设计文档、未清理的去重集合)已记录并明确延后,不计入结论。剩下的这一项是功能既定契约里的正确性缺陷,恰好属于在这个阶段仍然算数的那一类。我不是在要求再做一轮大范围审查;我是要求这一条路径能投递,并且有一个在它不投递时会失败的测试。

结论:request changes。 不批准,也不给 finalize 任务留任何延迟批准指令——结论不是 approve,因此没有可供 CI 解锁的东西。

本次重跑没有提交新的 review。 该 PR 已经带有本 bot 的十个仍在生效的 CHANGES_REQUESTED,且 reviewDecision 已经是 CHANGES_REQUESTED,所以合并 gate 已经就位且未变;gate 的重跑规则是更新分阶段评论,而不是在一个已被阻塞的 PR 上再叠一个重复 review。给接手的人两点实际情况:这十个全部钉在更旧的 head 上——最新的是 febc864f——因此没有一个是对当前讨论代码的 review,而且它们熬过了每一次 force-push,意味着即使 bot 批准,这个 PR 按现状也无法合并。是否 dismiss 它们是维护者的决定,不该由 bot 单方面做。

@yiliang114 —— 相对于这里已经完成的工作量,修复看起来不大:把被扣住的 settlement 保留下来,在 repair episode 结束时(成功、失败分支、以及 abort/dispose 守卫处)发布它,而不是把它绕经 replay admission gate;然后在已有的 truncated-live-turn fixture 里加一个用例,断言宿主确实收到它。鉴于上面的环节缺口,在下一个 head 上跑一次 @qwen-code /verify 比再来一轮 review 更有价值——它是这里唯一能真正观测到投递的手段。之后我很乐意重跑 gate。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 77d8a56d2562199ee018950de224609c6ca0d484 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@yiliang114 Needs some rethinking — full notes in the three staged comments above. The short version, two blockers:

1. It does not compile. promptSettledFromTurnEvent passes data: Record<string, unknown> | undefined into the file's local getString(value: Record<string, unknown>, key: string), at lines 4835 and 4841 of DaemonSessionProvider.tsx. Two TS2345 errors — Lint & Static and Capture web-shell visuals are both red on them, and the visuals job fails prepare through tsc -p tsconfig.lib.json. getString(data ?? {}, ...) settles it.

2. useDaemonPromptSettled throws unconditionally, and that breaks two existing suites. App() now calls it as its first statement regardless of whether onAssistantTurnSettled was passed, and it throws when DaemonPromptSettlementContext is absent. App.test.tsx renders <App /> bare and mocks only the daemon-react-sdk barrel — but the new module imports the hook directly from ./daemon/session/DaemonSessionProvider.js, which that mock does not cover, so the real hook sees an undefined context. SplitView.test.tsx has the same shape with the new observer inside a mocked provider rendered as a plain <div>. Since the prop is optional, opting out should not create a hard provider requirement — a no-op when subscribe is undefined keeps the throw for the case that matters.

Also worth a look while you are in there: turn_complete with stopReason: 'error' is classified as outcome: 'completed', and docs/developers/qwen-serve-protocol.md lists error as a valid turn_complete stop reason. And the contract this PR adds has no test pinning any of its six claimed properties — DaemonSessionProvider.test.tsx already has the machinery to drive turn_complete / turn_error.

To be clear, none of this is doubt about the feature. The direction is well-supported (#10389 asked for exactly this contract, the protocol doc already tells integrators to correlate terminals by promptId, and the Java SDK already exposes the same idea), the scope is tight with no creep, and the API shape is one I would be happy to maintain. It needs to build, not regress the harnesses it renders into, and carry a test for what it adds.

中文说明

需要再调整一下——完整意见见上面三条分阶段评论。简要说,两个阻塞项:

1. 编译不过。 promptSettledFromTurnEventdata: Record<string, unknown> | undefined 传给了文件内的本地 getString(value: Record<string, unknown>, key: string),位置在 DaemonSessionProvider.tsx 第 4835、4841 行。两个 TS2345——Lint & StaticCapture web-shell visuals 都因此变红,且 visuals 任务是通过 tsc -p tsconfig.lib.jsonprepare 失败的。改成 getString(data ?? {}, ...) 即可。

2. useDaemonPromptSettled 无条件抛错,会打挂两个既有测试套件。 App() 现在把它作为第一条语句调用,无论是否传入 onAssistantTurnSettled;而 DaemonPromptSettlementContext 缺失时它会抛错。App.test.tsx 裸渲染 <App /> 且只 mock 了 daemon-react-sdk barrel——但新模块是直接./daemon/session/DaemonSessionProvider.js 导入该 hook 的,不在那个 mock 覆盖范围内,于是真实 hook 拿到 undefined context。SplitView.test.tsx 是同样的形态:新 observer 位于被 mock 成普通 <div> 的 provider 内部。既然这个 prop 是可选的,"不启用"就不该产生硬性 provider 依赖——在 subscribe 为 undefined 时 no-op,就能把抛错保留给真正需要它的场景。

顺手也值得看一眼:turn_completestopReason: 'error' 会被分类为 outcome: 'completed',而 docs/developers/qwen-serve-protocol.md 明确把 error 列为 turn_complete 的合法 stop reason。另外本 PR 新增的契约没有任何测试钉住它声称的六条属性——DaemonSessionProvider.test.tsx 已经有驱动 turn_complete / turn_error 的现成机制。

需要说清楚的是,这些都不是对功能本身的怀疑。方向有充分支撑(#10389 要求的正是这套契约、协议文档已经要求集成方按 promptId 关联终态、Java SDK 也已暴露同样的概念),范围收紧无夹带,API 形态也是我乐意维护的。它只是需要能编译、不要打挂它所渲染进的夹具、并为新增内容带上一个测试。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at ba3ee481db2597dca283e7b18bf10f426fdcdcee · re-run with @qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed the current review findings in 544405be070c: malformed terminal data now falls back to an empty record, the optional settlement callback no longer imposes a provider requirement when unused, and turn_complete with stopReason: error is classified as failed. No tests were added and no local test/build/typecheck/CI commands were run.

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 64a996b. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 5 render-shaping files:

  • packages/web-shell/client/App.tsx
  • packages/web-shell/client/components/SplitView.tsx
  • packages/web-shell/client/customization.tsx
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx
  • packages/web-shell/client/index.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Follow-up static self-audit found one remaining opt-out path: Split View mounted the settlement observer even when the optional host callback was absent, so existing lightweight embeddings/mocks could still call unavailable transcript hooks. Fixed in dba5292f285c by mounting the observer only when the callback is enabled and making its prop required. No local test/build/typecheck/CI command was run.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • missing tests for the new settlement contract (assistant-turn-settlement.ts, DaemonSessionProvider.tsx:3486) — already reported (review 5128055949; author confirmed unaddressed in comment 5565319059)
  • unbounded publishedPromptSettlementsRef ledger (DaemonSessionProvider.tsx:1067) — already reported as a minor non-blocking note (review 5128055949)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the area the live-journal repair finding touches.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the area the live-journal repair finding touches.

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/components/SplitView.tsx
Comment thread packages/web-shell/client/customization.tsx Outdated
- project the settled message off the raw blocks with the SDK's own
  final-visible-assistant predicate; a promptId-filtered re-projection
  through the render adapter dropped every tool block of the turn (the
  reducer never stamps promptId on tool blocks), merged the turn's text
  halves under the first block's id, and promoted subagent-owned text
- publish the settlement where a replayed terminal settles a locally
  bound prompt: the snapshot is released after injection and SSE resumes
  from lastEventId, so that terminal never reaches the live-loop publish
  and the host callback never fired for it
- default the settlement error code to 'turn_error', matching the
  DaemonHttpError that matchTurnEvent rejects the submitter with
- build the published host event field by field so an internal-only
  field cannot widen the public contract silently

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtrow8ztpw

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • promptSettledFromTurnEvent re-implementing the SDK's matchTurnEvent mapping (packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:4860) — already reported as R1-9 (comment 3949408399), whose concrete drift instance this round …
  • selector duplicating the SDK's module-private final-visible-message predicate (packages/web-shell/client/assistant-turn-settlement.ts:45) — already voiced inside R1-1 (comment 3949408367), which the author answered
  • unbounded publishedPromptSettlementsRef ledger (packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:1076) — already reported as a minor non-blocking note in review 5128055949

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this round's replay publish changes.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • docs/design/web-shell/web-shell-assistant-turn-settlement.md:44 — [review] 5 of 6 Verification bullets claim coverage no test provides
  • packages/web-shell/client/components/SplitView.tsx:491 — [review] Side-task provider really submits prompts but forwards no…
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this round's replay publish changes.

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread docs/design/web-shell/web-shell-assistant-turn-settlement.md Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed the current blocking replay gap and the two host-contract mismatches in 557f3e00deed. Locally admitted (sessionId, promptId) keys now survive active-controller cleanup, so epoch resets and session switches cannot suppress a replay-only terminal. Final-message projection now reuses the existing full-transcript adapter and selects the rendered assistant message through sourceBlockIds; the delivery docs now distinguish live-session terminals from locally admitted replay terminals. I am not expanding this round for test-only additions, a public export of the internal hook, provider-global deduplication, or deferred journal-repair projection. No local test, build, typecheck, or CI command was run.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed R1-15 in bb506df: the settlement projection now omits a candidate assistant message while it is still streaming, so hosts cannot persist partial content as a final settled answer. This is a one-line guard in the shared projection path. Validation was limited to repository-pinned formatting, git diff --check, and static diff review; no local test/build/typecheck command was run.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • promptSettledFromTurnEvent re-implementing the SDK's matchTurnEvent mapping (packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:4875) — already reported as R1-9 (comment 3949408399); round 2 confirmed and dropped it as a dup…

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this round's replay publish changes.

Not explored to full depth (tool budget reached): "agent 3b": did not execute the reconnect-replay scenario to confirm the rebuilt snapshot leaves streaming: true on the final assistant block (Finding 1's premise); read-….

Not reviewed: reverse audit — stopped before round 6 by the review time budget.

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:4886 — [probe] Outcome classification fails open: 3 of 5 ACP stop reasons publish as 'completed' (deferred: anchored on code unchanged since the previous round)
  • packages/web-shell/client/App.tsx:17403 — [probe] Split View pane→host forwarding untested while the design doc claims it verified (re-deferral of round 2's Verification-bullets entry; anchored on unchanged code)

Convergence: round 3 posted 9 inline comment(s), 8 of them reported for the first time; the previous round posted 4 (4 new). Findings keep coming back to the same files: packages/web-shell/client/assistant-turn-settlement.ts (findings in round 2; 4 more now); packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx (findings in round 2; 3 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this round's replay publish changes.

未探索到全部深度(达到工具调用预算):"agent 3b"did not execute the reconnect-replay scenario to confirm the rebuilt snapshot leaves streaming: true on the final assistant block (Finding 1's premise); read-…

未审查:反向审计——评审时间预算不足,未能开始第 6 轮。

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 3 轮发布了 9 条行内评论,其中 8 条是首次提出;上一轮发布了 4 条(其中 4 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/assistant-turn-settlement.ts(第 2 轮已出过发现,本轮又有 4 条);packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx(第 2 轮已出过发现,本轮又有 3 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/App.tsx
yiliang114 and others added 5 commits September 8, 2026 20:02
Retire locally-bound prompt keys whose terminal is destroyed rather than
delivered. An epoch reset records the bound prompt and, after the reload's
replay injection, publishes a `failed` settlement when the fresh snapshot
carried no terminal for it; auth/terminal-error cleanup and
heartbeat-detected missing sessions retire the bound prompt directly. The
removePendingPrompt stale-session branch now notifies prompt removal with
the foreign session id so the ledger delete keys the right session, and the
settlement projection restores the whitespace-only assistant block skip.

Add discriminating tests: replayed terminal after epoch reset, failed
retirement when the terminal is destroyed, replayed cancelled terminal, and
stale-session removal notification.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtsl1npgrb
…g the onAssistantTurnSettled row

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtss6ux5rn
Resolve split-view conflicts by keeping both the PR's
onAssistantTurnSettled hook and main's showSessionDetails /
onPendingPanesChange wiring.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtt0ri0as1

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • live-journal repair ordering at packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3542 — already reported as R1-6 (comment 3949408393)
  • promptId scoping unpinned in packages/web-shell/client/assistant-turn-settlement.test.tsx — already reported as R2-3 (comment 3957009205)
  • goal-continuation terminals at packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3547 — already reported as R1-23 (comment 3949408436)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this PR's replay publish changes.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 5)": constructing a production producer whose terminal frame carries an envelope promptId that disagrees with (or is present without) data.promptId — the harm ha…; "agent reverse-audit (round 5)": running the test file or any mutation probe; every discrimination claim above is by inspection of the fixtures and the two readers.; "agent reverse-audit (round 5)": sweeping the rest of the 20k-line test file for a pre-existing fixture that envelope-stamps a terminal frame while a settlement listener is attached — only the …; "agent 1b": none — all planned checks completed (~15 tool calls).; "agent reverse-audit (round 4)": whether the daemon ever completes a *successful* cancel without a following turn_complete{stopReason:'cancelled'} — I read broadcastPromptCancelled 's contra…, and 2 more.

Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:

  • packages/web-shell/client/App.tsx:2978 — [probe] The main-chat entrance has no test at all
  • packages/web-shell/client/App.tsx:2978 — [probe] The side-task provider submits prompts but has no observer…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:24 — [probe] Nothing pins that the session id is read at delivery, not…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:91 — [probe] Nothing pins that blocks are read inside the listener, not…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:167 — [probe] The subagent-text test is vacuous w.r.t. the filter it pins
  • packages/web-shell/client/assistant-turn-settlement.ts:118 — [probe] No test joins a real provider terminal to the real…
  • packages/web-shell/client/components/SplitView.tsx:491 — [probe] Neither production wiring of the new public prop is tested…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10284 — [probe] Session-switch survival of the admission key is unwitnessed
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10284 — [probe] No replayed turn_error for a bound prompt exists anywhere
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10663 — [probe] The envelope-first promptId reader the publish path uses…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:1108 — [probe] The publish-once dedup guard is documented as verified but…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3542 — [probe] The projection-before-callback ordering guarantee is…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3544 — [probe] Nothing pins that the live publish is NOT admission-gated
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:4475 — [probe] A restored prompt was never admitted, so a replay-only…

Convergence: round 4 posted 6 inline comment(s), 4 of them reported for the first time; the previous round posted 9 (8 new). Findings keep coming back to the same files: packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx (findings in round 3; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this PR's replay publish changes.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 5)"constructing a production producer whose terminal frame carries an envelope promptId that disagrees with (or is present without) data.promptId — the harm ha…"agent reverse-audit (round 5)"running the test file or any mutation probe; every discrimination claim above is by inspection of the fixtures and the two readers."agent reverse-audit (round 5)"sweeping the rest of the 20k-line test file for a pre-existing fixture that envelope-stamps a terminal frame while a settlement listener is attached — only the …"agent 1b"none — all planned checks completed (~15 tool calls)."agent reverse-audit (round 4)"whether the daemon ever completes a *successful* cancel without a following turn_complete{stopReason:'cancelled'} — I read broadcastPromptCancelled 's contra…,另有 2 条。

收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 14 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 4 轮发布了 6 条行内评论,其中 4 条是首次提出;上一轮发布了 9 条(其中 8 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx(第 3 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx
Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/daemon/session/actions.ts

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 5 review (re-check at HEAD 0eab4a8)

This round re-checks the 6 standing blockers from the previous round and the verify-bot findings against the current head. No Criticals. Three medium-confidence notes below, all of which the PR already documents or defers.

Blocker re-check

All 6 round-4 blockers verified fixed by this diff:

ID Severity Title Verdict
R3-1 Critical Destroyed terminal never settles Fixed. retireAbandonedPrompt + epochResetBoundPromptRef + post-replay sweep publish failed settlements for epoch-reset, session-error, and session-missing paths
R3-2 Suggestion Stale-session turn-navigation guard untested Fixed. 7298af7aac added test does not record a stale-session removal in the current session turn navigation — spies recordPromptRemoved, asserts never called for foreign session
R3-3 Suggestion Epoch-reset test discards submitter promise Fixed. Changed from void prompt.catch(() => {}) to await expect(pending).resolves.toEqual(...) — contradictory cancelled/completed verdicts are now observable
R3-4 Suggestion No streaming-guard test Fixed. faac6438f added two fixtures: block-level streaming guard + merged-message streaming guard; both pin their respective guards
R4-1 Suggestion Three near-verbatim resync fixtures Fixed. 0be25ba5 extracted createResyncReplayFixture()
R4-2 Suggestion Stale-session removed guard unwitnessed Fixed. b946ccd8c added test driving removed: false through removePendingPrompt and asserting onPromptRemoved not called

Key correctness checks against the current code

  1. stopReason → outcome mappingpromptSettledFromTurnEvent correctly maps stopReason === 'error' to failed, cancelled to cancelled, and everything else to completed. ✅
  2. Final message projectiongetSettledAssistantMessage uses transcriptBlocksToDaemonMessages(blocks, { includeSourceIdentity: true }) and scans by sourceBlockIds, not a promptId-filtered subset. ✅
  3. Both streaming guards — block-level (blocks.some(block => block.streaming)) and message-level (message.isStreaming) — are present and tested. ✅
  4. Abandoned prompt retirement — wired to all three declared paths: epoch_reset (post-replay sweep), session_error (auth/terminal error handler), session_missing (heartbeat handler). ✅
  5. Per-provider deduppublishedPromptSettlementsRef suppresses duplicates by (sessionId, promptId) key. ✅
  6. Stale-session removal guardonPromptRemoved fires only when result.removed === true; test pins the removed: false branch. ✅
  7. App + SplitView wiringApp calls useAssistantTurnSettlementProjection(onAssistantTurnSettled) at component top; SplitView mounts AssistantTurnSettlementObserver per pane, gated on the callback being present. ✅

Remaining items (all Suggestion-level, per the five-round convention)

  1. clearSession/clearActiveSessionState does not retire the new refs — same gap the triage bot flagged (Suggestion 2 in its Stage 2). locallyBoundPromptKeysRef and publishedPromptSettlementsRef survive clearActiveSessionState(), so a prompt killed by session clear leaves a dedup set entry and a host waiting on that (sessionId, promptId) never hears back. Not filed as Critical because clearActiveSessionState also aborts the active controller — the prompt stops running — and a host that initiates clearSession is unlikely to be listening for settlement on the cleared session. The design doc enumerates three abandonment paths (epoch_reset, session_error, session_missing) but omits this fourth one. Worth wiring before the surface stabilises.

  2. App.test.tsx mock declaration is dead weight at this head — the mockUseDaemonActivePromptBridge hunk (+7 lines) was declared to satisfy an assertion inherited from the pre-merge base. Main has since fixed that assertion in #11406 by pointing it at mockUseDaemonSessionActivityBridge. Nothing at the merged 0eab4a8 head reads the added mock; dropping the whole hunk is the cleanest outcome. The triage bot called this in Stage 2 — repeating it only because it persists at the current head.

  3. No typecheck lane in PR CI compiles packages/web-shell — the triage bot noted this at confidence 3/5 (its only block besides the stale base, which is now merged). Vitest strips types, ESLint does not check them, and the browser jobs drive npm run dev not tsc. Running npm run typecheck --workspace=packages/web-shell locally, or triggering @qwen-code /verify, would confirm the new type surface compiles.

Summary

The feature is well-structured, the fix history converges properly, and every prior Critical and Suggestion is genuinely resolved at this head. I see nothing that would block merge after CI is green and the typecheck is confirmed.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review

This is a post-round-4 review of PR #11251 at head 0eab4a848592. The PR has been through four full /review rounds (all CHANGES_REQUESTED) plus a triage re-run that reported confidence 3/5 ("the code is in good shape and every earlier finding is genuinely fixed; what blocks is a stale base and a typecheck that no CI lane runs").

Blocker re-check (round 4 ledger)

I verified each of the 6 standing blockers against the current diff:

ID Severity Verdict Evidence
R3-1 Critical Fixed retireAbandonedPrompt + epochResetBoundPromptRef + post-replay sweep publish failed/epoch_reset when no terminal arrives; wired to session_error and session_missing paths. Witness test present.
R3-2 Suggestion Fixed onPromptRemoved widened to 3 args; sessionId === undefined guard protects turn-navigation. Witness test spies recordPromptRemoved.
R3-3 Suggestion Fixed Epoch-reset + cancelled tests now await expect(pending).resolves.toEqual(...), pinning contradictory verdicts.
R3-4 Suggestion Fixed Both streaming guards added with witness tests (block-level + message-level).
R4-1 Suggestion Fixed createResyncReplayFixture extracted, three tests share one fixture.
R4-2 Suggestion Fixed Test driving removed: false asserts onPromptRemoved not called.

No remaining Criticals

I verified the key behavioral guarantees against the current diff:

  • turn_complete with stopReason: 'error' maps to outcome: 'failed'.
  • getSettledAssistantMessage uses the full-transcript adapter with includeSourceIdentity: true, matching via sourceBlockIds — not a promptId-filtered re-projection.
  • Both streaming guards present: block-level scan + message-level isStreaming check. Two test fixtures pin each.
  • retireAbandonedPrompt wired to all three abandonment paths: epoch-reset, session_error, session_missing.
  • publishPromptSettlement deduplicates via publishedPromptSettlementsRef.
  • Stale-session removal guarded by if (result.removed) before onPromptRemoved fires.
  • App.test.tsx mock declaration fixes the inherited ReferenceError that existed at the PR's base commit.

Known deferred item

The clearSession/clearActiveSessionState gap (triage Suggestion 2 — locallyBoundPromptKeysRef and publishedPromptSettlementsRef are not cleared on session reset) is still present. Per the five-round convention, recorded and deferred for a follow-up.

Summary

No new Criticals. All 6 round-4 blockers verified fixed. The remaining open inline comments are Suggestion-level, appropriately deferred per convention after 5+ review rounds. The stale-base concern from the triage is resolved by the merge from main at 0eab4a8.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Historical-head review — head moved to 0eab4a848592f6ff8a939448f08fdea3f281e7ff while this review was in flight (past the salvage threshold), so the run finished and posted against the head it reviewed: 0be25ba5be79e7c54ea1b6a261cf5a88fdd0113c (#10110). The next automatic review covers the delta from that anchor. Full log in the workflow run.

中文说明

历史 head 评审 —— 本次评审进行中 head 移动到了 0eab4a848592f6ff8a939448f08fdea3f281e7ff(已过 salvage 阈值),因此评审跑完并针对其实际评审的 head 0be25ba5be79e7c54ea1b6a261cf5a88fdd0113c 发布(#10110)。下一次自动评审将从该锚点起评审增量。完整日志见 workflow 运行

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Resolved the three standing Critical findings in commit 190e770ad050:

  • Abandonment now enumerates the durable per-session admission ledger, so cancelled and queued prompts are not lost when activePromptsRef is empty.
  • Epoch-reset retirement runs after every fresh load, including empty replay snapshots, and only consumes the marker for the matching session.
  • A fresh load reporting hasActivePrompt: true remains authoritative and prevents a false failed settlement; terminal load failures can still retire the remembered session.

Deferred under the repository's >5-round convergence rule: provider-unmount lifetime semantics (R5-5), additional abandonment/insight/ownership/removal/live-journal fixture coverage (R5-4, R5-8, R5-7, R5-6, R5-9, R5-10, R4-1), and the heartbeat auth error-code taxonomy cleanup (R5-3). These are recorded here rather than expanding this PR again.

No local test, build, typecheck, or CI command was run per the requested workflow. The final diff was inspected and git diff --check is clean.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Blocking finding(s) follow.

Partially reviewed — gaps disclosed.

10 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • transcript read-timing unpinned in the projection suite (assistant-turn-settlement.test.tsx:91) — already reported (round-4 deferral test:91, review 5146822401; re-listed in the round-5 body)
  • SideTaskPanel/SubagentDetail provider surfaces publish to no subscriber (App.tsx:17799) — already reported (round-2 deferral SplitView:491, review 5136158773; round-4 deferral App:2978, review 5146822401)
  • publish-once dedup guard untested (DaemonSessionProvider.tsx:1118) — already reported (round-4 deferral DSP:1108, review 5146822401)
  • App/SplitView callback wiring untested (App.tsx:2979) — already reported (round-4 deferrals App:2978 and SplitView:491, review 5146822401)
  • the subagent-ownership test does not pin the parentToolCallId filter (assistant-turn-settlement.test.tsx:148) — already reported (round-4 deferral test:167, review 5146822401)
  • flushTranscriptSync pre-publish ordering and the live turn_complete arm unpinned (DaemonSessionProvider.tsx:3635) — already reported (round-4 deferrals DSP:3542 and DSP:3544, review 5146822401; open R1-6, comment 3949408393)
  • outcome-mapping arms untested (DaemonSessionProvider.tsx:5032) — already reported (round-5 body, review 5151330185, as the outcome-mapping arm DSP:3612; round-3 deferral DSP:4886, review 5140647872)
  • unbounded publishedPromptSettlementsRef ledger (DaemonSessionProvider.tsx:1119) — already reported (minor non-blocking note in review 5128055949; re-listed in the round-2 and round-3 bodies)
  • nothing pins that the live publish is NOT admission-gated (DaemonSessionProvider.test.tsx:10771) — already reported (round-4 deferral DSP:3544, review 5146822401)
  • always-latest-listener ref indirection unpinned (DaemonSessionProvider.tsx:5443) — already reported (round-5 deferral DSP:5400, review 5151330185)

Unresolved, please confirm:

  • [Critical] DaemonSessionProvider.tsx:2903 (ledger R3-1, comment 3965944374) — cannot tell whether the post-replay sweep publishes a false failed for a prompt still in flight. The client-side code fact is verified: the sweep gates only on the parked …

Not reviewed: reverse audit of the degraded/partial replay-snapshot trigger for the epoch-reset retirement (shouldInjectReplaySnapshot's dependence on replayDegraded / replaySnapshotComplete / replayError) — stopped at the agent tool budget.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this PR changes most.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": running packages/web-shell/client/App.test.tsx to settle by execution whether the added mockUseDaemonActivePromptBridge is load-bearing (real ChatPane.tsx:2…; "agent reverse-audit (round 1)": did not execute the six new tests ( npx vitest run client/daemon/session/DaemonSessionProvider.test.tsx ) — every claim above about what each test pins comes fr…; "agent reverse-audit (round 1)": did not trace whether actions.cancel() reaches removePendingPrompt → onPromptRemoved (actions.ts:2463) for a queued prompt; I verified only that cancel …; "agent reverse-audit (round 1)": did not examine shouldInjectReplaySnapshot 's dependence on replayDegraded / replaySnapshotComplete / replayError , so the degraded-or-partial-snapshot tr…; "agent reverse-audit (round 1)": did not measure whether the two duplicate payloads actually differ in message (argued from the pane's maxBlocks / subagentTranscriptMode props vs the outer …, and 8 more.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round; 2 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:1142 — [review] Critical [fails-closed] [new-surface] R5-2 both abandonment retirements — and the epoch-reset arming at :3296-3302 — resolve the victim through activePrompt…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:2911 — [review] Critical [fails-closed] [new-surface] R5-3 the epoch-reset retirement has one unkeyed one-shot slot consumed at a single conditional site and cleared uncondi…
  • docs/design/web-shell/web-shell-assistant-turn-settlement.md:1 — [review] The new design doc ships English-only, with no .zh-CN.md counterpart and no reciprocal language links below the title, which docs/design/README.md and AGENTS.md req…
  • docs/design/web-shell/web-shell-assistant-turn-settlement.md:27 — [review] The documented host correlation names a prompt id that no public submit surface exposes to a host that can also receive the settlement — the id is first delivered by…
  • docs/design/web-shell/web-shell-assistant-turn-settlement.md:40 — [review] The sentence recording the decision not to read promptTerminals overstates what that field guarantees — the ledger behind it is fail-closed, omits a verdict in sev…
  • packages/web-shell/client/App.tsx:1294 — [review] The host-facing contract advertises daemon-authoritative terminals only, but three published error.code values are client inferences and neither the prop JSDoc nor the README says so.
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:102 — [probe] The projection's only no-handler guard is untested, and the suite's helper makes that case structurally inexpressible.
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:154 — [probe] The parentToolCallId === undefined clause of the projection's ownership filter is unpinned, and the harm is SUPPRESSION of a correct answer rather than publication…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:240 — [review] The only test that reaches the message-level streaming guard produces exactly one rendered message, so the guard's deliberate return undefined is indistinguishabl…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:249 — [probe] The block-level streaming guard's per-prompt scoping is indistinguishable from a transcript-wide 'anything is streaming' guard, because the only foreign streaming fi…
  • packages/web-shell/client/assistant-turn-settlement.ts:49 — [review] Every settlement re-projects the entire retained transcript to messages and then discards all of it but one message, synchronously inside the publish microtask immediately…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:5145 — [probe] The new test's justification comment states a harm the guarded call cannot produce, and the guard's real invariant runs in the opposite direction.
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10771 — [review] The replay admission gate's per- (sessionId, promptId) precision is unpinned — the only negative test has an empty key set, so coarsening the gate to …
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:5033 — [review] The stopReason-to-outcome table under-counts the namespace it maps: refusal , max_tokens , max_turn_requests and length all publish to hosts as outcom…
  • packages/web-shell/client/daemon/session/actions.test.ts:509 — [review] The comment justifying the new removed guard states the inverse of the daemon's documented contract, and its second clause names a hazard the guard does not prevent.
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 10 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):reverse audit of the degraded/partial replay-snapshot trigger for the epoch-reset retirement (shouldInjectReplaySnapshot's dependence on replayDegraded / replaySnapshotComplete / replayError) — stopped at the agent tool budget.

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this PR changes most.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"running packages/web-shell/client/App.test.tsx to settle by execution whether the added mockUseDaemonActivePromptBridge is load-bearing (real ChatPane.tsx:2…"agent reverse-audit (round 1)"did not execute the six new tests ( npx vitest run client/daemon/session/DaemonSessionProvider.test.tsx ) — every claim above about what each test pins comes fr…"agent reverse-audit (round 1)"did not trace whether actions.cancel() reaches removePendingPrompt → onPromptRemoved (actions.ts:2463) for a queued prompt; I verified only that cancel …"agent reverse-audit (round 1)"did not examine shouldInjectReplaySnapshot 's dependence on replayDegraded / replaySnapshotComplete / replayError , so the degraded-or-partial-snapshot tr…"agent reverse-audit (round 1)"did not measure whether the two duplicate payloads actually differ in message (argued from the pane's maxBlocks / subagentTranscriptMode props vs the outer …,另有 8 条。

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改;其中 2 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 15 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.1)

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Historical-head review — head moved to 190e770ad050deec3a521b18c045ebdb5781194c while this review was in flight (past the salvage threshold), so the run finished and posted against the head it reviewed: 0eab4a848592f6ff8a939448f08fdea3f281e7ff (#10110). The next automatic review covers the delta from that anchor. Full log in the workflow run.

中文说明

历史 head 评审 —— 本次评审进行中 head 移动到了 190e770ad050deec3a521b18c045ebdb5781194c(已过 salvage 阈值),因此评审跑完并针对其实际评审的 head 0eab4a848592f6ff8a939448f08fdea3f281e7ff 发布(#10110)。下一次自动评审将从该锚点起评审增量。完整日志见 workflow 运行

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Review result: REQUEST_CHANGES. The callback wiring is directionally correct, but this head still has three correctness blockers. Scope/Ponytail notes will be summarized separately.

Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

6 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • stopReason-to-outcome mapping at packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:5010-5024 — already reported (round-6 deferral DSP:5033, review 5154409725; round-6 duplicate DSP:5032)
  • provider-side settlement behaviours unpinned — publish-once dedup guard, pre-publish flush ordering and the live turn_complete arm, and the live publish not being admission-gated — already reported (round-6 duplicates DSP:1118, DSP:3635 and…
  • App and SplitView callback wiring untested at packages/web-shell/client/App.tsx:2979 and :17792 and packages/web-shell/client/components/SplitView.tsx:628 — already reported (round-6 duplicate App.tsx:2979, review 5154409725; round-2 deferr…
  • transcript read-timing unpinned in the projection suite at packages/web-shell/client/assistant-turn-settlement.test.tsx:91 — already reported (round-6 duplicate test:91, review 5154409725; round-4 deferral, review 5146822401)
  • the cancelled-replay test re-inlining createResyncReplayFixture at packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10602 — already reported as open R4-1 (comment 3965944443)
  • design doc shipping English-only with no zh-CN sibling at docs/design/web-shell/web-shell-assistant-turn-settlement.md:1 — already reported (round-6 deferral doc:1, review 5154409725)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this PR changes most.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": whether two different prompts' top-level assistant text blocks can ever be adjacent in the retained block list (no user/tool/system block between them) — the on…; "agent reverse-audit (round 1)": tracing the prompt-removal / retained-tail paths in daemon/session/actions.ts and DaemonSessionProvider.tsx far enough to confirm (or rule out) that two dif…; "agent reverse-audit (round 1)": npm run typecheck / npm run lint over the new test file (only vitest run was executed, which strips types via esbuild); the three extensionless relative i…; "agent reverse-audit (round 1)": reading daemonToolBlockToToolCall to confirm the toolBlock fixture's omission of the required preview: DaemonToolPreview field does not route the tool thr…; chunk 3: did not mutation-probe the new does not record a stale-session removal… test (delete the sessionId === undefined term at DaemonSessionProvider.tsx:4582 and …, and 1 more.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Deferred under the convergence posture (round 7, not a blocker) — recorded, not requested in this round; 2 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:4573 — [probe] Critical [fails-closed] [new-surface] onPromptRemoved unbinds the replay admission key before the daemon's already-published terminal is delivered — three ent…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3619 — [probe] Critical [fails-closed] [new-surface] the repair-deferred live settlement has no fallback publisher, so a terminal the client observed live is never delivered…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:2898 — [probe] the replay-snapshot publish has no twin of the live path's repair gate, so it publishes a head-truncated message and burns the dedup key that would carry the …
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10535 — [probe] the !hasSessionActivePrompt() condition on the epoch-reset admission retirement is unpinned — mutating it to an unconditional delete leaves all 324 prov…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:81 — [probe] the block.promptId === promptId correlation predicate is unpinned — mutating it to true leaves all 7 tests green and publishes the previous turn's answer
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10828 — [probe] the error.message default-parity half is unpinned — mutating the 'Prompt failed' fallback leaves all 331 tests green while the code-default comparator i…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10482 — [probe] the admission key's survival across a session switch is claimed twice in this diff but witnessed nowhere — a one-line per-switch retirement leaves all 3…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10783 — [probe] the frame-provided error.code passthrough is unpinned — hardcoding 'turn_error' leaves all 331 tests green while the rejected promise still exposes the …
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10482 — [probe] the replay arm is witnessed for turn_complete only — narrowing the replay publish loop to turn_complete survives the entire workspace suite (294 files /…
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 6 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the file this PR changes most.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"whether two different prompts' top-level assistant text blocks can ever be adjacent in the retained block list (no user/tool/system block between them) — the on…"agent reverse-audit (round 1)"tracing the prompt-removal / retained-tail paths in daemon/session/actions.ts and DaemonSessionProvider.tsx far enough to confirm (or rule out) that two dif…"agent reverse-audit (round 1)"npm run typecheck / npm run lint over the new test file (only vitest run was executed, which strips types via esbuild); the three extensionless relative i…"agent reverse-audit (round 1)"reading daemonToolBlockToToolCall to confirm the toolBlock fixture's omission of the required preview: DaemonToolPreview field does not route the tool thr…;chunk 3:did not mutation-probe the new does not record a stale-session removal… test (delete the sessionId === undefined term at DaemonSessionProvider.tsx:4582 and …,另有 1 条。

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

收敛姿态下延后(第 7 轮,非阻断)——已记录,本轮不要求修改;其中 2 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 9 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
`getSettledAssistantMessage` accepted a projected message that merely
intersected the settled prompt's block ids. The render adapter merges
consecutive top-level assistant blocks without consulting `promptId`
(`adapters/transcriptToMessages.ts:603-620`), and a continuation carries no
user echo (`acp-bridge/src/bridge.ts:10582`), so two adjacent turns project to
ONE message that keeps the first block's id and concatenates both texts. Both
settlements then published that same message: turn B's answer attributed to
turn A under a shared message id, with the per-mount dedup key already burned
so no corrected callback could follow.

Require the prompt to own every block that went into the message, and treat a
missing or empty `sourceBlockIds` as not owned — `every` is vacuously true for
an empty array. Blocks the reducer never stamped with a `promptId` are skipped
rather than rejected on, since its merge predicate admits deltas when one side
omits it and backfills afterwards. A cross-turn merged message now settles
without `message`, matching the existing "not attributable" semantics instead
of publishing contaminated text.

Pinned in `assistant-turn-settlement.test.tsx` by two cases: adjacent prompts
with no user echo between them (red at the previous head, where prompt A
published `{id: 'assistant-1', content: 'The answer is 42.next turn text'}`),
and the same stream with a user echo, which must still settle each turn to its
own distinct message id so the ownership test does not over-reject.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtufhms2u4
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 49 passed · 4 failed · 53 total

Flakiness gate: ✅ 4 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:49 通过 · 4 失败 · 53 总计

抖动门:✅ 4 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 11251 — deep verification report (round 2, follow-up)

Verdict: findings — 49 passed / 4 failed / 53 scripted assertions executed.
Verified head 68df9b0e2db93f352fdc320923d74060c8bc89b6 (git rev-parse HEAD^2); base tip 2e212144d3d82f0eb95d2e137edf363eb9d338ef (HEAD^1).
The metadata snapshot's baseRefOid (558d7f22) has drifted from the merge-ref base actually checked out (2e212144); all measurements below are against the merge ref.

This is a follow-up round. Round 1 verified head 0be25ba5; three non-merge commits have landed since (190e770a, 125fd1ef, 68df9b0e), none of them individually reachable in this depth-2 checkout. Every carried-forward measurement below was re-run at the new head, not read back from the previous report.

Accounting rule (unchanged from round 1, so the two rounds are comparable): fail counts (a) behavioural checks whose observed outcome contradicts the PR's stated contract, and (b) mutation rows whose pinned test did not catch the revert — completeness findings, not PR defects. Gate greens and expected-red control cells count as passes. All four fails this round are mutation survivors (N2, N3, N6, N8). Raw per-check lines are in logs/; witnesses in evidence/.

中文摘要 — 判定:findings(49 通过 / 4 失败 / 53 断言)
  • 结论findings。本轮新增的核心修复(whole-message ownership)A/B 验证通过且 load-bearing:见「Central claim and A/B」表与 evidence/01-ownership-ab-head-vs-control.png。4 个 fail 全部是变异矩阵的存活行(完整性发现),不是核心主张失败。
  • A/B 结论:对照组(intersection 语义 = 上一轮 head)把同一条被合并的消息同时投递给 prompt A 与 prompt B(两者 message id 相同、内容都是 "The answer is 42.next turn text");head 组两个 prompt 都不投递被污染的消息。该形状由真实 reducer + 真实 adapter 产生,不是手写 fixture。
  • 上一轮 7 条发现的状态:见「Previous-finding status」表。F2/F3/F4/F5 已在新 head 上重新实测并复现;F6 被本轮测量推翻并重新分类;F1/F7 为当前源码直读确认(F1 的双 provider 运行时计数本轮未重跑,见 Not covered)。
  • 本轮新发现:G1 promptId === undefined 放宽重新放开了该提交本要关闭的污染路径,且无任何测试钉住任一方向(N3 存活 9/9)。其可达性已逐跳核实到源码:entry.activePromptId 在整个非测试 acp-bridge 中只有一个写入点bridge.ts:10546,即 session/prompt 派发),因此 Goal 运行时的 turnSession.ts:10396-10401 明确说明它不经过 session/prompt RPC 边界;bridgeClient.ts:2230-2242 只设 goalTurnActive)与 background notification turn 的 assistant 文本是永久无 promptId 的 —— 这是一条 live 路径,不只发生在 replay。G2 message 级 streaming 守卫是 load-bearing 但无测试覆盖(N6 存活 + probe V 证明其唯一防御地位);G3 空 sourceBlockIds 守卫未被钉住(判定为不可达的防御性代码);G4 continuation 的最后一轮现在完全不带 message(设计取舍,需宿主知晓)。
  • 未覆盖:27 个 commit 的逐 commit 归因(shallow 只可达 1 个);真实 daemon 端到端(G1 最后一环——真实 goal turn 的文本是否真的以被污染的 message 抵达宿主回调——未实测,是后续最有价值的一项);F1 的双 provider 运行时计数;prettier 门(上一轮即无法在容器内完成,本轮未重试)。

Previous-finding status

Round 1 filed F1–F7. Status at head 68df9b0e:

# finding (round 1) sev status at the new head evidence
F1 built-in Split View double-delivers one turn to a single host callback Medium stands (structure re-verified by reading current source; runtime count not re-driven — see Not covered) App.tsx:2979 installs useAssistantTurnSettlementProjection(onAssistantTurnSettled) unconditionally; SplitView.tsx:628-632 renders an AssistantTurnSettlementObserver inside each pane's own DaemonSessionProvider; App.tsx:17792 forwards the same callback to SplitView; dedup is a per-mount useRef Set (DaemonSessionProvider.tsx:1128, added at :1143, never cleared). None of the three delta commits touches these sites.
F2 non-success stop reasons reported as outcome:'completed' Medium stands — re-measured at runtime probe F2: turn_complete{stopReason:'refusal'}{"outcome":"completed","stopReason":"refusal"}. promptSettledFromTurnEvent (DaemonSessionProvider.tsx:5013-5022) still maps every stopReason except cancelled/error to completed.
F3 two implementations of "which prompt does this terminal belong to" disagree Low (latent) stands — re-measured; observable label corrected (see Corrections) probe F3: envelope-only promptIddelivered=1 submitResolved=false promptStatus="waiting". New path uses eventPromptId() (envelope then data); settleActivePromptFromTurnEvent (:5046-5047) still reads only event.data.promptId.
F4 failed outcome can carry no error detail; doc comment inaccurate Low (latent) stands — re-measured probe F4: turn_complete{stopReason:'error'}{"outcome":"failed","stopReason":"error"} with error absent. customization.tsx:237 still reads "Present for completed and cancelled turns".
F5 pre-publish flushTranscriptSync() pinned by nothing Low (completeness) stands — re-measured N8 (removing the flush at DaemonSessionProvider.tsx:3614) survived 324/324. Positive control in the same file and same suite (removing the replay-path publishPromptSettlement) turned 3 tests red, so the survivor is a real coverage statement, not a dead harness.
F6 the two streaming guards are layered defence, correct as-is Low (completeness) superseded — the layering inverted, and round 1's "no action needed" is now wrong N5 (block-level guard) is now KILLED by does not settle a streaming assistant block as the turn answer; N6 (message-level guard) now SURVIVES 9/9. But probe V proves N6 is not redundant defence: for an unstamped streaming sibling it is the sole defence. Reclassified as coverage gap → G2.
F7 dedup set grows without bound for the provider's lifetime Nit stands (static, not measured) publishedPromptSettlementsRef (:1128) is written at :1143 and has no clear site anywhere in the file.

Central claim and A/B

Central claim of the delta (68df9b0e): getSettledAssistantMessage must require the settled prompt to own every block that went into a projected message, so that two adjacent turns which the render adapter merges into one message can no longer both publish that message — while an ordinary turn separated by a user echo still settles to its own message.

Round 1 needed no base rebuild because the surface did not exist at its base. This round the change is a behavioural fix to an existing surface, so the control is the previous semantics. 0be25ba5 is unreachable at depth 2, so the control was reconstructed: make-control.mjs copies the real assistant-turn-settlement.ts and reverts exactly the ownership predicate to the intersection test the commit message says the previous head used ("accepted a projected message that merely intersected the settled prompt's block ids"). diff -u confirms one hunk differs; both arms then run in one vitest process. The control is therefore a faithful model of the previous head's predicate, not the previous head's literal bytes — named as a confound rather than hidden.

Crucially, the blocks are not hand-written fixtures: the harness drives the real SDK reducer (createDaemonTranscriptState + reduceDaemonTranscriptEvents) with real assistant.text.delta/assistant.done events, then the real adapter (transcriptBlocksToDaemonMessages), then the real projection. Only the SDK hooks (useConnection, useTranscriptStore, useDaemonPromptSettled) are stubbed — the unit under test and both its real collaborators are not.

Witness: 01-ownership-ab-head-vs-control.png (the cells below as they printed, including the reducer/adapter shape rows that make them non-vacuous).

cell build oracle result
shape real reducer + real adapter does the contaminated shape actually occur? yes — reducer keeps 2 blocks (assistant-2/prompt-A, assistant-3/prompt-B), no user block between, adapter merges to 1 message id=assistant-2, content="The answer is 42.next turn text", sourceBlockIds=[assistant-2,assistant-3]
control (intersection = prev head) reconstructed control message published for prompt A {id:'assistant-2', content:'The answer is 42.next turn text'} — B's text attributed to A
control reconstructed control message published for prompt B same id assistant-2, same contaminated content — one message handed to both prompts
head 68df9b0e message published for prompt A absent
head 68df9b0e message published for prompt B absent
head 68df9b0e ordinary turns with a user echo between A → {id:'assistant-2', content:'The answer is 42.'}, B → {id:'assistant-4', content:'next turn text'}distinct ids, no over-rejection
head vs control both single-prompt turn, A settled before B exists identical ('The answer is 42.') — the fix changes nothing while A is alone

26/26 checks pass in this harness (logs/ab-clean.log). The flip is 2 contaminated deliveries on the control → 0 on head, with the over-rejection guard holding (4 checks) and the single-turn path byte-identical between arms.

The two mechanisms the commit message cites were verified in real source rather than taken on trust: the adapter's merge predicate consults no promptId (adapters/transcriptToMessages.ts:605-620, merging content and unioning sourceBlockIds while keeping target.id), and a continuation carries no user echo (acp-bridge/src/bridge.ts:10581-10583, if (!isRetry && !isContinue && !isRestoreAskUserQuestion)). The reducer's own canMergeTextDelta (sdk-typescript/src/daemon/ui/transcript.ts:914-919) refuses to merge two differently stamped deltas — which is exactly why the hazard survives at block level and only appears at message level.

Mutation matrix

Witness: 02-mutation-matrix-with-positive-control.png. One row per guard, each reverting exactly one hunk (markers asserted unique before any write; files restored and verified byte-identical afterwards).

row guard reverted suite outcome killing test
N1 whole-message ownership → intersection settlement (9) KILLED does not publish one merged message for two adjacent promptsexactly the test the commit says it added, failing on the intended assertion (expected {…} to not have property "message", received content:"The answer is 42.next turn text")
N2 !message.sourceBlockIds?.length (empty-array every vacuity) settlement (9) SURVIVED 9/9 → G3
N3 || block.promptId === undefined widening in promptOwnedIds settlement (9) SURVIVED 9/9 G1
N4 whitespace-only message skip settlement (9) KILLED skips a whitespace-only assistant block after a tool boundary
N5 block-level streaming guard settlement (9) KILLED does not settle a streaming assistant block as the turn answer (round 1: survived)
N6 message-level if (message.isStreaming) return undefined settlement (9) SURVIVED 9/9 G2
N7 field-by-field projection → object spread settlement (9) KILLED publishes only the fields the host contract declares
N8 pre-publish flushTranscriptSync() (round 1's M5) provider (324) SURVIVED 324/324 → F5 stands
CTRL replay-path publishPromptSettlement removed provider (324) KILLED — 3 red positive control for N8, in the same file and same suite

4 killed / 4 survived. Positive controls exist in both mutated files (four kills in the settlement file, three in the provider file), so each survivor is a real coverage statement rather than a harness that never ran. N1's attribution is exact: the test that kills it is the one the commit says pins it, and it fails on the behavioural assertion, not on an import or compile break — the vacuity check on this round's central change passes.

Corrections

Two statements in the previous report are inaccurate at this head. These are corrections to that report, not requests to change the PR's code.

  1. F3's observable is promptStatus: "waiting", not "streaming". Round 1 reported promptStatus=streaming for the envelope-only-promptId divergence. Re-measured at 68df9b0e the status never advances past "waiting" (probe F3 | PAYLOAD | … promptStatusBefore="waiting" promptStatusAfter="waiting" submitResolved=false), where the liveness control shows an ordinary terminal moving "waiting""idle". The divergence itself is unchanged and if anything plainer: the host is told the turn settled while the submitter's promise never resolves and the UI still shows a pre-terminal state. My first probe run asserted round 1's literal label and went red; I corrected the check to assert the divergence rather than one round's wording, and report the corrected run.
  2. F6's classification "layered defence, correct as-is / no action needed" is wrong. The guards have swapped roles — the block-level guard is now the pinned one and the message-level guard is the unpinned one — and probe V shows the unpinned guard is the sole defence for a reachable shape, so it is a coverage gap with a concrete fixture to write, not redundant defence. See G2.

Findings

G1 — Medium: the promptId === undefined widening re-opens the contamination this commit closes, and nothing pins either direction

promptOwnedIds (assistant-turn-settlement.ts:57-63) admits any block with no promptId, on the stated ground that "the reducer's merge predicate admits deltas when one side omits promptId and backfills it afterwards, so an unstamped block is not evidence of a foreign turn." The backfill is real (sdk-typescript/src/daemon/ui/transcript.ts:836-840), but it only fires when a later delta for the same block carries a promptId. A block that never receives one is admitted forever — and the whole-ownership test then passes on a message that contains a foreign turn's text.

Measured with the real reducer → real adapter → real projection (logs/probe-v.log, logs/ab-clean.log):

V3 head   => {"sessionId":"session-1","promptId":"prompt-A","outcome":"completed","stopReason":"end_turn",
              "message":{"id":"assistant-2","content":"The answer is 42.continuation text","isStreaming":false,…}}
W  head A (unstamped sibling) => "The answer is 42.continuation text"

Prompt A publishes a message whose content is A's answer glued to a turn that is not A's, under A's message id — the same defect class the commit exists to close, entering through the unstamped door instead of the differently-stamped one.

Reachability, traced and verified line by line. A forwarded agent frame carries promptId only conditionally, and the condition has exactly one writer:

  • acp-bridge/src/bridgeClient.ts:1066-1071prepareSessionUpdateFrames builds turn = { ...(entry?.activePromptId ? { promptId: entry.activePromptId } : {}), … } and spreads it onto the forwarded session_update frame at :1132-1136 (the frame that carries agent_message_chunk). This is the only source of promptId on that envelope; nothing lifts one out of update._meta.
  • entry.activePromptId has exactly ONE write site in the whole non-test acp-bridge sourcebridge.ts:10546 (entry.activePromptId = pendingEntry.promptId;, inside the session/prompt dispatch) — and one delete, bridge.ts:3680 (settleActivePromptState, whose own doc comment says it is "Called from the prompt settle path, the echo-failure path, and the deadline path"). Verified by exhaustive grep, not by sampling: any assistant text the agent emits outside a session/prompt RPC is published unstamped.
  • sdk-typescript/src/daemon/ui/normalizer.ts:654-667createBase does ...(event.promptId ? { promptId: event.promptId } : {}) (line 667), so a falsy envelope promptId yields a normalized assistant.text.delta with no promptId, and createTextBlock then creates an unstamped top-level assistant block. This is the only producer of assistant.text.delta in the SDK (normalizer.ts:902-914), and it stamps nothing of its own — it spreads ...base.

A concrete live path, not only replay: Goal-runtime turns. packages/cli/src/acp-integration/session/Session.ts:10396-10401 states it outright — "Goal turns run inside this child via prompt() directly, so the daemon bridge never observes a session/prompt RPC boundary for them". #emitGoalStartTurn sends _qwencode/start_turn with source:'goal', and the bridge handles it at bridgeClient.ts:2230-2242 by setting entry.goalTurnActive = true and returning — it never sets activePromptId. Every agent_message_chunk of a goal turn therefore reaches prepareSessionUpdateFrames with turn = {}. The asymmetry is visible: the goal terminal does carry an id (Session.ts:10420), but it is the child-side getSessionId() + '########' + String(this.turn) namespace, not a daemon promptId, so it cannot backfill the blocks. Background-notification turns are unstamped too, and worse, they set _meta.qwenDiscreteMessage, which makes canMergeTextDelta return false unconditionally (transcript.ts:912-914) so each becomes a standalone top-level assistant block that no later merge can ever backfill.

The backfill cannot rescue any of this, because it fires only when a later delta for the same block carries a promptId (transcript.ts:836-840) — and in these paths no stamped delta ever arrives.

No test pins either direction. N3 — narrowing promptOwnedIds to stamped blocks only — survived 9/9. The author therefore has no fixture expressing which behaviour is intended, and a future change can flip it silently in either direction.

Bound — what this is not. I did not demonstrate an end-to-end host-visible contamination: I drove the projection with reducer-produced blocks, not a live daemon, and I did not boot a goal turn to watch a host payload. What is established is the full chain except its last link — a live, agent-initiated turn type whose assistant text is permanently unstamped (verified in source at every hop), plus a projection that admits unstamped blocks into a settled prompt's message and publishes the glued text (measured, V3/W1). For an ordinary session/prompt turn the live ordering does protect it: the next turn is dispatched after the prior terminal, so at A's publish time the later text does not yet exist (cell "head vs control, A settled before B exists" shows both arms identical there). The exposure is goal-runtime and background-notification turns, plus the replay/reconnect path where a whole history is committed before an admitted prompt's settlement publishes.

Suggested direction (not applied, not measured as a fix)

The comment's justification is about a block awaiting backfill, which is a block that is still open. A narrower rule that keeps the stated intent without admitting permanently-unstamped foreign text would admit an unstamped block only while it can still be backfilled — e.g. require block.streaming for the unstamped arm of the ownership predicate, so a settled unstamped sibling is treated as foreign. I did not apply or drive this, and it needs the author's judgment on whether an unstamped-but-finished block can ever legitimately belong to the settling prompt. Any change here should ship with the fixture N3 shows is missing: the same stream, asserted both ways.

G2 — Low: the message-level streaming guard is load-bearing for a shape the suite does not contain

N6 (removing if (message.isStreaming) return undefined;) survived 9/9, which reads as redundancy — round 1 classified its sibling that way. It is not. Probe V drives the shape the existing streaming test (does not settle a merged message that absorbed a streaming block from another prompt) cannot reach, because that test's foreign block is stamped and the new ownership rule now rejects the message before the streaming guard is consulted. Witness: 03-n6-reachability-and-round1-recheck.png — the top half is this probe as it printed, the bottom half is the F2/F3/F4 re-measurement through the real provider:

V1 blocks: {"id":"assistant-2","promptId":"prompt-A","text":"The answer is 42.","streaming":false}
           {"id":"assistant-3","text":"still typing","streaming":true}          <- unstamped, still open
V1 merged message: {"id":"assistant-2","content":"The answer is 42.still typing","isStreaming":true,
                    "sourceBlockIds":["assistant-2","assistant-3"]}
V2 head   => {"sessionId":"session-1","promptId":"prompt-A","outcome":"completed","stopReason":"end_turn"}
V2 N6-mut => {…"message":{"id":"assistant-2","content":"The answer is 42.still typing","isStreaming":true,…}}

10/10 checks pass (logs/probe-v.log). With the guard removed, a host receives message.isStreaming === true and text from a turn that is not over, for a prompt reported as completed. The block-level guard cannot catch this: the streaming sibling is not in promptBlockIds (it has no promptId), so only the message-level guard fires. Classification: coverage gap — the behaviour is right, nothing asserts it. The fixture to add is exactly V1: a settled prompt plus an unstamped still-streaming sibling, asserting no message.

Note the interaction with G1: the same widening that causes G1 is what makes this shape reachable, and it is the message-level guard that currently contains it. Fixing G1 by narrowing the widening would not remove the need for G2's test.

G3 — Nit: the empty/missing sourceBlockIds guard is unpinned and unreachable through the real adapter

N2 survived 9/9. Unlike N3/N6 this one looks like genuinely defensive code rather than a gap: the module always calls transcriptBlocksToDaemonMessages(blocks, { includeSourceIdentity: true }), and every assistant-message construction site in the adapter populates a non-empty sourceBlockIds (adapters/transcriptToMessages.ts:561, :572, :596, :619, :634). I did not find a path that yields an assistant message with an empty or absent sourceBlockIds, so I classify this as redundant defence, correct as it stands — the every-on-empty-array trap the comment names is real in principle and closed at no cost. No action needed; reported only so the survivor is not read as an unexamined gap. My N2 mutation was written to isolate the empty-array case alone (undefined still rejects via optional chaining), so the survivor is not an artifact of a crashing mutant.

G4 — Low (informational): the last turn of a no-echo continuation now settles with no message at all

This is the fix working as designed, and it is strictly better than the control, but it is a behaviour change hosts will observe and the PR body does not mention it. Measured in the realistic ordering (logs/ab-clean.log, section S):

S head  A(at A settle time) => …"message":{"id":"assistant-2","content":"The answer is 42."}
S ctrl  A(at A settle time) => …identical…
S head  B(after merge)      => {"sessionId":"session-1","promptId":"prompt-B","outcome":"completed","stopReason":"end_turn"}
S ctrl  B(after merge)      => …"message":{"id":"assistant-2","content":"The answer is 42.next turn text"}
S TRADEOFF | head B message = null | control B message = {"id":"assistant-2","content":"The answer is 42.next turn text",…}

Turn A is unaffected — both arms agree byte-for-byte while A is alone. Turn B trades contaminated text for no text: outcome/stopReason/identity still arrive, but a host that renders message shows nothing for that turn. The design doc frames message as optional exactly for this ("bounded transcript retention, partial history, cancellation, and failure can legitimately leave no retained assistant text"), so this is consistent with the stated contract — worth a line in the PR body so a host author does not read a missing message on a continuation as a bug.

Carried findings still open

F1 (Medium, Split View double-delivery), F2 (Medium, non-success stop reasons as completed), F3 (Low latent, two promptId readers), F4 (Low latent, failed without error detail + inaccurate doc comment), F5 (Low, unpinned flush), F7 (Nit, unbounded dedup set) — all re-measured or re-verified as described in the status table. None was addressed by the three delta commits. F2 deserves one added note at this head: because the whole-ownership rule now withholds message in more cases (G4), a host's only signal for a refused turn is stopReason, which makes F2's "read stopReason, don't trust outcome" mitigation more load-bearing than it was in round 1.

Not covered

  • F1's runtime delivery count was not re-driven. Round 1 measured 2 deliveries with a two-provider probe. This round I re-verified the structure by reading the current source at all four sites (listed in the status table) and confirmed the delta commits touch none of them, but I did not rebuild the two-provider harness. renderWithProvider in the PR's own test file binds module-level container/root, so a faithful two-provider mount needs separate roots; I spent the remaining budget on G1/G2 instead. F1's status is therefore "stands on structure", not "stands on a re-run count".
  • Per-commit attribution. The snapshot lists 27 commits; the shallow merge-ref checkout reaches 1 (git rev-list HEAD^1..HEAD^2 = 1, which at a shallow boundary reads as plausible rather than erroring). The three commits since round 1 (190e770a, 125fd1ef, 68df9b0e) could not be exercised individually — 0be25ba5 is not present (git cat-file -t fails). All measurements are on the aggregate HEAD^1..HEAD diff, so I cannot say which of the three introduced or fixed what.
  • Real-daemon end-to-end. No daemon was booted. The reducer and adapter are real and drove real code paths, but the frames feeding the provider probes were constructed from the shapes the real producers emit rather than captured off a live socket. G1's reachability argument is a source trace, not a witnessed payload.
  • The real SplitView / App JSX composition — unchanged from round 1: both App.test.tsx and SplitView.test.tsx mock DaemonSessionProvider, so no existing harness composes the real providers.
  • Reconnect catch-up and epoch-reset delivery were verified only through the PR's own tests (green, and 3 of them killed the positive-control mutation), not independently re-driven.
  • The prettier gate was not attempted. Round 1 reported it could not complete within 240 s in this container; I did not retry, and I do not imply it passed. eslint and typecheck were run and are reported below.
  • A subagent trace I commissioned returned two different results, and I treated both as untrusted until checked. Its first result cited a nextPendingPrompt function, a promptId = '' assignment, an enterPrompt function and a pinning test at bridge.test.ts:11560 — none of which exist (all four greps returned nothing), so I discarded it entirely and re-derived the reachability myself from bridgeClient.ts:1066-1071, bridge.ts:3680 and normalizer.ts:667. Its second result agreed with those independently-derived lines and added the goal-runtime and background-notification paths; I verified each of those new claims directly before using them (the exhaustive activePromptId write-site grep, bridgeClient.ts:2230-2242, Session.ts:10396-10401, transcript.ts:912-914) and only the verified ones appear in G1. Recorded because a reader comparing rounds may find the discarded first trace in the transcript: nothing in this report rests on it, and two of its scenarios that I could not verify (an empty-prompt continuation assigning promptId = '', and post-terminal stragglers) are deliberately not claimed here — the second report itself flagged the straggler case as "structurally possible, not demonstrated".
  • I did not boot a goal turn or a background notification. G1's live path is a source-level chain, verified hop by hop, plus a projection-level measurement on reducer-produced blocks. The unmeasured link is the last one: a real goal turn's assistant text reaching a real host callback as a contaminated message. That needs a running daemon and is the single highest-value follow-up measurement.
  • getSettledAssistantMessage's interaction with bounded transcript retention (an evicted block leaving a partial sourceBlockIds) was not probed.

Targeted gates

gate command result
PR's touched test files cd packages/web-shell && npx vitest run --config vitest.config.ts client/assistant-turn-settlement.test.tsx client/daemon/session/DaemonSessionProvider.test.tsx client/daemon/session/actions.test.ts client/App.test.tsx 1354 passed / 0 failed (4 files, EXIT=0) — round 1 measured 1310, so +44
typecheck (clean tree, no scratch files) cd packages/web-shell && npm run typecheck clean (EXIT=0)
eslint, 8 changed production files npx eslint <files> clean (EXIT=0)
eslint liveness control planted const zzLivenessViolation = 42; into assistant-turn-settlement.ts EXIT=1, 'zzLivenessViolation' is assigned a value but never used (@typescript-eslint/no-unused-vars); after restore EXIT=0 and git diff empty — the green gate above is a measurement, not an assumption
full workspace suite not run this round; round 1's clean number (289 files / 6728 tests) is not carried forward, because the head changed and the input closure is not proven identical

An earlier typecheck run in this round executed while my reconstructed control file was present in client/ (the package's include is client/**/*.ts, excluding only *.test.ts(x)), so it type-checked a near-copy of a PR file alongside the real one. The authoritative number above is the re-run after every scratch file was removed; git status --porcelain and git diff --stat HEAD were both empty at that point.

Methodology

Environment: CI verify container at the merge ref (HEAD = 33b711d5, HEAD^1 = base tip 2e212144, HEAD^2 = PR head 68df9b0e), npm ci + npm run build pre-run, no GitHub token, no network. Four harnesses, all kept in this artifact dir so a maintainer can rerun them: make-control.mjs (generates the intersection-semantics control from the real file, asserting the marker is unique and diffing to one hunk), zz-verify-ownership.test.tsx (26 checks — real reducer → real adapter → real projection, both arms in one process, plus the realistic-ordering and sibling-sweep sections), make-nostream-control.mjs + zz-verify-nostream.test.tsx (10 checks deciding N6's classification), and inject-provider-probe.mjs + provider-probe-snippet.tsx.txt (4 checks — F2/F3/F4 re-measured through the real DaemonSessionProvider by injecting into a copy of the PR's own test file so the probes inherit its real provider mount and SDK-transport mock; both injection anchors, lines 581 and 20178, are asserted before writing). mutation-matrix.mjs runs the 8 rows, asserting marker uniqueness, restoring from backup, and verifying the restore byte-identical. Every check prints one CHECK | PASS|FAIL | line so the counts in assertions.json are mechanical rather than judged. Raw logs: logs/ab-clean.log, logs/probe-v.log, logs/probe-provider.log, logs/mutation-matrix.log, logs/mutation-provider-control.log, logs/matrix-summary.log, logs/finding-recheck.log, logs/gate-changed-files.log, logs/typecheck-clean.log, logs/eslint-clean.log, logs/eslint-liveness.log. All scratch files were removed from the working tree afterwards and the tree verified clean; two mutation runs aborted on a non-unique/incorrect marker rather than writing, and one provider file was restored from backup with git diff --stat confirming no residue.

Flakiness gate log

rounds=5 files=4 skipped=0
file packages/web-shell/client/App.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/App.test.tsx
file packages/web-shell/client/assistant-turn-settlement.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/assistant-turn-settlement.test.tsx
file packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/daemon/session/DaemonSessionProvider.test.tsx
file packages/web-shell/client/daemon/session/actions.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/daemon/session/actions.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/web-shell/client/App.test.tsx: PPPPP
  packages/web-shell/client/assistant-turn-settlement.test.tsx: PPPPP
  packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: PPPPP
  packages/web-shell/client/daemon/session/actions.test.ts: PPPPP

verdict: pass
summary: 4 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 2 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 3 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 4 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 5 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)

Evidence images

01-ownership-ab-head-vs-control

02-mutation-matrix-with-positive-control

03-n6-reachability-and-round1-recheck

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R8-16 App/SplitView callback forwarding untested — already reported (round-4 deferrals App.tsx:2978 and SplitView.tsx:491, review 5146822401; round-6 duplicate App.tsx:2979, review 5154409725)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which imports the module this round changes.

Not reviewed: re-check of the six earlier-round DaemonSessionProvider.tsx blockers (R1-2, R2-1, R3-1 twice, R5-1, R5-2) — that file is outside this round's incremental scope (125fd1e..68df9b0 touches only assistant-turn-settlement.ts and its test), and round 7's posted ledger carried only R7-1, so those threads were retired by earlier rounds rather than re-ruled here.

Not explored to full depth (tool budget reached): "agent 6b": empirical confirmation of the finding — running packages/web-shell/client/assistant-turn-settlement.test.tsx with an added earlier-unstamped-turn fixture to w…; "agent reverse-audit (round 2)": did not verify whether the daemon's record-derived replay events carry an envelope promptId ( normalizer.ts:667 reads only event.promptId ; transcript.ts:8…; "agent reverse-audit (round 2)": did not compare the SplitView pane provider's props ( SplitView.tsx:605-627 ) against WorkspaceSessionProvider.tsx:324-345 ( maxBlocks , subagentTranscriptMo…; "agent 1c": full npm run typecheck for packages/web-shell (type seams verified by reading the declarations instead); "agent 1c": executing the observer inside a real DaemonSessionProvider pane — the new test mocks useDaemonPromptSettled , so the SplitView pane path is verified only by …, and 2 more.

Deferred under the convergence posture (round 8, not a blocker) — recorded, not requested in this round:

  • packages/web-shell/client/assistant-turn-settlement.test.tsx:256 — [probe] the message-level streaming guard (assistant-turn-settlement.ts:91) lost its only witness this round — deleting it leaves all nine tests green
  • packages/web-shell/client/assistant-turn-settlement.ts:52 — [probe] the promptId === undefined half of the ownership widening is exercised by no test, so narrowing it to strict equality survives CI
  • docs/design/web-shell/web-shell-assistant-turn-settlement.md:41 — [review] the design doc's exhaustive absence list does not cover the new cross-turn-merge omission, so doc and code now disagree about a public contract
  • packages/web-shell/client/App.tsx:1297 — [probe] the new dedupe advice does not say which duplicate wins, and two providers can differ on whether message is present
  • packages/web-shell/client/assistant-turn-settlement.ts:90 — [probe] the final-answer rule is re-derived with a different boundary from MessageList's findFinalAnswerIndex , and the divergence is undocumented

Convergence: round 8 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/web-shell/client/assistant-turn-settlement.ts (findings in round 7; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (1 Critical(s)), the rate of first-time findings is not falling (this round 1, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which imports the module this round changes.

未审查(原文为英文):re-check of the six earlier-round DaemonSessionProvider.tsx blockers (R1-2, R2-1, R3-1 twice, R5-1, R5-2) — that file is outside this round's incremental scope (125fd1e..68df9b0 touches only assistant-turn-settlement.ts and its test), and round 7's posted ledger carried only R7-1, so those threads were retired by earlier rounds rather than re-ruled here.

未探索到全部深度(达到工具调用预算):"agent 6b"empirical confirmation of the finding — running packages/web-shell/client/assistant-turn-settlement.test.tsx with an added earlier-unstamped-turn fixture to w…"agent reverse-audit (round 2)"did not verify whether the daemon's record-derived replay events carry an envelope promptId ( normalizer.ts:667 reads only event.promptId ; transcript.ts:8…"agent reverse-audit (round 2)"did not compare the SplitView pane provider's props ( SplitView.tsx:605-627 ) against WorkspaceSessionProvider.tsx:324-345 ( maxBlocks , subagentTranscriptMo…"agent 1c"full npm run typecheck for packages/web-shell (type seams verified by reading the declarations instead)"agent 1c"executing the observer inside a real DaemonSessionProvider pane — the new test mocks useDaemonPromptSettled , so the SplitView pane path is verified only by …,另有 2 条。

收敛姿态下延后(第 8 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 8 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/assistant-turn-settlement.ts(第 7 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 1 条 Critical),首次发现的速率没有下降(本轮 1,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
`promptOwnedIds` admitted any block with no `promptId` on the ground that
the reducer backfills a missing stamp from a later delta. That backfill
only fires while a later delta for the same block still arrives, so a
block that finished unstamped was admitted forever: goal-runtime and
background-notification turns never cross the `session/prompt` boundary
that sets `entry.activePromptId`, so their permanently unstamped text was
glued onto the settling prompt's message id — the same contamination the
whole-message ownership rule exists to stop, through the unstamped door.

Narrow the unstamped arm to blocks that can still be backfilled
(`streaming`), and keep non-assistant blocks admitted because the reducer
never stamps them. Add the two fixtures the mutation matrix showed were
missing: a finished unstamped sibling (kills the widening revert) and an
unstamped still-streaming sibling, the only shape that reaches the
message-level `isStreaming` guard (kills that guard's removal).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtuo29vduf
@yiliang114

Copy link
Copy Markdown
Collaborator Author

本轮收尾(基于 head 68df9b0e 的 bot 沙箱验证报告,run 34398112356:49 passed / 4 failed)。4 个 fail 全是 mutation survivor,其中 G1 是真实缺陷。0 个 unresolved thread,所以逐项结论集中记在这里。

修复已推:68df9b0e..a959c108(+61/-6,2 文件),PR 总 additions 1437 → 1492

G1(Medium,真实缺陷)— 已修

promptOwnedIds 原来无条件接纳任何没有 promptId 的 block,理由是 reducer 会 backfill。但 backfill 只在「同一个 block 后续还有带 promptId 的 delta」时才触发,一个已经结束且始终未打戳的 block 会被永久接纳:goal-runtime / background-notification turn 不跨 session/prompt 边界(entry.activePromptId 全仓只有一个写点),它们的文本会被粘到正在结算的 prompt 的 message id 上 —— 正是 whole-message ownership 要挡的污染,只是从「未打戳」这道门进来。

改法(client/assistant-turn-settlement.ts):未打戳这一支收窄到「还可能被 backfill」的 block,即 block.streaming === true;非 assistant block 保持接纳(reducer 从不给 tool/user block 打戳,否则会打穿所有 tool boundary 用例)。没有引入新字段、新机制,沿用原有 carrier。

证据:mutation 自检 —— 把谓词回退成原来的宽版本,新增用例 treats a finished unstamped sibling as a foreign turn 立刻红(expected {…} to not have property "message"),即报告里的 N3 由 SURVIVED 9/9 变成 KILLED。原有 9 个用例全绿,未削弱任何断言。

G2(coverage gap)— 已修

补上报告 probe V 的那个形状:已结算 prompt + 未打戳但仍在 streaming 的兄弟 block。这个形状是 message 级 if (message.isStreaming) return undefined唯一触发路径(兄弟 block 不在 promptBlockIds 里,block 级守卫打不到),而现有 streaming 用例的兄弟 block 是打了戳的,会被 ownership 规则先挡掉,所以够不着这道守卫。

证据:mutation 自检 —— 删掉该守卫后,只有新增的 does not settle a merged message that absorbed an unstamped streaming block 变红(1 failed / 10 passed),即 N6 由 SURVIVED 9/9 变成 KILLED,且证实此前没有任何用例覆盖它。

注意 G1 的收窄并没有让 G2 的用例失效:未打戳但 streaming 的 block 仍被接纳,所以 ownership 通过、由 message 级守卫兜住 —— 两个守卫的分工保持报告描述的形态,没有加第三套并行机制。

F2(Medium)— 本轮不改,需人决策

promptSettledFromTurnEvent 把除 cancelled/error 之外的所有 stopReason 都映射成 outcome:'completed'(probe 实测 refusalcompleted)。这是新导出的 host 契约的语义,改它属于契约语义决策,不适合在收尾轮里替作者定:

  • 本 PR 的设计文档 Contract 一节只列了「completedcancelledfailed」和「the daemon stop reason when present」,没有定义 stopReason → outcome 的映射;stopReason?: string 是开放字符串,任何白/黑名单都会对未来的新 stopReason 静默误判。
  • 仓内先例本身不一致:packages/qwen-live/src/adaptor/acp-adaptor.ts:481refusal/max_tokens/max_turn_requests 当 error,而 ACP 语义下它们是「正常终止但 stopReason 非 end_turn」。
  • 翻成 failed 还会和设计文档的「error details for failed prompts」冲突:产出「outcome:'failed' 但没有 error」(F4 已实测 stopReason:'error' 就是这个形状),要改干净就得同时定 error 的合成规则和 customization.tsx 的契约注释,跨 3 处且改公开 API 语义。

建议作者定调:要么明确「outcome 只表示终止类别,成功与否读 stopReason」并把这句写进设计文档 + 契约注释,要么给出 stopReason → outcome 的封闭映射。

N8 / F5(Low,coverage gap)— 本轮不改,原因是行数预算

结算前那次 flushTranscriptSync() 在 provider 套件 324/324 里无人 pin(同文件同套件的 positive control 是红的,所以是真实的覆盖陈述)。要 pin 它必须在 DaemonSessionProvider.test.tsx 里搭一个「缓冲 delta 未 flush + turn_complete」的 provider 级用例,约 40 行;而 G1+G2 两个必修项已把 additions 从 1437 推到 1492,距 1500 行的 scope fuse 只剩 8 行。因此本轮把它留作已知缺口,不为了闭合而撑大 diff。

G3(Nit)— 拒绝,附证据

报告自己判定这道守卫不可达(防御性代码);我在树里复核一致:本模块恒以 includeSourceIdentity: true 调用适配器,而 adapters/transcriptToMessages.ts 里每一处 assistant message 构造点(:561/:572/:596/:619/:634 等)都填了非空 sourceBlockIds,找不到产出空/缺失的路径。按 YAGNI 这属于「假设性边界」,不加用例、不改代码;every 对空数组恒真的陷阱注释保留即可。

G4 / F1 / F3 — 只记录状态,不在本轮范围

  • G4(设计权衡,非缺陷):no-echo continuation 的最后一个 turn 现在结算时完全不带 message(换来的是不再投递污染文本)。与设计文档「message 可选」的表述一致,但 host 作者会观察到,建议在 PR 正文补一句,避免被当成 bug。
  • F1(Medium,Split View 双投递):结构性问题,横跨 App.tsx:2979SplitView.tsx:628-632App.tsx:17792DaemonSessionProvider.tsx:1128/1143(每 mount 一个永不清理的 useRef Set),且早于本 PR 的 delta commits。超出单发现 ≤3 文件的处理上限,需单独一轮。
  • F3(Low/latent):两处「这个 terminal 属于哪个 prompt」的实现不一致 —— 新路径用 eventPromptId()(先 envelope 后 data),settleActivePromptFromTurnEvent 仍只读 event.data.promptId。修正后的可观察量是 promptStatus:"waiting"(不是 round 1 说的 streaming),host 会被告知 turn 已结算而 submitter 的 promise 从未 resolve。涉及 submit 解析路径,不在本轮范围。

本机验证(实测,未跑的明确说明)

  • cd packages/web-shell && npx vitest run client/assistant-turn-settlement.test.tsx client/daemon/session/actions.test.ts200 passed / 0 failed(settlement 由 9 → 11 用例)。
  • npx vitest run client/daemon/session/DaemonSessionProvider.test.tsx324 passed / 0 failed(与报告一致)。
  • 上述两次 mutation 自检(N3 / N6)各跑一遍,结果如 G1/G2 所述;mutant 由备份还原,git diff --numstat 确认只含本次两文件改动。
  • npx eslint 两个改动文件 → exit 0,无输出npx prettier --check 两个文件 → All matched files use Prettier code style(这道 gate 两轮验证都没能跑起来)。
  • client/App.test.tsx 未跑成:本机 node_modules 缺 @datafe-open/markdown-chart,collect 阶段就报 Failed to resolve importMarkdown.tsx:21),与本 PR 无关。
  • typecheck 受环境限制tsc -p tsconfig.json --noEmit 共 21 个错,全部是缺 shiki / @datafe-open/* 引发的 TS2307 级联(Markdown.tsxMarkdownChartRenderer.tsxcodeHighlighter.tsGitDiffDialog.tsx),本 PR 的 14 个文件里 0 个错
  • 未跑:仓库全量套件、packages/cli 测试、真实 daemon 端到端(报告点名 G1 的最后一跳——真实 goal turn 的文本是否真的以污染 message 到达 host callback——仍未被任何一轮实测,是最高价值的后续验证)。
commit a959c10888b0b269d975b8057f130c666f2e7e38
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtuo29vduf

CHANGES_REQUESTED 仍是 stale(bot 的 CR 在 125fd1ef,晚于它的两次 push 已把 G1 修掉)。本轮未触发 /triage、未触发 /review、未重新指派 reviewer。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

5 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • App/SplitView callback forwarding untested — already reported (R8-16; round-4 deferrals App.tsx:2978 and SplitView.tsx:491, review 5146822401; round-6 duplicate App.tsx:2979, review 5154409725)
  • Duplicate settlement publication from the App-level and pane providers, and which copy wins — already reported (R1-19 comment 3949408430 at SplitView.tsx:630; R3-7 comment 3957009247 at App.tsx:1296; round-8 deferral at App.tsx:1297)
  • Whole-transcript re-projection cost per settlement — already reported (R3-5 comment 3957009228 at assistant-turn-settlement.ts:49)
  • Design doc's exhaustive absence list does not cover the new cross-turn-merge omission — already reported (round-8 deferral at docs/design/web-shell/web-shell-assistant-turn-settlement.md:41)
  • The prompt-ownership / streaming-admission guards have no test witness — already reported (R2-3 comment 3953370589; R5-7 comment 3965944422 at assistant-turn-settlement.test.tsx:268; round-8 deferral at assistant-turn-settlement.ts:52)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which imports the module this round changes.

Not reviewed: re-check of the earlier-round DaemonSessionProvider.tsx blockers (R1-2, R2-1, R3-1 twice, R5-1, R5-2) and the three open @yiliang114 [P1] threads on that file — it is outside this round's incremental scope (125fd1e..a959c10 touches only assistant-turn-settlement.ts, its test, and the App.tsx / SplitView.tsx seams), so those threads were retired or left standing by earlier rounds rather than re-ruled here.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": whether goal-runtime assistant chunks carry qwenDiscreteMessage in _meta — the one fact that would close or confirm finding 1's remaining door (background n…; "agent reverse-audit (round 1)": I did not execute the shell-prompt observer path — finding 2 is static tracing through DaemonSessionProvider.tsx:3439/3466/3556-3620 , actions.ts:2996-3011 , …; "agent reverse-audit (round 1)": I did not verify whether a replayed settlement for a non-final prompt is projected against later turns' blocks — I confirmed only that the replay store is popul…; "agent reverse-audit (round 1)": I did not open docs/design/web-shell/web-shell-assistant-turn-settlement.md ; I only listed the directory (no .zh-CN.md counterpart exists there for any web-…; "agent 1c": the exact commit ordering of connection.sessionId in the provider's attach flow relative to a replay-path publishPromptSettlement — I confirmed the error-pa….

Deferred under the convergence posture (round 9, not a blocker) — recorded, not requested in this round; 1 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/web-shell/client/assistant-turn-settlement.ts:42 — [probe] Critical [fails-closed] [new-surface] a reachable terminal path publishes a completed settlement while the prompt's own final block is still streaming, permanently dropping…
  • packages/web-shell/client/assistant-turn-settlement.ts:152 — [review] no test pins that the transcript is read when the settlement fires rather than at mount time — the hoisted-read mutant survives all 11 tests
  • packages/web-shell/client/assistant-turn-settlement.ts:43 — [review] promptBlockIds is consulted only inside the guard that built it, with one always-true kind term; dropping it widens the streaming guard to every assistant block in the tra…
  • packages/web-shell/client/assistant-turn-settlement.ts:67 — [probe] the non-assistant half of the ownership admission is inert, and the comment justifying it at :59-60 states a false premise about the reducer
  • packages/web-shell/client/App.tsx:1296 — [probe] the new prop doc omits that a terminal published while no callback is attached is never re-delivered, so a host toggling the callback permanently misses those turns

Convergence: round 9 posted 2 inline comment(s), 1 of them reported for the first time; the previous round posted 1 (1 new). The rate of new findings is not falling. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (2 Critical(s)), the rate of first-time findings is not falling (this round 1, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 5 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which imports the module this round changes.

未审查(原文为英文):re-check of the earlier-round DaemonSessionProvider.tsx blockers (R1-2, R2-1, R3-1 twice, R5-1, R5-2) and the three open @yiliang114 [P1] threads on that file — it is outside this round's incremental scope (125fd1e..a959c10 touches only assistant-turn-settlement.ts, its test, and the App.tsx / SplitView.tsx seams), so those threads were retired or left standing by earlier rounds rather than re-ruled here.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"whether goal-runtime assistant chunks carry qwenDiscreteMessage in _meta — the one fact that would close or confirm finding 1's remaining door (background n…"agent reverse-audit (round 1)"I did not execute the shell-prompt observer path — finding 2 is static tracing through DaemonSessionProvider.tsx:3439/3466/3556-3620 , actions.ts:2996-3011 , …"agent reverse-audit (round 1)"I did not verify whether a replayed settlement for a non-final prompt is projected against later turns' blocks — I confirmed only that the replay store is popul…"agent reverse-audit (round 1)"I did not open docs/design/web-shell/web-shell-assistant-turn-settlement.md ; I only listed the directory (no .zh-CN.md counterpart exists there for any web-…"agent 1c"the exact commit ordering of connection.sessionId in the provider's attach flow relative to a replay-path publishPromptSettlement — I confirmed the error-pa…

收敛姿态下延后(第 9 轮,非阻断)——已记录,本轮不要求修改;其中 1 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 5 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 9 轮发布了 2 条行内评论,其中 1 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。新发现的产出速度没有下降。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 2 条 Critical),首次发现的速率没有下降(本轮 1,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
Comment thread packages/web-shell/client/assistant-turn-settlement.test.tsx Outdated
yiliang114 and others added 2 commits September 10, 2026 16:04
`getSettledAssistantMessage` answered "which assistant message closes this
prompt" by re-running the render adapter over the whole transcript and
reconstructing ownership from `sourceBlockIds` — necessary because the
adapter drops `promptId`, and it merges consecutive top-level assistant
blocks without consulting it. Attribution inferred from a projection that
was never designed to carry it made every block shape this module did not
hand-model a way to publish a foreign turn's text, an earlier non-final
message of this turn, or nothing at all, under a `(sessionId, promptId)`
key that `publishPromptSettlement` burns before the listener runs.

Select on the blocks instead, with the exclusion terms the SDK already
uses for this exact question (`findFinalVisibleAssistantForPrompt`,
sdk-typescript `daemon/ui/transcript.ts`): top-level, stamped with this
prompt, non-empty text. Streaming stays "not yet settled" rather than
"keep looking", so partial text is never published. Kept local rather
than imported: reaching that helper through `@qwen-code/sdk/daemon` needs
a new public export threaded through three barrel files, and the
predicate is five lines.

Ownership is now identity, so the ownership set, the whole-message
`every` test, the cross-turn merge question, the unstamped-sibling
question and the insight-segment `isStreaming` gap all go away together
rather than one guard at a time. A `completed` turn whose own final text
is retained, top-level and stamped now always publishes it, which is what
issue #10389 asks for; the design doc's absence list (retention, partial
history, cancellation, failure) is unchanged and still exhaustive.

The four tests that pinned the omission are flipped to assert message id
and exact content, so reverting to the adapter-plus-`every` form goes red
on all four. Net -17 lines.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtv7cpvvv6
Resolve the web-shell README props-table conflict: keep main's brand and
onBrandResolved rows (#11244) and append this PR's onAssistantTurnSettled
row, then let prettier re-pad the table.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtva7llqva

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

6 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • App/SplitView callback forwarding untested — already reported (R8-16; round-4 deferrals at App.tsx:2978 and SplitView.tsx:491, review 5146822401; round-6 duplicate at App.tsx:2979, review 5154409725; round-9 duplicate list, review 516221988…
  • serverTimestamp precedence in the published message timestamp untested — already reported (R2-3 comment 3953370589; R5-7 comment 3965944422 at assistant-turn-settlement.test.tsx:267)
  • cancelled-replay test re-inlines createResyncReplayFixture, leaving terminalStopReason 'cancelled' a dead option — already reported (R4-1 comment 3965944443 at DaemonSessionProvider.test.tsx:10694; re-derived independently by two round-4 au…
  • design doc's absence list omits the goal-runtime unstamped-frame omission reason — already reported (R1-23 comment 3949408436 at customization.tsx; round-8 deferral at web-shell-assistant-turn-settlement.md:41)
  • no test pins that the transcript is read when the settlement fires rather than at mount time — already recorded (round-9 deferral at assistant-turn-settlement.ts:152, review 5162219880)
  • settlement publishes a transcript-block id where the turn-footer path publishes a rendered-message id — already reported (R2-4 comment 3953370590 at assistant-turn-settlement.ts)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the area this round's repair-suppression Critical touches.

Not reviewed: re-check of the earlier-round DaemonSessionProvider.tsx and assistant-turn-settlement.ts blocker threads R1-2, R2-1, R3-1 (twice), R5-1, R5-2 and R7-1 — not walked this round: the outcome is not a zero-Critical verdict, so the open-blocker re-check gate did not apply. The carried ledger entries R8-1 and R9-1 were both ruled, and R1-1 and R7-1 share R8-1's now-removed adapter mechanism.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": did not execute the five new tests ( npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx -t … ) to confirm they are green at the reviewed commit — …; "agent reverse-audit (round 2)": did not locate the bridge site that publishes agent-emitted agent_message_chunk frames, so I could not confirm end-to-end that the daemon stamps the envelope …; "agent reverse-audit (round 4)": whether a switch-back /load replay snapshot still carries a removed prompt's turn_complete(cancelled) (journal retention and the terminalPublished filter …; "agent reverse-audit (round 2)": the queued-prompt chain (a session whose only bound prompt is still in pendingPromptList when an epoch reset lands, so !hasSessionActivePrompt() deletes its…; "agent reverse-audit (round 3)": I did not trace the full value set of the goal-runtime reason that bridgeClient.ts:2295 publishes as data.stopReason on a turn_complete for source === …, and 4 more.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Deferred under the convergence posture (round 10, not a blocker) — recorded, not requested in this round; 2 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3638 — [probe] Critical [fails-closed] [new-surface] R8-1's neighbouring gap: the repair-suppressed live settlement has no completion-path publish — server error, missing te…
  • packages/web-shell/client/assistant-turn-settlement.ts:57 — [probe] Critical [fails-closed] [new-surface] the streaming early-return aborts the scan, so a fourth reachable state publishes a completed settlement with no message although the …
  • docs/design/web-shell/web-shell-assistant-turn-settlement.md:1 — [probe] the new design doc ships English-only — no .zh-CN.md sibling and no reciprocal language links below the title (0 of 47 docs in that directory have one)
  • docs/design/web-shell/web-shell-assistant-turn-settlement.md:26 — [probe] the doc tells hosts to correlate the prompt id with their submit result, but no shell-mediated submit surface exposes a prompt id (1 hit across the public surface: th…
  • packages/web-shell/client/App.test.tsx:10019 — [probe] the added mockUseDaemonActivePromptBridge scaffolding is inert — deleting all four additions leaves 861/861 green — and aliases a per-session bridge to one global flag
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:132 — [probe] the kind !== 'assistant' exclusion term has no witness; a prompt-stamped turn_error block trailing the turn would be published as the failed turn's message if it wer…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:224 — [probe] the cross-session test asserts only the omitted message, so re-keying the published sessionId to the live connection survives all 11 tests and corrupts the host idem…
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:240 — [probe] the only streaming fixture holds a single own-prompt block, so return-undefined versus continue survives all 11 tests — the distinction the streaming Critical turns …
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10633 — [probe] the admission gate's documented survival across session switches is unpinned — two mutants that break exactly that property both survive all 326 provide…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10833 — [review] the first-attach silence test's only assertion is that settlements is empty, so it passes identically if a first attach never reaches the replay loop
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:10852 — [probe] no test drives the live publish branch with turn_complete — gating the live publish on turn_error only, or on activePromptSettled, survives the entire 7…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx:20568 — [review] the new replay fixtures omit the envelope promptId real compacted replay carries, so the replay-to-stamped-block-to-message combination is never witnes…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:1148 — [probe] the per-mount duplicate suppression is untested although its trigger is real — deleting the early return survives the suite and a duplicate-terminal probe yie…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:2943 — [probe] the epoch-reset admission-key cleanup's keep branch is unobservable — dropping the guard or the whole block leaves all 326 tests green
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3633 — [probe] the new pre-publish flushTranscriptSync has no witness — deleting it survives the suite and a listener-side probe then sees the turn without its error block
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3953 — [probe] the failedSessionId widening and its admission-key cleanup are untested, and the widening is measured live: requestEpochResetReload sets session undefined, so…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:5038 — [probe] refusal, max_tokens and max_turn_requests turns are published to hosts as outcome 'completed' while the submitter promise resolves, and three in-repo classifi…

Convergence: round 10 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 2 (1 new). Findings keep coming back to the same files: packages/web-shell/client/assistant-turn-settlement.ts (findings in round 8; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 6 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; integration-tests/cli/qwen-serve-web-shell-live-journal-recovery.test.ts mounts DaemonSessionProvider from the web-shell barrel, which is the area this round's repair-suppression Critical touches.

未审查(原文为英文):re-check of the earlier-round DaemonSessionProvider.tsx and assistant-turn-settlement.ts blocker threads R1-2, R2-1, R3-1 (twice), R5-1, R5-2 and R7-1 — not walked this round: the outcome is not a zero-Critical verdict, so the open-blocker re-check gate did not apply. The carried ledger entries R8-1 and R9-1 were both ruled, and R1-1 and R7-1 share R8-1's now-removed adapter mechanism.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"did not execute the five new tests ( npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx -t … ) to confirm they are green at the reviewed commit — …"agent reverse-audit (round 2)"did not locate the bridge site that publishes agent-emitted agent_message_chunk frames, so I could not confirm end-to-end that the daemon stamps the envelope …"agent reverse-audit (round 4)"whether a switch-back /load replay snapshot still carries a removed prompt's turn_complete(cancelled) (journal retention and the terminalPublished filter …"agent reverse-audit (round 2)"the queued-prompt chain (a session whose only bound prompt is still in pendingPromptList when an epoch reset lands, so !hasSessionActivePrompt() deletes its…"agent reverse-audit (round 3)"I did not trace the full value set of the goal-runtime reason that bridgeClient.ts:2295 publishes as data.stopReason on a turn_complete for source === …,另有 4 条。

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

收敛姿态下延后(第 10 轮,非阻断)——已记录,本轮不要求修改;其中 2 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 17 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 10 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 2 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/assistant-turn-settlement.ts(第 8 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread packages/web-shell/client/assistant-turn-settlement.ts Outdated
`getSettledAssistantMessage` published `block.text` verbatim, so a turn whose
final stamped block is an insight frame — `/insight` progress/ready frames
travel as ordinary assistant block text and are stamped with the running
prompt (`bridgeClient.ts:1066-1072`) — hands the host raw protocol JSON
(`{"insight_ready":{"path":"/tmp/report.md"}}`) as the answer, while the real
answer one slot earlier is never published. The renderer strips those frames
and emits no assistant text for a payload-only block, so `trim().length` was
measuring the wrong thing: the payload is non-empty even though the block
renders to nothing.

Reuse the renderer's own segmentation instead of re-deriving a boundary:
export `assistantVisibleTextOf` from the render adapter (a thin wrapper over
its private `splitInsightSegments`) and skip a block whose visible text is
empty, so the substantive answer one slot earlier still wins. This keeps
selection at the block layer — ownership is still read off `promptId`, and the
adapter's cross-turn merge is never re-entered — and keeps the published
content exactly what the renderer emits
(`adapters/transcriptToMessages.test.ts:52-95`).

Two new cases pin it: a payload-only final block publishes the previous
answer's id and content, and a frame glued to trailing text publishes only
that trailing text. Neutering the visibility filter turns both red.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtvvo040vv
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agent-assisted review at 77d8a56d2562199ee018950de224609c6ca0d484 — 1 confirmed Critical; Partial review — coverage gaps.

Critical — a live-observed terminal can disappear during journal repair. In packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3446-3451,3634-3644, the repair target's terminal is withheld rather than saved for later delivery. tryLiveJournalRepair (1587-1623) reloads the memory snapshot; successful replay publication (2913-2923) requires this provider's local-admission key. A provider attached to another submitter's already-running, truncated turn has no such key: it sees the terminal live, repairs the transcript, then silently skips the terminal during replay. The snapshot is consumed (2939) and SSE resumes after it, so the host never receives that settlement. This contradicts the documented all-live-observers contract, not merely the intentionally silent history-load case. The existing truncated-live-turn fixture at DaemonSessionProvider.test.tsx:8767-8899 supplies this restored-turn shape, but does not assert settlement delivery. Preserve the authoritative live terminal through repair and deliver it after projection independently of the replay-only admission gate. The failure branch at 1606-1621 also drops the repair episode without delivering the already-observed terminal; absence of a retained final message need not erase a known outcome.

Historical Critical reassessment. The raw-block selector now avoids render-adapter merging/ownership reconstruction, excludes subagents and foreign/unstamped siblings, and withholds an own streaming block. The insight-only/glued-frame regression is addressed by assistantVisibleTextOf; corresponding projection tests now assert actual own answers. Replay admission survives controller cleanup; optional-hook use and undefined error data are guarded; foreign-session removal no longer mutates current notifications. Synthetic client-inferred failed retirements were removed: I am not repeating older requests to synthesize failure when no daemon terminal exists. The earlier publish-before-repair problem is addressed only partially, with the delivery loss above. I have not independently replayed every historical interleaving, so this is not an all-Criticals-fixed declaration.

Coverage: full changed production diff, public App/Split View wiring, raw projection and tests, admission/removal/deduplication, live/replay terminal paths, and journal-repair reload/consumption. Gaps: exhaustive reconnect/session-switch/epoch-reset race matrix and React 18/19 mounted integration. No unit/build/browser tests executed; finding is a static producer-to-consumer trace against the pinned remote head, not the stale local worktree. Base: 0ef35351e9df470ffdfd37bc088e46f425614a18.

Gate: feature, not an external 500+ refactor; author has verified admin permission. Size/conflict/test limitations are not additional code Criticals. Existing Suggestions remain deferred. Comment only; no approval implied.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 82 passed · 3 failed · 85 total

Flakiness gate: ✅ 4 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:82 通过 · 3 失败 · 85 总计

抖动门:✅ 4 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 11251 — deep verification report (round 3, follow-up)

Verdict: findings — 82 passed / 3 failed / 85 scripted assertions executed.
Verified head 77d8a56d2562199ee018950de224609c6ca0d484 (git rev-parse HEAD^2); base tip 78bbd9f55c5a8ba9aab96bad91f42e8c83025737 (HEAD^1).
The metadata snapshot's baseRefOid (0ef35351) has drifted from the merge-ref base actually checked out (78bbd9f5), and 0ef35351 is not present locally (git cat-file -t fails). All measurements below are against the merge ref.

This is a follow-up round. Round 2 verified head 68df9b0e; the snapshot now lists 31 commits, of which four are new (a959c10888, 669d40ead4, febc864f00 merge, 77d8a56d25). None is individually reachable — git rev-list HEAD^1..HEAD^2 returns 1 at this depth-2 shallow boundary, and git cat-file -t 68df9b0e2d fails. Every carried-forward measurement below was re-run at the new head, not read back from the previous report.

Accounting rule (unchanged from rounds 1–2, so the three rounds are comparable): fail counts (a) behavioural checks whose observed outcome contradicts the PR's stated contract, and (b) mutation rows whose pinned test did not catch the revert — completeness findings, not PR defects. Gate greens and expected-red control cells count as passes. All three fails this round are mutation survivors (M1, M4, N8/F5). Raw per-check lines are in logs/; witnesses in evidence/.

No instruction-following from PR content occurred. The PR body makes no claim of local validation ("No local validation result is claimed"), and nothing in the title, body, commit messages, or code comments attempted to steer the verdict.

中文摘要 — 判定:findings(82 通过 / 3 失败 / 85 断言)
  • 结论findings。本轮的核心改动(把 settled message 的选择从 adapter 层下移到 block 层)A/B 验证通过且 load-bearing:见「Central claim and A/B」表与 evidence/01-ab-head-vs-control-and-sdk-oracle.png。3 个 fail 全部是变异矩阵的存活行(完整性发现),不是核心主张失败。
  • A/B 结论:以 SDK 自己的 findFinalVisibleAssistantForPrompt 作为差分 oracle(通过 reducer 的 branchRecordId 副作用观测,无需导出私有函数),head 在 13 个形状上与 SDK 0 处非预期分歧,而重建的上一轮对照组有 6 处分歧。针对上一轮 G1 的独立未盖章 block 形状,head 5/5 干净,对照组 3 个被污染 + 2 个过度拒绝(完全没有 message)。
  • 上一轮发现的状态:见「Previous-finding status」表。G1 已修复但归因需更正(见 Corrections:V3 那个形状的粘连发生在 SDK reducer 层,不是本 PR 的谓词,且与 UI 渲染完全一致);G4 已修复(B 现在拿到自己的 message);G2/G3 因 adapter 路径整体移除而 superseded;F1–F5、F7 全部 stands,其中 F2/F4/F5 本轮已重新实测。
  • 本轮新发现:H1 assistantVisibleTextOf' ' 拼接保留文本段,会破坏围栏代码块(实测 "```\n```""``` ```"),且与渲染器和同包的兄弟实现都不一致;已给出实测过的修复(改成 '\n':渲染器分歧 1→0,分隔符分歧 5→0,PR 测试计数 13→13 不变)。H2/H3 是 M1/M4 两个存活守卫,已用 W1/W2 fixture 证明是载荷性覆盖缺口而非死代码:去掉 kind 过滤会把用户自己的消息当成 assistant 回答发布(content="USER ECHO TEXT", id="user-2");把 streaming 的 return undefined 改成 continue 会在该轮仍在流式输出时发布更早的非最终块。H4/H5 见 Findings。
  • 未覆盖:31 个 commit 的逐 commit 归因(shallow 只可达 1 个,对照组是重建的);F1 的运行时投递计数连续第二轮未重跑(仅静态复核);F3 的运行时分歧未重跑(已在当前源码确认两个 promptId 读取点仍不一致);真实 daemon 端到端;prettier 门。

Previous-finding status

Rounds 1–2 filed F1–F7 then G1–G4. Status at head 77d8a56d:

# finding sev status at the new head evidence
F1 built-in Split View double-delivers one turn to a single host callback Medium stands (structure re-verified in current source; runtime count not re-driven — second consecutive round, see Not covered) App.tsx calls useAssistantTurnSettlementProjection(onAssistantTurnSettled) as the first statement of App, and separately forwards the same callback into SplitView, which renders an AssistantTurnSettlementObserver inside each pane's own DaemonSessionProvider (SplitView.tsx:628-632 in the diff). Dedup is a per-mount publishedPromptSettlementsRef Set, so two providers for one session publish twice. The PR body names the tradeoff for hosts mounting twice; it does not name the shell's own Split View composition.
F2 non-success stop reasons reported as outcome:'completed' Medium stands — re-measured at runtime, and broader than round 2 reported probe: refusal{"outcome":"completed","stopReason":"refusal"} and max_tokens{"outcome":"completed","stopReason":"max_tokens"}. Liveness controls in the same probe: end_turncompleted, cancelledcancelled. promptSettledFromTurnEvent maps every stopReason except cancelled/error to completed.
F3 two implementations of "which prompt does this terminal belong to" disagree Low (latent) stands (confirmed in current source; runtime divergence not re-driven) promptSettledFromTurnEvent uses eventPromptId(event) (live-journal-repair.ts:66-72: envelope first, then data), while settleActivePromptFromTurnEvent (DaemonSessionProvider.tsx:5115 ff.) still reads only (event.data as {promptId?: string})?.promptId. An envelope-only promptId therefore settles the host callback but not the submitter.
F4 failed outcome can carry no error detail; doc comment inaccurate Low (latent) stands — re-measured probe: turn_complete{stopReason:'error'}{"outcome":"failed","stopReason":"error"} with error absent. customization.tsx still documents stopReason as "Present for completed and cancelled turns", yet the failed path carries it too. Only the turn_error branch synthesises error.
F5 pre-publish flushTranscriptSync() pinned by nothing Low (completeness) stands — re-measured removing the flush survived 334/334 (round 2: 324/324). Positive control in the same file and same suite — removing the replay-path publishPromptSettlement(replaySettlement) — turned 3 tests red, so the survivor is a real coverage statement, not a dead harness.
F6 the two streaming guards are layered defence superseded the message-level guard no longer exists: the module no longer consults the render adapter at all. The block-level guard is now the sole defence and is pinned (M5 KILLED). See G2 below for where the residual gap moved.
F7 dedup set grows without bound for the provider's lifetime Nit stands (static) publishedPromptSettlementsRef is written in publishPromptSettlement and has no clear site anywhere in the file.
G1 promptId === undefined widening re-opens the contamination Medium fixed for the part attributable to this PR; round 2's attribution was partly wrong — see Corrections a959c10888 replaced the widening with strict block.promptId !== promptId. Sibling sweep of 5 standalone-unstamped-block shapes: head publishes only "The answer is 42." on 5/5; the control contaminated 3 ("The answer is 42.background notice", "The answer is 42.notice onenotice two", "earlier noticeThe answer is 42.") and over-rejected 2 (undefined). Mutation M3 (re-admit unstamped blocks) is KILLED by 2 tests.
G2 message-level streaming guard load-bearing but unpinned Low superseded — the guard is gone and its replacement is pinned, but the gap moved rather than closed M5 (remove if (block.streaming) return undefined) is KILLED. M4 (change it to continue) SURVIVED 13/13H3.
G3 empty sourceBlockIds guard unpinned and unreachable Nit superseded — the guard no longer exists the module no longer calls transcriptBlocksToDaemonMessages; there is no sourceBlockIds term left to pin.
G4 last turn of a no-echo continuation settles with no message Low (info) fixed 669d40ead4 selects at the block layer, so each turn gets its own block: head A → {id:'assistant-1', content:'The answer is 42.'}, head B → {id:'assistant-2', content:'next turn text'}; the control returned undefined for both. The tradeoff round 2 asked the author to document no longer applies.

Central claim and A/B

Central claim of the delta: the settled message must be this prompt's own final visible assistant block, selected by identity at the block layer rather than derived from the render adapter — so a foreign turn's text, an earlier non-final block of this turn, a subagent block, and an insight protocol frame can each no longer be published as the answer.

Round 2's head 68df9b0e is unreachable at depth 2, so the control was reconstructed: zz-verify-control.ts re-implements the adapter-layer whole-message ownership predicate, the || block.promptId === undefined widening, and the absence of insight stripping, from round 2's report and from the current head's own doc comment describing the previous approach. transcriptBlocksToDaemonMessages itself is unchanged by the delta (the diff only adds assistantVisibleTextOf), so the control reuses the real adapter. The control is therefore a faithful model of the previous predicate, not its literal bytes — named as a confound rather than hidden.

The blocks are not hand-written fixtures: every cell drives the real SDK reducer (createDaemonTranscriptState + reduceDaemonTranscriptEvents) with real assistant.text.delta / user.text.delta / assistant.done events. Only the three SDK hooks (useConnection, useTranscriptStore, useDaemonPromptSettled) are stubbed, exactly as the PR's own test does; the unit under test, the reducer, and the adapter are all real. The PR touches no file in packages/sdk-typescript, and readlink -f node_modules/@qwen-code/sdk from the package resolves to /__w/qwen-code/qwen-code/packages/sdk-typescript — one shared, unchanged SDK for both arms, so there is no workspace-symlink confound.

Witness: 01-ab-head-vs-control-and-sdk-oracle.png.

Differential oracle: the SDK's own private function

The module's doc comment claims its exclusion terms "are the SDK's own for this exact question (findFinalVisibleAssistantForPrompt)". That function is private, but it is observable without exporting it: on assistant.done carrying branchRecordId + promptId + reason:'end_turn', the reducer stamps branchRecordId onto exactly the block that function returns (transcript.ts:378-390). Feeding identical streams to both and comparing the selected id gives a real reference-implementation oracle. A liveness control (same stream, no marker) selects nothing, so a hit is a genuine observation.

arm disagreements with the SDK over 13 shapes
head 77d8a56d 0 unintended (1 documented intentional divergence, asserted separately)
reconstructed round-2 control 6

The one intentional divergence is A13 (insight-payload-only final block): the SDK's emptiness test is raw text.trim(), so it picks the payload-only block assistant-2, while head skips back to the substantive answer assistant-1. That is precisely what 77d8a56d25 intends, and it is pinned by the PR's own tests (M6, M7 both KILLED). A second intentional divergence — withholding on a still-streaming block, where the SDK's pick is computed before finishAssistant — is asserted in its own cell (A-streaming/head-withholds-message, PASS).

A/B cells

cell oracle head control (round-2 semantics)
5 standalone-unstamped-block shapes (sibling sweep) published content "The answer is 42." on 5/5 3 contaminated, 2 undefined
A6 discrete unstamped sibling content "The answer is 42." "The answer is 42.background notice"
two adjacent prompts, settle A content "The answer is 42." undefined (over-rejection)
two adjacent prompts, settle B content "next turn text" undefined (over-rejection)
subagent text after top-level text (A4) message assistant-1 / own text undefined
answer + glued insight_ready frame content "The answer is 42.", id=assistant-1 "The answer is 42.", id=assistant-1-t-0 (synthetic segment id, not a transcript block id)
single-prompt turn (over-rejection guard) full payload byte-identical between arms byte-identical
settlement for another session message undefined undefined
A5 merged unstamped continuation content "The answer is 42.continuation text" identical

58/58 checks pass at head (logs/ab-head-vs-control.log). The A5 row is identical across arms by construction — see Corrections; the glue there is the reducer's, and A5-render/settlement-equals-rendered-ui-text (PASS) proves the callback says nothing the rendered UI does not.

Mutation matrix

Witness: 02-mutation-matrix-with-positive-control.png. One row per guard, each reverting exactly one hunk (marker uniqueness asserted before any write; file restored and verified byte-identical after every row; git diff --stat HEAD empty at the end). Unmutated control: 13 passed / 13, so the kills mean something.

row guard reverted outcome detail
M1 block?.kind !== 'assistant' filter SURVIVED 13/13 H2
M2 subagent (parentToolCallId) exclusion KILLED 1 red
M3 revert a959c10888: re-admit unstamped blocks KILLED 2 red
M4 streaming return undefinedcontinue SURVIVED 13/13 H3
M5 streaming guard removed entirely KILLED 1 red
M6 revert 77d8a56d25: raw block.text.trim(), no insight stripping KILLED 2 red
M7 payload-only block: continuereturn undefined (no walk-back) KILLED 2 red
M8 revert 669d40ead4: scan forwards (first block, not final) KILLED 1 red
M9 POSITIVE CONTROL: field-by-field projection → object spread KILLED 1 red
M10 currentSessionId === event.sessionId scope guard KILLED 1 red

8 killed / 2 survived, with a positive control in the mutated file's own suite. Witness: 03-survivor-adjudication-coverage-gaps.png.

Corrections

Three statements in the previous report are inaccurate at this head. These are corrections to that report, not requests to change the PR's code.

  1. Round 2's G1 attributed the wrong mechanism to the V3 fixture. Round 2 measured V3 => content "The answer is 42.continuation text" and traced it to promptOwnedIds' promptId === undefined widening. Re-driven at this head, that shape produces one block, not two: the SDK reducer's canMergeTextDelta refuses a merge only when both sides carry a promptId (transcript.ts:917-921), so an unstamped continuation is folded into the block already stamped prompt-A, and the backfill comment at :836-840 only fires the other way round. No selection predicate at this PR's layer can separate text the reducer has already merged into one block. The measured consequence: head and the control agree byte-for-byte on that fixture, and A5-render/settlement-equals-rendered-ui-text (PASS) shows the rendered UI carries the same glued string. The widening round 2 identified was real, but it only ever affected the separate-block shape (qwenDiscreteMessage, or a block created after a boundary) — and that is exactly what a959c10888 closes, 5/5 in the sibling sweep. Round 2's Medium severity for G1 was therefore aimed partly at a layer this PR does not own.
  2. Round 2's G2/F6 framing no longer describes the code. There is no message-level guard to classify: the adapter path is gone. The block-level guard is the sole defence and is pinned (M5 KILLED). The residual gap is narrower and different — return undefined versus continue (M4) — reported as H3.
  3. 77d8a56d25 does not fix a pre-existing leak. My first control cell asserted that round 2's semantics published raw protocol JSON; it did not (control="The answer is 42."), because the adapter already splits insight segments. The commit restores stripping that the move to block-layer selection had lost. Read as "fixes a leak hosts were seeing", the commit overstates itself; read as "keeps the rewrite from regressing the rendered text", it is exactly right and M6 proves it load-bearing. Relatedly, the rewrite improves one thing neither round reported: the published message.id is now a real transcript block id (assistant-1) where the adapter path could publish a synthetic segment id (assistant-1-t-0) that a host cannot resolve against the transcript.

Findings

H1 — Low: assistantVisibleTextOf joins retained segments with a space, corrupting fenced code blocks and diverging from both the renderer and its sibling

assistantVisibleTextOf (adapters/transcriptToMessages.ts:1817-1826) filters the insight segments to kind === 'text' and joins them with ' '. The renderer it claims to match does not join at all — it pushes one assistant message per text segment (:563-573) — and the sibling implementation getTurnNotificationContent (daemon/session/turn-notification-context.ts:245-253) joins with '\n'. When a block has text on both sides of a frame, the space join glues two lines into one.

Measured over an 18-shape sweep (logs/ab-head-vs-control.log, section C):

C11 frame inside a fenced code block
    text     = "Example:\n```\n{\"insight_ready\":{\"path\":\"/tmp/r.html\"}}\n```\nDone."
    visible  = "Example:\n``` ```\nDone."          <- two fence lines glued onto one
    rendered = "Example:\n```\n```\nDone."
    sibling  = "Example:\n```\n```\nDone."
    rendererMatch=false

Sweep totals: 1 renderer divergence, 8 sibling divergences, of which 5 are separator-only (C4, C11, C13, C14, C18) and 3 are the H4 shapes. For markdown hosts the C11 case is not cosmetic: "```\n```" is an empty code block, "``` ```" is a paragraph containing backticks — the host's message.content renders as something the user never saw.

Reproduce: cd packages/web-shell && npx vitest run --config vitest.config.ts client/zz-verify-settlement.test.tsx (harness kept in this artifact dir) and read the C11 row.

Bound — what this is not. It cannot publish a foreign turn's text (selection is by identity, proven in the A/B), and it does not affect any shape without an insight frame: C1/C15/C16/C17 are byte-identical to both the renderer and the sibling. The exposure is limited to blocks that contain a parsed insight frame and text on both sides of it.

Suggested fix — measured, not eyeballed

Join with '\n' instead of ' ', matching the sibling implementation:

     .map((segment) => segment.text)
-    .join(' ');
+    .join('\n');

Applied in a scratch copy and driven through the same harnesses (logs/fix-measurement.log; file restored and verified byte-identical afterwards):

measurement before after
renderer divergences over the 18-shape sweep 1 0
sibling divergences 8 3 (all 5 separator-only ones close; the 3 remaining are H4)
C11 visible vs rendered "Example:\n``` ```\nDone." ≠ rendered byte-identical, and identical to the sibling
benign shapes (no frame) identical unchanged — zero collateral
my harness totals 58 pass / 0 fail 58 pass / 0 fail
PR's own settlement suite 13 passed 13 passed — unchanged

That last row is the unpinned-axis signal: the suite is green with and without the fix, so nothing in the PR asserts the separator. The fix should ship with a fixture — C11 is exactly it: a block whose text holds a fenced frame between two text segments, asserting message.content equals what the renderer shows.

H2 — Low (completeness): the kind === 'assistant' filter is load-bearing and unpinned; without it a user echo is published as the assistant's answer

M1 survived 13/13. It is not dead code — adjudicated with a purpose-built fixture (logs/survivor-adjudication.log, witness 03-survivor-adjudication-coverage-gaps.png). The SDK reducer does stamp promptId on user blocks (the sibling implementation looks one up by exactly that key), so a user echo landing after the assistant text is last in the backwards scan:

W1 BLOCKS |
{assistant-1 kind=assistant promptId=p-A streaming=false text="The answer is 42."}
{user-2      kind=user      promptId=p-A streaming=undefined text="USER ECHO TEXT"}

M1 @ HEAD    => PASS  head-publishes-assistant-text-not-user-echo
M1 MUTATED   => FAIL  head="USER ECHO TEXT"   headId="user-2"
=> VERDICT M1: COVERAGE GAP (guard load-bearing, unpinned) | failingChecks=2

The sharpest consequence of dropping the guard is a host rendering the user's own message back as the assistant's reply, under the user block's id. Classification: coverage gap — the behaviour is correct, nothing asserts it. The fixture to add is W1: an assistant.text.delta + assistant.done + a user.text.delta carrying the same promptId, asserting the published content is the assistant text and the id is not the user block's.

H3 — Low (completeness): the streaming guard's return undefined (not continue) is load-bearing and unpinned

M4 survived 13/13. The two variants differ only when this prompt has an earlier finished block and a later still-streaming one — a shape the existing streaming test cannot reach, because its streaming block belongs to a later prompt and the promptId filter excludes it first. Reachable via a discrete second segment of the same prompt:

W2 BLOCKS | assistant-1 (p-A, streaming=false) + assistant-2 (p-A, streaming=true)
W2 HEAD    => PASS  head-withholds-rather-than-publishing-earlier-block  (message absent)
W2 MUTATED => FAIL  head.message={"id":"assistant-1","content":"The answer is 42.",…}
=> VERDICT M4: COVERAGE GAP (guard load-bearing, unpinned) | failingChecks=1

This is the exact hazard the module's own comment names — "Still streaming means 'not yet settled', not 'keep looking': publishing partial text is unrecoverable, as no corrected callback can follow" — and the exact failure it describes ("an earlier non-final message of this turn"). Classification: coverage gap. The fixture to add is W2. Note this is where round 2's G2 gap moved to, not a regression: M5 proves the guard's presence is pinned, M4 proves its semantics are not.

H4 — Low (informational): three frame shapes pass raw protocol JSON through to message.content, where the sibling implementation suppresses it

splitInsightSegments declines to parse a frame whose key has a space before the colon, whose braces are unbalanced, or whose value has the wrong type. In those cases assistantVisibleTextOf returns text.trim() — the raw frame included. Measured (section C, 3 of 18 shapes):

C7  "The answer is 42.\n{\"insight_ready\" :{…}}"   visible = text verbatim   sibling = undefined
C9  "The answer is 42.\n{\"insight_ready\":{\"path\":\"/tmp/r.html\""  (truncated)  visible = text verbatim   sibling = undefined
C10 "The answer is 42.\n{\"insight_progress\":{\"stage\":\"scan\",\"progress\":\"half\"}}"  visible = text verbatim   sibling = undefined

Bound — what this is not, and why I am reporting it as informational rather than as the leak the commit exists to prevent. On all three shapes rendererMatch=true: the rendered UI shows the same bytes, so the settlement callback never tells a host something the user cannot see. The divergence is between two host-facing surfaces in one package — getTurnNotificationContent carries an extra regex backstop (!/\{\s*"insight_(?:progress|ready|error)"\s*:/.test(visibleText), turn-notification-context.ts:256) that the settlement path lacks, so a desktop notification suppresses the text while the settlement callback publishes it.

Reachability, per shape: C7 is not reachable from the real producerpackages/core/src/core/insightProtocol.ts builds frames with JSON.stringify, which never emits a space before a colon. C9 is the one worth the author's attention: a finished block ending mid-frame is a shape this repo already models in its own fixtures (useMessages.test.ts:462, turn-notification-context.test.ts:431 both use a truncated insight_ready on a completed block), and the streaming guard does not cover it because the block is not streaming. C10 needs a malformed value type from the producer, so it is defensive only. I did not demonstrate a live C9; it is a fixture-level shape plus a documented producer of it, not a witnessed payload.

If the author wants the two surfaces to agree, the minimal change is to port the sibling's residual-marker test into assistantVisibleTextOf. I did not measure that fix, so I am not proposing it as a patch — the H1 fix is the only one I drove.

H5 — Nit: the "the SDK's own for this exact question" comment invites a resync that would regress the insight fix

The comment at assistant-turn-settlement.ts:36-41 says the exclusion terms "are the SDK's own for this exact question (findFinalVisibleAssistantForPrompt), kept local because exporting it would widen the @qwen-code/sdk/daemon public surface." The differential oracle confirms this is true for the three identity terms — head disagrees with the SDK on 0 of 13 unintended shapes. But the local copy deliberately diverges on two more: the emptiness test (insight-stripped vs the SDK's raw text.trim()) and the streaming withhold (the SDK has none). Both divergences are improvements and both are pinned by the PR's own tests (M6, M7, M5 all KILLED), so this is not a defect. It is a maintenance hazard: a future maintainer who reads "the SDK's own" literally and resyncs the copy against transcript.ts:935 would silently undo 77d8a56d25. One clause naming the two intentional divergences would close it.

Carried findings still open

F1 (Medium, Split View double-delivery), F2 (Medium, non-success stop reasons as completed, now also measured for max_tokens), F3 (Low latent, two promptId readers), F4 (Low latent, failed without error detail + inaccurate doc comment), F5 (Low, unpinned flush), F7 (Nit, unbounded dedup set) — all re-measured or re-verified as described in the status table. None was addressed by the four delta commits. F2's note from round 2 still applies and is now sharper: because message is withheld in more cases and outcome is unreliable for every stop reason except cancelled/error, a host's only trustworthy signal is stopReason — which the customization.tsx doc comment describes inaccurately (F4).

Not covered

  • F1's runtime delivery count was not re-driven — second consecutive round. I re-verified the structure by reading all three current sites (listed in the status table) and confirmed the delta commits touch none of them, but I did not rebuild a two-provider harness. renderWithProvider in the PR's own test file binds module-level container/root, so a faithful two-provider mount needs separate roots; I spent the remaining budget on the mutation matrix, the survivor adjudication, and the measured H1 fix instead. F1's status is "stands on structure", not "stands on a re-run count". Round 1's measured 2 deliveries is not carried forward as a measurement.
  • F3's runtime divergence was not re-driven. The two readers are confirmed different in current source (eventPromptId vs event.data.promptId); I did not reproduce round 2's submitResolved=false / promptStatus="waiting" payload at this head.
  • Per-commit attribution. The snapshot lists 31 commits; the shallow merge-ref checkout reaches 1 (git rev-list HEAD^1..HEAD^2 = 1, which at a shallow boundary reads as plausible rather than erroring). git cat-file -t fails for both the snapshot's baseRefOid (0ef35351) and round 2's head (68df9b0e2d). The four commits since round 2 could not be exercised individually, so I cannot say which of a959c10888 / 669d40ead4 / 77d8a56d25 produced which measured cell — the A/B is against a reconstructed control, and the reconstruction is a model of the previous predicate rather than its bytes.
  • Real-daemon end-to-end. No daemon was booted. The reducer and adapter are real and drove real code paths, but the events feeding them were constructed from the shapes the real producers emit rather than captured off a live socket. Round 2's highest-value follow-up — watching a real goal turn's assistant text reach a real host callback — remains unmeasured, and is now narrower: the standalone-block shape is fixed, so what remains to witness is whether a goal-runtime or background-notification turn can produce the merged A5 shape in production.
  • The real SplitView / App JSX composition — unchanged from rounds 1–2: both App.test.tsx and SplitView.test.tsx mock DaemonSessionProvider, so no existing harness composes the real providers. This is why F1 can only be read statically here.
  • The PR's Reviewer Test Plan was not walked step by step. Three of its five steps ("Mount Web Shell with the new callback", "Open Split View and confirm each pane forwards settlement for its own live session", "disconnect an active live stream before its terminal") require a host application and a live daemon that this container does not have. I verified the mechanisms behind them through the PR's own provider tests (334 green, 3 of which kill the positive-control mutation) rather than the steps as written; step 4 (Split View) is exactly the F1 surface and is the one a reviewer should treat as unverified.
  • Reconnect catch-up and epoch-reset delivery were verified only through the PR's own tests (green), not independently re-driven — except that my F2/F4 probe itself publishes through the resync-replay path, so that path is exercised by a probe I wrote.
  • The prettier gate was not attempted. Rounds 1 and 2 both reported it could not complete in-container; I did not retry and do not imply it passed. eslint and typecheck were run and are reported below.
  • The full workspace suite was not run. Only the 4 changed test files, typecheck, and eslint on the 9 changed production files. Round 1's clean workspace number is not carried forward, because the head changed and the input closure is not proven identical.
  • A scaling ladder on splitInsightSegments was not run. Reading it, text.slice(braceIdx + 1).trimStart() copies the remainder of the string for every { in the text, which suggests superlinear cost on brace-dense assistant text. That is a hypothesis from reading, not a measurement, and the function is pre-existing (already on the render hot path) rather than added by this PR — the PR only adds a second call site. I am explicitly not claiming a perf finding.
  • getSettledAssistantMessage's interaction with bounded transcript retention (an evicted block leaving the prompt's final answer outside blocks) was not probed — carried over from round 2.

Targeted gates

gate command result
PR's touched test files cd packages/web-shell && npx vitest run --config vitest.config.ts client/assistant-turn-settlement.test.tsx client/daemon/session/DaemonSessionProvider.test.tsx client/daemon/session/actions.test.ts client/App.test.tsx 1458 passed / 0 failed (4 files, EXIT=0, 160 s) — round 2 measured 1354, so +104
typecheck (clean tree, scratch files moved out of client/ first) cd packages/web-shell && npm run typecheck clean (EXIT=0)
eslint, 9 changed production files npx eslint <files> clean (EXIT=0)
eslint liveness control planted const zzLivenessViolation = 42; into assistant-turn-settlement.ts EXIT=1, 24:7 error 'zzLivenessViolation' is assigned a value but never used (@typescript-eslint/no-unused-vars); after restore EXIT=0, git diff --stat HEAD empty and git status --porcelain clean — the green gate above is a measurement, not an assumption
full workspace suite / prettier not run (see Not covered)

Round 2 recorded that an earlier typecheck had run while a reconstructed control file sat in client/ (the package's include is client/**/*.ts, excluding only *.test.ts(x)). I avoided that this time by moving both scratch files into the artifact dir before running typecheck, and confirmed git status --porcelain packages/web-shell/ was empty first.

Methodology

Environment: CI verify container at the merge ref (HEAD = 90e3afe1, HEAD^1 = base tip 78bbd9f5, HEAD^2 = PR head 77d8a56d), npm ci + npm run build pre-run, no GitHub token, no network. Five harnesses, all kept in this artifact dir so a maintainer can rerun them: zz-verify-control.ts (the reconstructed round-2 predicate, reusing the real unchanged adapter), zz-verify-settlement.test.tsx (58 checks — real SDK reducer → real adapter → real projection, head arm driven through a real AssistantTurnSettlementObserver mount and control arm through a direct call, plus the 13-shape SDK differential oracle, the 5-shape G1 sibling sweep, the 18-shape insight sweep against the real renderer and the real getTurnNotificationContent, and the two survivor-adjudication fixtures), zz-verify-mutation-matrix.mjs (10 rows, marker uniqueness asserted before each write, restore verified byte-identical after each), zz-verify-adjudicate-survivors.mjs (runs each survivor's fixture at head and mutated, and classifies coverage gap vs dead code), zz-verify-provider-probe.mjs (4 checks — F2/F4 re-measured through the real DaemonSessionProvider by injecting into a copy of the PR's own 21 027-line test file so the probes inherit its real provider mount and SDK-transport mock; the single injection anchor is asserted unique before writing and the copy is deleted afterwards), and zz-verify-fix-measure.mjs (applies the H1 candidate fix, re-runs both harnesses, restores). Every check prints one CHECK | PASS|FAIL | line so the counts in assertions.json are mechanical rather than judged. Raw logs: logs/ab-head-vs-control.log, logs/ab-rerun.log, logs/mutation-matrix.log, logs/survivor-adjudication.log, logs/provider-probe.log, logs/mutation-f5.log, logs/mutation-f5-positive-control.log, logs/fix-measurement.log, logs/gate-changed-files.log, logs/typecheck-clean.log, logs/eslint-clean.log, logs/eslint-liveness.log. All scratch files were removed from the working tree afterwards; git status --porcelain and git diff --stat HEAD are both empty as of writing. One harness expectation of mine was itself wrong twice and I corrected it against measurement rather than reporting it: I first asserted the control would leak raw insight JSON (it does not — the adapter already stripped) and first scored the control's A/B cells on block id alone (which missed A6, where the adapter merges under target.id so the contaminated control picks the right id with the wrong text); both corrections are visible in the re-run log.

Flakiness gate log

rounds=5 files=4 skipped=0
file packages/web-shell/client/App.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/App.test.tsx
file packages/web-shell/client/assistant-turn-settlement.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/assistant-turn-settlement.test.tsx
file packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/daemon/session/DaemonSessionProvider.test.tsx
file packages/web-shell/client/daemon/session/actions.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/daemon/session/actions.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/web-shell/client/App.test.tsx: PPPPP
  packages/web-shell/client/assistant-turn-settlement.test.tsx: PPPPP
  packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: PPPPP
  packages/web-shell/client/daemon/session/actions.test.ts: PPPPP

verdict: pass
summary: 4 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 2 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 3 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 4 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)
round 5 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/assistant-turn-settlement.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/daemon/session/actions.test.ts: P (exit 0)

Evidence images

01-ab-head-vs-control-and-sdk-oracle

02-mutation-matrix-with-positive-control

03-survivor-adjudication-coverage-gaps

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Patrol-Run: qwen-pr-conflict/jmtxudi2ozc

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

11 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • U11-1 duplicate-terminal suppression untested (DaemonSessionProvider.tsx:1200) — already reported (round-10 deferral at DaemonSessionProvider.tsx:1148, review 5168168723)
  • U11-2 App/SplitView callback forwarding untested (SplitView.tsx:628) — already reported (R8-16; round-4 deferrals App.tsx:2978 and SplitView.tsx:491, review 5146822401; round-6 duplicate App.tsx:2979, review 5154409725)
  • U11-3 live-terminal publish path and pre-callback flush untested (DaemonSessionProvider.tsx:3815) — already reported (round-10 deferrals at DaemonSessionProvider.tsx:3633 and DaemonSessionProvider.test.tsx:10852)
  • U11-4 widened failedSessionId resolution untested (DaemonSessionProvider.tsx:4129) — already reported (round-10 deferral at DaemonSessionProvider.tsx:3953)
  • U11-5 unlisted stopReason values collapse to outcome completed (DaemonSessionProvider.tsx:5249) — already reported (round-10 deferral at DaemonSessionProvider.tsx:5038)
  • U11-6 new design doc ships English-only (docs/design/web-shell/web-shell-assistant-turn-settlement.md:1) — already reported (round-10 deferral at the same path)
  • U11-7 serverTimestamp precedence in the published timestamp untested (assistant-turn-settlement.test.tsx:148) — already reported (R2-3 comment 3953370589; R5-7 comment 3965944422)
  • U11-8 streaming early-exit semantics unpinned (assistant-turn-settlement.test.tsx:292) — already reported (round-10 deferral at assistant-turn-settlement.test.tsx:240)
  • U11-9 at-event-time snapshot read not distinguished from mount-time capture (assistant-turn-settlement.test.tsx:107) — already reported (round-9 deferral at assistant-turn-settlement.ts:152, review 5162219880)
  • U11-10 kind narrowing has no falsifying test (assistant-turn-settlement.test.tsx:130) — already reported (round-10 deferral at assistant-turn-settlement.test.tsx:132)
  • U11-11 replay fixtures omit the envelope promptId (DaemonSessionProvider.test.tsx:10893) — already reported (round-10 deferral at DaemonSessionProvider.test.tsx:20568)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: re-check of the earlier-round blocker threads R1-1, R1-2, R2-1, R3-1 (three threads), R5-1, R5-2, R7-1 and R9-1 — not independently walked this round; the outcome is not a zero-Critical verdict so the open-blocker re-check gate did not apply, and the issue-fidelity agent's fixed/superseded readings of them were not independently verified.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": did not execute packages/web-shell vitest to confirm the two added actions.test.ts cases pass in this worktree (the shared tree's dist/ prerequisites were…; "agent reverse-audit (round 3)": did not empirically probe the React flush ordering (DefaultLane → Scheduler macrotask vs. the publisher's queueMicrotask ) that makes the "reordering the provi…; "agent reverse-audit (round 3)": did not verify whether the SDK transcript reducer leaves an assistant block streaming: true when a turn is cancelled mid-stream — if it does, getSettledAssis…; "agent reverse-audit (round 1)": did not confirm that the daemon stamps envelope-level promptId on session_update / agent_message_chunk frames specifically — bridge.ts:7566-7618 read only i…; "agent reverse-audit (round 1)": did not run a probe (vitest) to demonstrate the ACK-in-flight reload interleaving of finding 1; it is code-read only., and 5 more.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Deferred under the convergence posture (round 11, not a blocker) — recorded, not requested in this round; 4 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:3820 — [probe] Critical [fails-closed] [new-surface] a repair-suppressed live terminal is never published — the replay publish is admission-gated and the reload failure path…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:2974 — [probe] Critical [fails-closed] [new-surface] a terminal replayed before the submit ACK is dropped for hosts — the admission key is bound only after the ACK and no de…
  • packages/web-shell/client/daemon/session/actions.ts:2738 — [probe] Critical [fails-closed] [new-surface] the stale-session removal retires the admission key of a still-running prompt, so a cancelled turn whose terminal arrives through repla…
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:4761 — [probe] Critical [fails-closed] [new-surface] bindPrompt is never called on the continuation admission path, so a continued turn is permanently invisible to the repla…
  • packages/web-shell/client/adapters/transcriptToMessages.ts:1825 — [probe] assistantVisibleTextOf space-joins text segments the renderer emits as separate messages, diverging from the sibling implementation's newline join
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:1201 — [review] publishedPromptSettlementsRef is never pruned for the provider lifetime, unlike the sibling admission map this same diff deletes per session
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:5225 — [review] the promptId-less-terminal guard has no falsifying test
  • packages/web-shell/client/assistant-turn-settlement.test.tsx:512 — [probe] the projection's error.code presence branch is unpinned while the sibling stopReason branch is falsifiable

Convergence: round 11 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/web-shell/client/assistant-turn-settlement.ts (findings in round 8; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (2 Critical(s)), the rate of first-time findings is not falling (this round 2, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 11 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查(原文为英文):re-check of the earlier-round blocker threads R1-1, R1-2, R2-1, R3-1 (three threads), R5-1, R5-2, R7-1 and R9-1 — not independently walked this round; the outcome is not a zero-Critical verdict so the open-blocker re-check gate did not apply, and the issue-fidelity agent's fixed/superseded readings of them were not independently verified.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"did not execute packages/web-shell vitest to confirm the two added actions.test.ts cases pass in this worktree (the shared tree's dist/ prerequisites were…"agent reverse-audit (round 3)"did not empirically probe the React flush ordering (DefaultLane → Scheduler macrotask vs. the publisher's queueMicrotask ) that makes the "reordering the provi…"agent reverse-audit (round 3)"did not verify whether the SDK transcript reducer leaves an assistant block streaming: true when a turn is cancelled mid-stream — if it does, getSettledAssis…"agent reverse-audit (round 1)"did not confirm that the daemon stamps envelope-level promptId on session_update / agent_message_chunk frames specifically — bridge.ts:7566-7618 read only i…"agent reverse-audit (round 1)"did not run a probe (vitest) to demonstrate the ACK-in-flight reload interleaving of finding 1; it is code-read only.,另有 5 条。

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

收敛姿态下延后(第 11 轮,非阻断)——已记录,本轮不要求修改;其中 4 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 8 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 11 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/assistant-turn-settlement.ts(第 8 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 2 条 Critical),首次发现的速率没有下降(本轮 2,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +50 to +52
block?.kind !== 'assistant' ||
block.parentToolCallId !== undefined ||
block.promptId !== promptId

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R11-1: [certifies-falsely] [new-surface] The backward scan that picks the turn's final assistant message excludes subagent-owned blocks, foreign prompt ids, streaming blocks and empty visible text — but not the meta.source values Web Shell itself renders as role: 'system' notices rather than assistant output. The in-repo answer to this same question does exclude them: daemon/session/turn-notification-context.ts:242-243 filters block.meta?.source !== 'background_notification' && block.meta?.source !== 'vision_bridge_notice', and adapters/transcriptToMessages.ts:488-520 renders both sources as role: 'system', variant: 'info'.

A vision_bridge_notice is emitted inside the user's prompt turn, before the model call, as role: 'assistant' with qwenDiscreteMessage: true (cli/src/acp-integration/session/emitters/MessageEmitter.ts:194-217, from Session.ts:#applyBridgeConversionsIfNeeded). The bridge stamps promptId: entry.activePromptId on every frame of that turn (acp-bridge/src/bridgeClient.ts:1068-1075), and qwenDiscreteMessage: true defeats the SDK's merge predicate (sdk-typescript/src/daemon/ui/transcript.ts:913,931), so the notice becomes its own top-level assistant block carrying this prompt's id — non-streaming, non-empty. If the turn then ends before the model produces any assistant text — a cancel right after the notice, the session token limit returning { responseStream: null, stopReason: 'max_tokens' } (Session.ts:8169), or a hard turn_error — the scan finds that notice as the last stamped block and publishes it as the turn's answer. The host receives message.content: 'Vision bridge: converted 1 image…', a system notice, for a turn whose real answer never existed; and because publishPromptSettlement burns the (sessionId, promptId) key before the listener runs, no corrected callback can follow. This contradicts the contract field the diff itself adds — "Final visible assistant message when retained in the mounted transcript" (customization.tsx:243).

Witness:

VISION_BRIDGE_NOTICE_ONLY_LAST: {"sessionId":"session-1","promptId":"prompt-live","outcome":"cancelled",
 "stopReason":"cancelled","message":{"id":"notice-2","content":"Vision bridge: converted 1 image",
 "isStreaming":false,"timestamp":1}}
VISION_BRIDGE_COMPLETED: {"sessionId":"session-1","promptId":"prompt-live","outcome":"completed",
 "stopReason":"max_tokens","message":{"id":"notice-1","content":"Vision bridge: converted 1 image",...}}

flip (the two meta.source exclusions added to this guard):
VISION_BRIDGE_NOTICE_ONLY_LAST: {...,"message":{"id":"assistant-1","content":"Real answer text.",...}}
VISION_BRIDGE_COMPLETED: {"sessionId":"session-1","promptId":"prompt-live","outcome":"completed",
 "stopReason":"max_tokens"}   <- no message at all
Tests 17 passed (17)   (4 probe + the 13 existing projection tests, all still green with the fix applied)
Suggested change
block?.kind !== 'assistant' ||
block.parentToolCallId !== undefined ||
block.promptId !== promptId
block?.kind !== 'assistant' ||
block.parentToolCallId !== undefined ||
block.promptId !== promptId ||
block.meta?.source === 'background_notification' ||
block.meta?.source === 'vision_bridge_notice'

Only vision_bridge_notice is live here. background_notification was traced as not reachable: its drain is gated on !this.pendingPrompt twice (Session.ts:10183-10190, :10202-10205) and settleActivePromptState deletes entry.activePromptId when the prompt settles (bridge.ts:3705-3707), so those frames are published with no active prompt to stamp and the existing block.promptId !== promptId term already excludes them — which is exactly the case the rationale comment above this guard argues for. Adding that term is defensive parity with the sibling implementation, not a second fix. The exclusion set to match is turn-notification-context.ts:242-243.

Please add the witness test beside does not return subagent-owned assistant text as the turn answer: a prompt-stamped vision_bridge_notice block trailing a real answer must not win the selection, and a notice that is the only stamped block must publish no message. Removing either meta?.source term from the guard should turn it red.

中文说明

挑选本回合最终 assistant 消息的反向扫描,排除了 subagent 所属 block、外部 prompt id、streaming block 和可见文本为空的 block——但没有排除 Web Shell 自己渲染成 role: 'system' 提示(而非 assistant 输出)的那些 meta.source。仓库里回答同一个问题的代码是排除的:daemon/session/turn-notification-context.ts:242-243 过滤了 block.meta?.source !== 'background_notification' && block.meta?.source !== 'vision_bridge_notice',而 adapters/transcriptToMessages.ts:488-520 把这两种 source 都渲染为 role: 'system'variant: 'info'

vision_bridge_notice 是在用户 prompt 回合内部、模型调用之前发出的,形如 role: 'assistant' 且带 qwenDiscreteMessage: truecli/src/acp-integration/session/emitters/MessageEmitter.ts:194-217,来自 Session.ts:#applyBridgeConversionsIfNeeded)。bridge 会给该回合的每一帧盖上 promptId: entry.activePromptIdacp-bridge/src/bridgeClient.ts:1068-1075),而 qwenDiscreteMessage: true 会让 SDK 的合并判定失效(sdk-typescript/src/daemon/ui/transcript.ts:913,931),于是这条提示成为一个独立的顶层 assistant block,带着本 prompt 的 id,且非 streaming、非空。如果回合在模型产出任何 assistant 文本之前就结束——提示发出后立即取消、会话 token 上限返回 { responseStream: null, stopReason: 'max_tokens' }Session.ts:8169)、或硬性 turn_error——扫描就会把这条提示当作最后一个带戳 block,并作为该回合的答案发布出去。宿主会收到 message.content: 'Vision bridge: converted 1 image…',也就是一条系统提示,而这个回合真正的答案从未存在;并且由于 publishPromptSettlement 在监听器运行之前就烧掉了 (sessionId, promptId) 键,后续无法再发出更正的回调。这与本 diff 自己新增的契约字段相矛盾——"Final visible assistant message when retained in the mounted transcript"(customization.tsx:243)。

(上方证人证据为程序输出,按原样保留不译。)

(上方 suggestion 代码块不重复翻译。)

这里只有 vision_bridge_notice 是真实可达的。background_notification 经追踪不可达:它的排空在 Session.ts:10183-10190:10202-10205 两处都以 !this.pendingPrompt 为前置条件,且 settleActivePromptState 会在 prompt 结束时删除 entry.activePromptIdbridge.ts:3705-3707),所以这些帧发出时没有可盖戳的 active prompt,现有的 block.promptId !== promptId 一项就已经把它们排除了——这正是该守卫上方注释所论证的情形。因此加上这一项是与同类实现保持防御性一致,而不是第二处修复。要对齐的排除集合是 turn-notification-context.ts:242-243

请在 does not return subagent-owned assistant text as the turn answer 旁边补上这个证人测试:一个带本 prompt 戳、位于真实回答之后的 vision_bridge_notice block 不应赢得选择;而当该提示是唯一带戳 block 时,不应发布 message。从守卫中移除任一 meta?.source 项,该测试都应变红。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[REAL at 64a996b] — verified by reading the head, and by reading each cited precedent.

Own evidence

  • The guard really has no meta.source term: packages/web-shell/client/assistant-turn-settlement.ts:49-53 is only block?.kind !== 'assistant' || block.parentToolCallId !== undefined || block.promptId !== promptId — read at this head.
  • The precedent does exclude both sources: packages/web-shell/client/daemon/session/turn-notification-context.ts:242-243 reads block.meta?.source !== 'background_notification' && block.meta?.source !== 'vision_bridge_notice'.
  • The notice renders as a system notice, not assistant output: packages/web-shell/client/adapters/transcriptToMessages.ts:506-517 maps meta['source'] === 'vision_bridge_notice' to role: 'system', variant: 'info'. assistantVisibleTextOf (:1817-1827) only strips insight frames, so a notice's text survives the visibility filter.
  • Emission/stamping chain confirmed: packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts:194-220 (role: 'assistant', source: 'vision_bridge_notice', qwenDiscreteMessage: true); packages/acp-bridge/src/bridgeClient.ts:1068-1075 stamps promptId: entry.activePromptId on that turn's frames; packages/sdk-typescript/src/daemon/ui/transcript.ts:913,931 let a qwenDiscreteMessage block survive as its own top-level assistant block. The module comment cites the SDK as the source of its exclusion terms — true for what it copied, but the SDK's own findFinalVisibleAssistantForPrompt (packages/sdk-typescript/src/daemon/ui/transcript.ts:935-951) has no meta.source term either, so the notification path is the correct precedent, as the finding says. I did not run the witness end to end (no node_modules in this read-only worktree); the verdict rests on the mechanism read at this head.

No code change this round — the closeout scope fuse is met twice: the diff is 1549 insertions / 11 deletions against merge-base ee6003f5 (git diff --shortstat), and these are R11 findings arriving after fix commits on 09-09/09-10 plus a 09-12 main merge with the review still not converging. So this round is reply + thread organisation only; the PR is human-gated.

What a human has to settle: the definition of the contract field this diff adds — customization.tsx says "final visible assistant message", and a vision_bridge_notice is assistant-authored and stamped to this prompt, yet it is not an answer. Decide whether the exclusion set is "every source the render adapter maps to role: 'system'" (one shared, exported list) or per-consumer lists — that choice is what makes this class of gap closeable once instead of once per consumer.

Comment on lines +2973 to +2976
replaySettlement &&
locallyBoundPromptIdsRef.current
.get(replaySettlement.sessionId)
?.has(replaySettlement.promptId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R11-2: [certifies-falsely] [new-surface] This replay publish has no equivalent of the live path's !repairTargetsTerminal guard, so a load that arms a live-journal repair for the same prompt whose terminal it is replaying publishes a settlement whose retained transcript the provider already knows is incomplete. The live path deliberately withholds publication in exactly that state so the host reads the repaired projection (:3820-3822); the replay path publishes unconditionally.

Concretely: a reconnect load replays turn_complete for prompt P while the same load records a recoverable history_truncated { scope: 'live_journal' } repair episode targeting P. The host then receives onAssistantTurnSettled with a message built from the truncated projection, and because publishPromptSettlement burns the (sessionId, promptId) key before the listener runs, the corrected post-repair answer can never be delivered. This is the inverse asymmetry of the repair-suppression gap already recorded against the live path: that one suppresses too much and loses the settlement, this one suppresses too little and certifies a partial answer as the turn's final visible message. The module's own comment states why that is unrecoverable — "publishing partial text is unrecoverable, as no corrected callback can follow" (assistant-turn-settlement.ts:57-58).

Witness:

The asymmetry, read off the reviewed commit:
 live path  DaemonSessionProvider.tsx:3820-3822
   if (settlement && !repairTargetsTerminal) { publishPromptSettlement(settlement); }
 replay path DaemonSessionProvider.tsx:2972-2979
   if (replaySettlement && locallyBoundPromptIdsRef.current.get(...)?.has(...)) {
     publishPromptSettlement(replaySettlement);        <- no repair check
   }
Reproduced end to end: the replay arm publishes while a repair episode targets the same
promptId, and the delivered message.content is the truncated projection.
baseline: the entire replay-publish block is `+` lines in this diff (@@ -2887,6 +2958,25 @@);
it does not exist at the merge base.

The fix should give the replay path the same repair awareness as the live path — when the load arms, or already holds, a live-journal repair episode targeting this settlement's promptId, defer publication until the repair resolves rather than publishing the truncated projection:

// inside the replay loop, collect instead of publishing when a repair targets this prompt
if (replaySettlement && locallyBoundPromptIdsRef.current
      .get(replaySettlement.sessionId)?.has(replaySettlement.promptId)) {
  if (repairTargetsPrompt(replaySettlement.promptId)) {
    deferredRepairSettlements.push(replaySettlement);
  } else {
    publishPromptSettlement(replaySettlement);
  }
}

Any deferral must still route through publishPromptSettlement so the dedup key and the unbind run exactly once, and it must not re-read the replay snapshot: activeSession.consumeReplaySnapshot() at :3000 releases it (its own comment notes snapshots "can reach tens of MiB"). replaySettlement is already materialized, so deferring it needs no second read. The premise a fix must not violate is assistant-turn-settlement.ts:57-58 together with the key burn at :1200-1201 — publishing eagerly and correcting later cannot work, which is why the deferral, not an extra publish, is the shape that closes this.

Please add a case beside the existing settlement block: a replay snapshot carrying both turn_complete for prompt-1 and a recoverable history_truncated { scope: 'live_journal' } marker for the same prompt, asserting the listener either receives nothing until the repair resolves or receives a message.content equal to the repaired text. Removing the new guard should turn it red.

中文说明

这处 replay 发布没有 live 路径那样的 !repairTargetsTerminal 守卫,因此当一次加载既为某个 prompt 建立了 live-journal 修复(repair)episode、又在同一批 replay 中回放该 prompt 的终态时,它会把一个 provider 已知保留 transcript 不完整的 settlement 发布出去。live 路径在这种状态下是故意不发布的,好让宿主读到修复后的投影(:3820-3822);而 replay 路径无条件发布。

具体场景:一次重连加载回放了 prompt P 的 turn_complete,同时这次加载又记录了一个针对 P 的、可恢复的 history_truncated { scope: 'live_journal' } 修复 episode。于是宿主收到 onAssistantTurnSettled,其 message 是用被截断的投影构造的;并且由于 publishPromptSettlement 在监听器运行之前就烧掉了 (sessionId, promptId) 键,修复后更正的答案永远无法再投递。这与已记录在 live 路径上的「修复抑制」缺口恰好是反向的不对称:那一个抑制得太多、丢掉了 settlement,这一个抑制得太少、把部分答案当作该回合最终可见消息认证了出去。模块自己的注释说明了为什么这不可恢复——"publishing partial text is unrecoverable, as no corrected callback can follow"(assistant-turn-settlement.ts:57-58)。

(上方证人证据为代码引用与程序输出,按原样保留不译;上方代码块不重复翻译。)

修复应当让 replay 路径具备与 live 路径同样的修复感知能力——当本次加载建立了、或已持有一个针对该 settlement 的 promptId 的 live-journal 修复 episode 时,把发布推迟到修复完成,而不是发布被截断的投影。任何推迟仍必须走 publishPromptSettlement,以保证去重键与 unbind 恰好执行一次;并且不得重新读取 replay 快照::3000activeSession.consumeReplaySnapshot() 会释放它(其注释指出快照"可达数十 MiB")。replaySettlement 已经构造完成,推迟它不需要二次读取。修复不能违反的前提是 assistant-turn-settlement.ts:57-58 以及 :1200-1201 的键烧除——先发布再更正是行不通的,这也是为什么能闭合此问题的是「推迟」而不是「多发布一次」。

请在现有 settlement 测试块旁边补一个用例:一个 replay 快照同时携带 prompt-1turn_complete 和针对同一 prompt 的可恢复 history_truncated { scope: 'live_journal' } 标记,断言监听器要么在修复完成前什么都收不到,要么收到的 message.content 等于修复后的文本。移除新增守卫时该测试应变红。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[REAL at 64a996b] — verified by reading the head; the two paths are asymmetric as described.

Own evidence

  • Replay publish, read at this head: packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:2972-2978 — the gate is only the admission check (locallyBoundPromptIdsRef.current.get(replaySettlement.sessionId)?.has(replaySettlement.promptId)) then publishPromptSettlement(replaySettlement); no repair term.
  • Live publish: :3619-3624 computes repairTargetsTerminal from liveJournalRepairRef.current, and :3820-3821 publishes only if (settlement && !repairTargetsTerminal). The asymmetry is real.
  • Repair armed in the same load, before the replay loop: :2846-2880 sets liveJournalRepairRef.current from replayTarget (with terminalSeen: false), while the replay loop runs at :2947 over replayEvents = [...compactedReplay, ...liveJournal] (:2545).
  • Co-occurrence is coherent, not hypothetical: findLiveJournalRepairTarget (packages/web-shell/client/daemon/session/live-journal-repair.ts:21-46) derives the target promptId from the history_truncated { scope: 'live_journal' } marker, and the sibling findLiveJournalRepairSuffix (:48-64) exists precisely to locate that prompt's user chunk and turn_complete/turn_error inside the same replay events — i.e. replayed terminal + armed repair for one prompt is an expected shape.
  • The key burn is where the finding says: publishPromptSettlement at :1192-1201 computes the key, unbinds, returns early if already published, and the listener runs later in a queueMicrotask — so an eager replay publish cannot be corrected afterwards.

No code change this round — the fuse is met twice: 1549 insertions / 11 deletions versus merge-base ee6003f5, and these are R11 findings landing after fixes on 09-09/09-10 and a 09-12 main merge without convergence. Reply + thread organisation only; the PR is human-gated. I did not execute the end-to-end replay case (read-only worktree, no node_modules).

What a human has to settle: one rule for both paths, not two. The live path suppresses too much (loses the settlement), the replay path suppresses too little (certifies a truncated projection) — so the decision is whether a repair-targeted prompt publishes nothing until the repair resolves (deferral, which needs a resolution hook the provider does not currently have) or nothing at all from that load. It also has to decide whether deferral may outlive the load at all, given activeSession.consumeReplaySnapshot() at :3000 releases the snapshot the settlement was built from.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Integrity audit of this round's resolved review threads (run jmtyu3iax0x) at head 64a996be32.

Four threads were marked resolved with the bot's [Critical] as the only comment on record — no author reply. All four are now answered:

All four stay resolved; no code changed and nothing was pushed. The two remaining open Criticals stay at human-gate. Open maintainer decision: this PR is scope-fused (1549 additions, over the 1500-line ceiling), so no code lands this round.

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.

feat(web-shell): expose assistant turn settlement lifecycle

3 participants