Skip to content

feat(web-shell): take back a cancelled prompt that produced nothing - #13488

Merged
wenshao merged 5 commits into
mainfrom
feat/web-shell-esc-withdraw-cancelled-prompt
Oct 6, 2026
Merged

wenshao merged 5 commits into
mainfrom
feat/web-shell-esc-withdraw-cancelled-prompt

Conversation

@wenshao

@wenshao wenshao commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

When you cancel a prompt in the Web Shell before the turn has produced anything — double Esc or the stop button, typically right after noticing the prompt was wrong — the prompt now goes back into the composer (text, pasted images, files and tags) and the turn is removed from the session. Before, the prompt stayed in the transcript and in the model's history.

It only applies when taking the prompt back cannot lose anything:

  • The cancelled turn is the prompt this tab just sent from the composer. A slash command, a retry, a queued prompt, or a turn another client started keeps today's plain stop.
  • Nothing but thinking and notices came back. An answer, a tool call, shell output or a permission request keeps the plain stop.
  • The composer is empty and no follow-up is queued, so a draft is never overwritten.

Nothing is decided when the cancel returns. The take-back waits for the daemon's terminal event for the turn (turn_complete / turn_error), which the daemon publishes after every block the turn produced and which the client applies to the transcript before it publishes the settlement — so output that was still in flight when the cancel landed is seen, and keeps the turn. A turn that finished or failed on its own keeps its result. The turn is recognised by its prompt id, or — when Esc Esc came before the admission response reached the tab — as the next turn of this client's to settle (the daemon stamps every terminal frame with the submitting client).

While the turn is rewound, prompts are held back: the hold starts before the snapshots are read and lifts once the session_rewound event has shown up in the transcript, when the daemon refuses the rewind, or when no rewind is issued. If the rewind call fails for any other reason — request or response lost, client-side timeout — the daemon may still apply it (it cannot take back a rewind it has dispatched), so no timer releases the hold; the daemon is asked instead. It lists a turn's snapshot only while the turn is in its history and never reuses a snapshot id, and — new on the daemon side — it answers the listing only after every rewind admitted before the call has run: a rewind waits its turn on the session's queue (behind a branch, say) and the status read used to bypass that queue. So the target still listed means no rewind happened and prompts are released — on two readings, since the first can be answered while the daemon is still reading the rewind's own request; the target gone means it did, and the hold lasts until the transcript shows it. While the daemon cannot be reached it is asked again (0.5 s, 1 s, 2 s). During the hold the composer is read-only: typing is not accepted and Enter sends nothing; an existing draft stays. This is the hold the inline edit-and-resend path already uses; both now share it.

When the session cannot be rewound — older history is not loaded in this tab, the workspace is SSH, or the daemon had not yet forwarded the prompt to the model — the prompt still returns to the composer and the turn stays in history as it does today. A failed rewind is silent, since nobody asked for one.

The session recovery status is now re-read when a rewind lands, so the "previous request was interrupted · Continue execution" banner does not outlive the turn it described.

Why it's needed

Reported behaviour: type a prompt, notice immediately that it is wrong, press Esc twice — and it is still in history. Measured against a real daemon, that has two consequences on main:

  1. The transcript keeps the mistaken prompt, plus an "interrupted" banner offering to continue it.
  2. The corrected prompt that follows is sent to the model merged with the mistaken one. The scripted model received "HOLD oops typo S1\nfixed prompt S1" as a single user message.

The TUI already takes a cancelled prompt back (restore-on-cancel in AppContainer.tsx), including treating thoughts as "nothing produced". The Web Shell had no equivalent, so the two surfaces disagreed.

Reviewer Test Plan

How to verify

  1. npm run build && npm run bundle, start qwen serve, open the Web Shell.
  2. Send a prompt and let it finish. Send a second one and press Esc twice before the model answers (a slow or reasoning model makes this easy), or click stop.
  3. Expected: the second prompt disappears from the transcript and is back in the composer, with no "interrupted" banner. Send a corrected prompt: only that one is in history, also after a page reload.
  4. Repeat, but wait until the answer starts streaming: plain stop, as today.
  5. Repeat, but type a draft while the turn runs: plain stop, draft untouched.
  6. Repeat with a model that answers about a second in, cancelling right as the answer lands: whenever the answer is in the transcript, the turn stays.

Focused tests:

cd packages/web-shell
npx vitest run --config vitest.config.ts client/utils/cancelledTurn.test.ts
npx vitest run --config vitest.config.ts client/App.test.tsx -t "cancelling a prompt before it produced anything"
npx vitest run --config vitest.config.ts client/daemon/session/DaemonSessionProvider.test.tsx -t "refreshes recovery after|carries the terminal frame originator"
npx vitest run --config vitest.config.ts client/daemon/session/actions.test.ts -t "failed rewind calls"

Evidence (Before & After)

Real stack: the qwen serve bundle built from this branch, the real Web Shell in Chromium (Playwright), and a scripted OpenAI-compatible model that can stay silent, stream only reasoning, stream text, or call a tool. "main" is the same daemon serving the client bundle built from main.

Esc twice before the model answers: before and after

Sending the corrected prompt: before and after

A prompt with a pasted image: before and after

Raw results for every scenario and the harness (scripted model, Playwright driver): pr-13488.

Scenario main this PR
Model still silent, Esc Esc prompt stays, composer empty, banner shown prompt back in composer, turn gone, no banner
Then send the corrected prompt model receives ["hello first", "HOLD oops typo S1\nfixed prompt S1"]; reload shows both prompts model receives ["hello first", "fixed prompt S1"]; reload shows only the corrected one
Only reasoning streamed, Esc Esc prompt and thoughts stay taken back
Stop button instead of Esc prompt stays, banner shown taken back
First prompt of a new session prompt stays, banner shown taken back
Prompt with a pasted image prompt and image stay, composer empty text and image back in the composer; the resend carries the image once
Enter, Esc, Esc with no pause — taken back; model receives only the corrected prompt
Twelve take-backs in a row in one session, then a real prompt — 12/12 taken back, no banner, no toast; model receives ["hello first", "final real prompt S13"]
Second tab watching the same session — the prompt disappears there too; its composer is untouched
Answer text already streaming plain stop plain stop (unchanged)
A tool already ran plain stop plain stop (unchanged)
Draft typed during the turn plain stop plain stop, draft untouched
Follow-up queued during the turn plain stop, queue drains plain stop, queue drains (unchanged)
Another tab stops this tab's prompt plain stop plain stop in both tabs, neither composer is filled
Page reloaded mid-turn, then Esc Esc plain stop plain stop (the tab no longer owns the prompt)

Also measured over the daemon HTTP API directly: a rewind sent the instant POST /session/:id/cancel returns succeeded 27 times out of 27 (silent and reasoning-only turns, cancelled 20–320 ms after admission), so the take-back needs no wait or retry.

Review round 2 (decide on the turn's terminal event; hold prompts during the rewind). All scenarios above were re-run on the same real stack with the client built from d65f8b171f, plus two new ones for the races the review raised; raw results, including an intermediate build and the first full pass, are in pr-13488/round2.

Scenario this PR (round 2)
Answer lands as the cancel does (model answers at 900 ms; Esc Esc at 700–1200 ms, 10 runs) 0 violations: in the 7 runs where the model had delivered its answer, the answer and the prompt stayed; in the other 3 the turn was taken back
Correction typed and sent the instant the prompt is back (≈130 ms later) sent once, not merged, not lost: model receives ["hello first", "fixed prompt S16"], same after a reload
Enter, Esc, Esc with no pause (the cancel cuts the admission response short) taken back via the originator stamp; model receives only the corrected prompt. An intermediate build that recognised the turn by prompt id only kept this turn and merged the resend — this scenario caught it (results-round2-promptid-only-build.json)
The other 14 scenarios as in the table above

One stop-button run in the first full pass of round 2 stopped the turn without taking it back (the daemon log shows no snapshot read after the terminal). It did not recur in a second full pass, 20 isolated runs and 4 back-to-back runs with the preceding scenario; the raw result is kept as results-this-pr-round2-first-pass.json.

Review round 3 (a rewind of unknown outcome must not release the hold on a timer). The 16 scenarios above were re-run on the same real stack with the client built from ccdac0bdf5, plus three in which the browser loses the rewind request or its response (Playwright route interception in front of the real daemon; the composer's contenteditable is polled to time the hold); raw results and the harness are in pr-13488/round3.

Scenario this PR (round 3)
Rewind request lost before it reaches the daemon (POST …/rewind aborted in the browser) composer read-only from the restore on; the daemon was asked at +41 ms and +546 ms after the restore and listed the turn both times; released at +565 ms. Text typed and Enter pressed during the hold changed nothing and sent nothing. The correction sent afterwards reaches the model merged with the kept turn, as on main — the daemon never saw a rewind
Daemon applies the rewind, browser loses the response (route.fetch() then abort) daemon answered 200 at +34 ms; the one reading at +41 ms no longer listed the turn, so the daemon's answer released nothing; the hold lifted when session_rewound reached the transcript; model receives ["hello first", "fixed prompt S17b"]
Request lost, daemon unreachable for the next two readings readings failed at +32 ms and +535 ms, succeeded at +1540 ms and +2046 ms (0.5 s, then 1 s, then the 0.5 s recheck); released at +2064 ms — later than the former 2 s timer, and only after the daemon had answered twice. Nothing typed during the hold got through
The other 16 scenarios as in the tables above

Review round 4 (the listing must be ordered after a rewind the bridge has admitted but not yet dispatched). The bridge now records the settled state of the last admitted rewind on the session entry and getRewindSnapshots waits for it before asking the agent. Bridge tests with the in-memory ACP channel: a rewind admitted behind a gated branch — the listing requested meanwhile is not answered until the branch has released and the rewind has run, and the agent then sees branch, rewind, rewind_snapshots in that order; a rewind that failed does not block later listings. Dropping the wait fails the first test. The 19 scenarios were re-run on the real stack with the bundle rebuilt from 16bd737bd3 (raw results in pr-13488/round4): all as before — twelve take-backs in a row 12/12, answer-lands-as-the-cancel-does 0 violations, request lost released at +571 ms after two listings, response lost held until the event (model receives ["hello first", "fixed prompt S17b"]), daemon unreachable released at +2063 ms after two answers.

In the first full pass of round 3, one of the twelve take-backs in a row (round 7, a reasoning-only turn) stopped without being taken back; the following rounds were taken back and the final prompt reached the model merged with that one kept turn, as on main. The round did not recur in a second full pass or in 9 isolated runs of the scenario (108/108 rounds); the raw result is kept as results-this-pr-round3-first-pass.json. Together with the round-2 stop-button miss this is 2 misses in 2 × (16 + 12 × 2) + 108 take-backs, both a plain stop rather than a wrong rewind.

Tests:

  • packages/web-shell unit suite: 412 files, 10822 tests, all passing. packages/acp-bridge suite: 54 files, 2597 tests, all passing.
  • The races from the review are unit tests against the real App: an answer whose event arrives after the cancel returned; a correction submitted before the snapshots are read and before the rewind reached the transcript; the hold's release on a daemon refusal and on no rewind; after an unknown rewind failure, the hold kept until the event arrives however late when the daemon no longer lists the turn, released once the daemon has twice listed it as still there, kept asking while the daemon cannot be reached, no longer asking once the transcript shows the rewind, and released at once when the snapshots could not be read because no rewind was issued; a turn that completed or failed on its own; another prompt's or another client's settlement; a settlement that arrives too late.
  • Each guard in the take-back path was mutated in turn (28 mutations: the 17 from round 1 that still apply, plus settlement outcome, timeout, prompt-id and originator matching, deciding at cancel time instead of at settlement, the prompt-id stamp, hold set, hold release, refusal branch, write-blocked bail, newest-snapshot, and arming without a prompt id). Every mutation fails at least one of the new tests; dropping the originator from the settlement fails the provider's new test. Round 3 added 11 mutations of the outcome check (one reading releases, gone means not rewound, read failure releases, no stop once the hold lifted — twice, confirming without an issued rewind, confirming on a refusal, releasing on an unknown failure, never counting the first reading, releasing after a landed rewind, flat backoff): 10 fail a test; the flat backoff survives, as it only paces the retries.
  • Web Shell @smoke Playwright suite (mock daemon): 212 of 213 passed in a full local run. The one failure, web-shell.split-persist.spec.ts "keeps title details inside narrow panes" (a hover popover in split view), passed 5/5 when re-run on its own and does not touch the cancel path. The existing "mobile stop remains reachable with a queued draft" spec passes.

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux ✅

macOS and Windows were not run locally; the change is browser-side only and is left to CI there.

Environment (optional)

Linux x86_64, Node 22, npm run build && npm run bundle, qwen serve with an isolated QWEN_HOME, Chromium via Playwright 1.61.

Risk & Scope

  • Main risk or tradeoff: an automatic rewind removing the wrong turn. It only ever rewinds the daemon's newest snapshot, and only when that snapshot's index equals this tab's newest user turn with the whole history loaded, the turn's blocks are thoughts and notices, and the prompt is the block this tab sent. The check runs on the daemon's terminal event for the turn, so output still in flight when the cancel landed is counted. Files are never rewound (rewindFiles: false); no tool ran in such a turn anyway.
  • Prompts are held while the rewind is in flight (snapshot read, rewind call, its event — tens of milliseconds locally); Enter during the hold is refused and the draft stays. If the daemon rewound but its event never arrives, the hold lasts until the transcript catches up (reconnect or reload), as with the inline edit path. After a rewind failure of unknown outcome the hold lasts until the daemon has twice listed the turn as still there (about 0.5 s when reachable, longer while it is not), or until the transcript shows the rewind; a refusal does not hold at all. During the hold the composer is read-only. The listing is ordered after every rewind the bridge has admitted, so what remains is a rewind request that reaches the daemon only after the second reading was answered — more than half a second after the browser reported it failed (a timed-out request has been at the daemon for 30 s by then; a request the browser aborted is either already there or never arrives).
  • Not validated / out of scope: split-view panes (ChatPane) keep the plain stop. Sessions whose older history is not loaded in the tab, and SSH workspaces (which reject rewind), get the prompt back in the composer but keep the turn in history. Taking back the first prompt of a new session leaves an empty session titled after that prompt. SSH workspaces were not exercised against a real remote.
  • Breaking changes / migration notes: none. The bridge's getRewindSnapshots now waits for rewinds admitted before it, so a listing requested while a rewind is queued behind a branch is answered after both have run. The getRewindSnapshots and rewindSession session actions gain an optional silent flag, following the existing silent option on getContextUsage and getTasks. SendPromptOptions.onAdmitted now receives { promptId } (callers that ignore the argument are unaffected). DaemonPromptSettledEvent gains originatorClientId; the host-facing onAssistantTurnSettled projection is unchanged.

Linked Issues

None.

中文说明

这个 PR 做了什么

在 Web Shell 里,如果一条 prompt 在本轮还没有任何产出时就被取消(连按两次 Esc 或点停止按钮,典型场景是刚发出去就发现写错了),这条 prompt 现在会回到输入框(文本、粘贴的图片、文件和标签),并且这一轮会从会话中移除。此前它会留在 transcript 和模型历史里。

只有在撤回不会丢任何东西时才生效:

  • 被取消的是当前标签页刚从输入框发出的 prompt。斜杠命令、重试、排队的 prompt、其他客户端发起的轮次,仍然只是普通的停止。
  • 返回的只有思考和提示信息。已经有回答、工具调用、shell 输出或权限请求时,仍然只是普通的停止。
  • 输入框为空且没有排队的后续消息,因此不会覆盖草稿。

取消返回时什么都不决定。撤回会等待 daemon 对这一轮的终点事件(turn_complete / turn_error)——daemon 在这一轮产生的所有内容块之后才发布它,客户端也先把这些块应用到 transcript、再发布结算事件——因此取消落地时仍在路上的输出会被看到,并让这一轮保留。自行完成或自行失败的轮次保留其结果。识别这一轮靠 prompt id;若 Esc Esc 发生在 admission 响应到达标签页之前,则按"本客户端下一个结算的轮次"识别(daemon 给每个终点帧都盖了提交方客户端的戳)。

回退进行期间会暂扣新 prompt:从读取快照之前开始,到 session_rewound 事件在 transcript 里出现、或 daemon 拒绝回退、或根本没有发起回退时解除。若回退调用因其他原因失败(请求或响应丢失、客户端超时),daemon 仍可能已执行它(已派发的回退无法撤销),所以不会由计时器解除暂扣,而是去问 daemon:它只在某一轮仍在历史里时才列出该轮的快照,快照 id 永不复用,并且——daemon 侧新增——只在此前已接纳的每个回退都执行完之后才应答列表:回退要在会话队列上排队等候(比如排在 branch 之后),而状态读取此前绕过了这个队列。于是目标仍被列出即说明回退没有发生,放行;要读两次,因为第一次读取可能在 daemon 还在读取回退请求本身时就被应答;目标消失即说明回退已发生,暂扣持续到 transcript 显示它为止。daemon 不可达时会反复询问(0.5 s、1 s、2 s)。暂扣期间输入框为只读:不接受输入、Enter 不发送任何内容;已有草稿保留。这正是内联"编辑并重发"路径已有的暂扣机制,两者现在共用。

当会话无法回退时(当前标签页没有加载完较早的历史、SSH 工作区、或 daemon 尚未把 prompt 转发给模型),prompt 仍然会回到输入框,这一轮则像现在一样留在历史里。回退失败是静默的,因为没有人主动要求回退。

另外,回退事件到达后会重新读取会话恢复状态,这样"上一次请求被中断 · 继续执行"的横幅不会在对应轮次消失后继续残留。

为什么需要

反馈的现象:输入后立刻发现错了,连按两次 Esc,它仍然在历史里。在真实 daemon 上实测,main 上有两个后果:

  1. transcript 保留了写错的 prompt,并附带一个"被中断"的横幅,提示可以继续执行它。
  2. 随后发送的修正 prompt 会和写错的那条合并后一起发给模型。脚本模型实际收到的是一条用户消息 "HOLD oops typo S1\nfixed prompt S1"。

TUI 已经会把取消的 prompt 拿回来(AppContainer.tsx 里的 restore-on-cancel),并且同样把思考视为"没有产出"。Web Shell 没有对应行为,两端不一致。

Reviewer 测试计划

如何验证

  1. npm run build && npm run bundle,启动 qwen serve,打开 Web Shell。
  2. 发一条 prompt 并等它完成。再发第二条,在模型回答之前连按两次 Esc(用较慢或带推理的模型更容易操作),或点停止。
  3. 预期:第二条 prompt 从 transcript 消失并回到输入框,没有"被中断"横幅。发送修正后的 prompt:历史里只有这一条,刷新页面后也一样。
  4. 重复一次,但等回答开始流式输出后再取消:和现在一样只是停止。
  5. 重复一次,但在本轮运行期间输入一段草稿:只是停止,草稿不变。
  6. 换一个约一秒后才回答的模型,在回答落地的同时取消:只要回答进了 transcript,这一轮就保留。

聚焦测试:

cd packages/web-shell
npx vitest run --config vitest.config.ts client/utils/cancelledTurn.test.ts
npx vitest run --config vitest.config.ts client/App.test.tsx -t "cancelling a prompt before it produced anything"
npx vitest run --config vitest.config.ts client/daemon/session/DaemonSessionProvider.test.tsx -t "refreshes recovery after|carries the terminal frame originator"
npx vitest run --config vitest.config.ts client/daemon/session/actions.test.ts -t "failed rewind calls"

证据(前后对比)

真实环境:由本分支构建的 qwen serve bundle、Chromium 中的真实 Web Shell(Playwright),以及一个脚本化的 OpenAI 兼容模型(可以保持沉默、只流式输出推理、流式输出文本、或调用工具)。"main" 指同一个 daemon 改为提供由 main 构建的客户端 bundle。

模型回答前连按两次 Esc:前后对比

发送修正后的 prompt:前后对比

带粘贴图片的 prompt:前后对比

每个场景的原始结果和测试脚本(脚本化模型、Playwright 驱动):pr-13488。

场景 main 本 PR
模型尚未输出,Esc Esc prompt 保留,输入框为空,显示横幅 prompt 回到输入框,该轮消失,无横幅
随后发送修正的 prompt 模型收到 ["hello first", "HOLD oops typo S1\nfixed prompt S1"];刷新后两条都在 模型收到 ["hello first", "fixed prompt S1"];刷新后只有修正的那条
只流式输出了推理,Esc Esc prompt 和思考保留 撤回
用停止按钮代替 Esc prompt 保留,显示横幅 撤回
新会话的第一条 prompt prompt 保留,显示横幅 撤回
带粘贴图片的 prompt prompt 和图片保留,输入框为空 文本和图片都回到输入框;重发时图片只携带一次
Enter、Esc、Esc 之间不停顿 — 撤回;模型只收到修正后的 prompt
同一会话连续撤回十二次,再发一条真正的 prompt — 12/12 撤回,无横幅,无 toast;模型收到 ["hello first", "final real prompt S13"]
第二个标签页旁观同一会话 — 那边的 prompt 也消失;它的输入框不受影响
回答文本已在流式输出 普通停止 普通停止(不变)
已经执行过工具 普通停止 普通停止(不变)
运行期间输入了草稿 普通停止 普通停止,草稿不变
运行期间排队了后续消息 普通停止,队列继续执行 普通停止,队列继续执行(不变)
另一个标签页停止了本标签页的 prompt 普通停止 两个标签页都是普通停止,输入框都不会被填入
运行中刷新页面,再 Esc Esc 普通停止 普通停止(该标签页已不再拥有这条 prompt)

另外直接通过 daemon HTTP API 测量:在 POST /session/:id/cancel 返回的瞬间发出回退请求,27 次中成功 27 次(模型沉默和只有推理的轮次,在被接纳后 20–320 ms 取消),因此撤回不需要等待或重试。

评审第二轮(以该轮终点事件为判定点;回退期间暂扣 prompt)。上表全部场景在同一真实环境用 d65f8b171f 构建的客户端复跑,并针对评审提出的两个竞态新增两个场景;原始结果(含一个中间构建和第一遍全量)在 pr-13488/round2。

场景 本 PR(第二轮)
回答与取消同时落地(模型 900 ms 后回答;Esc Esc 在 700–1200 ms 之间,10 轮) 0 次违规:模型已交付回答的 7 轮里回答和 prompt 都保留;其余 3 轮撤回
prompt 刚回到输入框就立刻输入修正并发送(约 130 ms 后) 只发送一次,未合并,未丢失:模型收到 ["hello first", "fixed prompt S16"],刷新后一致
Enter、Esc、Esc 之间不停顿(取消切断了 admission 响应) 通过 originator 戳撤回;模型只收到修正后的 prompt。一个只按 prompt id 识别的中间构建在此场景下保留了该轮并合并了重发——正是这个场景抓到了它(results-round2-promptid-only-build.json)
其余 14 个场景 与上表一致

第二轮第一遍全量里有一次停止按钮场景只停止、未撤回(daemon 日志显示终点事件之后没有读取快照)。第二遍全量、20 次单独运行、4 次与前一场景连跑均未复现;原始结果保留为 results-this-pr-round2-first-pass.json。

评审第三轮(结果未知的回退不得由计时器解除暂扣)。上述 16 个场景在同一真实环境用 ccdac0bdf5 构建的客户端复跑,并新增三个浏览器丢失回退请求或其响应的场景(在真实 daemon 前用 Playwright 路由拦截;通过轮询输入框的 contenteditable 计时暂扣);原始结果与 harness 在 pr-13488/round3。

场景 本 PR(第三轮)
回退请求在到达 daemon 前丢失(浏览器内中止 POST …/rewind) 从回填起输入框只读;在回填后 +41 ms 和 +546 ms 两次询问 daemon,两次都仍列出该轮;+565 ms 放行。暂扣期间输入的文字和按下的 Enter 什么都没改变、什么都没发出。之后发送的修正与保留下来的那一轮合并后到达模型,与 main 一致——daemon 从未见到回退
daemon 已执行回退、浏览器丢失响应(route.fetch() 后中止) daemon 在 +34 ms 返回 200;+41 ms 的唯一一次读取已不再列出该轮,因此 daemon 的回答没有放行任何东西;暂扣在 session_rewound 到达 transcript 时解除;模型收到 ["hello first", "fixed prompt S17b"]
请求丢失且随后两次读取 daemon 不可达 读取在 +32 ms 和 +535 ms 失败,在 +1540 ms 和 +2046 ms 成功(0.5 s、1 s、再 0.5 s 复核);+2064 ms 放行——晚于原先的 2 s 计时器,且只在 daemon 两次回答之后。暂扣期间输入的内容没有发出去
其余 16 个场景 与上表一致

评审第四轮(列表必须排在 bridge 已接纳、尚未派发的回退之后)。bridge 现在把最后一个已接纳回退的完成状态记在会话条目上,getRewindSnapshots 先等它再向 agent 发起查询。用内存 ACP 通道的 bridge 测试:一个回退接纳在被闸住的 branch 之后——期间请求的列表直到 branch 放行、回退执行完才被应答,agent 侧看到的顺序是 branch, rewind, rewind_snapshots;失败的回退不会阻塞之后的列表。去掉这个等待会让第一个测试失败。19 个场景用 16bd737bd3 重新打包的 bundle 在真实环境复跑(原始结果在 pr-13488/round4):与此前一致——连续 12 次撤回 12/12,回答与取消同时落地 0 违规,请求丢失在两次列表后 +571 ms 放行,响应丢失保持到事件到达(模型收到 ["hello first", "fixed prompt S17b"]),daemon 不可达在两次回答后 +2063 ms 放行。

第三轮第一遍全量里,连续 12 次撤回中有一次(第 7 轮,只有推理的轮次)只停止、未撤回;之后各轮均撤回,最终 prompt 与那一轮保留下来的 prompt 合并后到达模型,与 main 一致。第二遍全量和该场景 9 次单独运行(108/108 轮)均未复现;原始结果保留为 results-this-pr-round3-first-pass.json。连同第二轮的停止按钮一次,共 2 次未撤回,都是普通停止而非错误回退。

测试:

  • packages/web-shell 单元测试全量:412 个文件,10822 个用例,全部通过。packages/acp-bridge 全量:54 个文件,2597 个用例,全部通过。
  • 评审提出的竞态都写成了针对真实 App 的单元测试:取消返回之后才到达事件的回答;在读取快照之前、在回退到达 transcript 之前提交修正;daemon 拒绝与未发起回退时暂扣的解除;未知回退失败之后:daemon 不再列出该轮时暂扣保持到事件到达为止、无论多晚,daemon 两次列出该轮仍在时放行,daemon 不可达时持续询问,transcript 显示回退后不再询问,以及快照读取失败(未发起过回退)时立即放行;自行完成或自行失败的轮次;其他 prompt 或其他客户端的结算;到达过晚的结算。
  • 对撤回路径上的每个守卫逐一做了变异(28 个变异:第一轮仍适用的 17 个,加上结算结果、超时、prompt id 与 originator 匹配、改回在取消时判定、prompt id 记录、暂扣设置、暂扣解除、拒绝分支、写阻塞退出、最新快照、无 prompt id 时的武装)。每个变异都至少让一个新增测试失败;从结算事件中去掉 originator 会让 provider 的新增测试失败。第三轮对结果确认逻辑新增 11 个变异(一次读取就放行、消失视为未回退、读取失败即放行、暂扣解除后不停止询问——两处、未发起回退也去确认、被拒绝也去确认、未知失败直接放行、首次读取不计数、回退已落地仍放行、退避不增长):10 个被测试杀死;退避不增长的变异存活,它只影响重试节奏。
  • Web Shell @smoke Playwright 套件(mock daemon):本地完整运行 213 个中通过 212 个。唯一失败的 web-shell.split-persist.spec.ts"keeps title details inside narrow panes"(分屏视图里的悬停弹层)单独重跑 5/5 通过,且不涉及取消路径。已有的"mobile stop remains reachable with a queued draft"用例通过。

测试平台

OS 状态
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux ✅

macOS 和 Windows 没有在本地运行;改动只在浏览器端,这两个平台交给 CI。

环境(可选)

Linux x86_64,Node 22,npm run build && npm run bundle,使用隔离 QWEN_HOME 的 qwen serve,通过 Playwright 1.61 驱动 Chromium。

风险与范围

  • 主要风险或取舍:自动回退删错轮次。它只会回退 daemon 的最新快照,并且仅当该快照的序号等于本标签页最新的用户轮次、完整历史已加载、该轮的内容块只有思考和提示信息、且 prompt 就是本标签页发出的那个块时才执行。判定在 daemon 对该轮的终点事件上进行,因此取消落地时仍在路上的输出会被计入。从不回退文件(rewindFiles: false);这样的轮次里本来也没有工具运行过。
  • 回退进行期间暂扣 prompt(读取快照、回退调用、其事件——本地为数十毫秒);期间按 Enter 会被拒绝,草稿保留。若 daemon 已回退但事件始终未到,暂扣持续到 transcript 追上为止(重连或刷新),与内联编辑路径一致。结果未知的回退失败之后,暂扣持续到 daemon 两次列出该轮仍在(可达时约 0.5 s,不可达时更久)、或 transcript 显示回退为止;被拒绝则不暂扣。暂扣期间输入框只读。列表已排在 bridge 接纳的每个回退之后,剩下的只有:回退请求在第二次读取被应答之后才到达 daemon——即浏览器报告失败半秒多之后(超时的请求此时已在 daemon 那里 30 s;浏览器中止的请求要么已经到了,要么永远不会到)。
  • 未验证 / 不在范围内:分屏视图的面板(ChatPane)仍然只是普通停止。标签页里没有加载完较早历史的会话,以及 SSH 工作区(拒绝回退),prompt 会回到输入框,但该轮仍留在历史里。撤回新会话的第一条 prompt 会留下一个以该 prompt 命名的空会话。SSH 工作区没有用真实远端验证。
  • 破坏性变更 / 迁移说明:无。bridge 的 getRewindSnapshots 现在会等待此前接纳的回退,因此在回退排在 branch 之后时请求的列表,会在两者都执行完后才应答。getRewindSnapshots 和 rewindSession 两个会话 action 新增可选的 silent 标志,沿用 getContextUsage 和 getTasks 上已有的 silent 选项。SendPromptOptions.onAdmitted 现在会收到 { promptId }(忽略参数的调用方不受影响)。DaemonPromptSettledEvent 新增 originatorClientId;面向 host 的 onAssistantTurnSettled 投影不变。

关联 Issue

无。

Cancelling a prompt right after sending it (double Esc or the stop
button) left it in the transcript and in the model's history, so the
corrected prompt that followed was sent together with the mistaken one.

When the cancelled turn is this client's own composer prompt and nothing
but thoughts and notices came back, the prompt now returns to the
composer and the turn is rewound out of the session, matching the TUI's
restore-on-cancel. A draft in the composer, a queued follow-up, an answer
or tool call, a slash command, or a turn started elsewhere keep the
plain stop.

The session recovery status is re-read after a rewind so the interrupted
banner does not outlive the turn it described.
@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

评审基于当前提交 ff617efcd960885c6c151c3ef8ad98a4cad72114。发现 2 个需要修复的问题,建议处理后再合并。

  1. [P1] 自动回退前,需要确认该轮输出已经同步。 App.tsx:17809–17817 仅根据浏览器当前的 transcript 判断“没有产出”,但取消和快照读取的 HTTP 响应不保证此前的 SSE 已被浏览器处理。如果 daemon 已产生回答或执行了工具,而对应事件延迟到达,这里仍会把该轮判为空并自动回退。测试中把 assistant 事件延迟到取消及快照读取之后,实际仍调用了 rewindSession,随后真实 transcript reducer 将回答随该轮删除。服务端的 rewindToTurn 只要求轮次已空闲,并不会再验证“没有产出”,因此本地判断失真时无法保护模型历史;工具副作用也不会被 rewindFiles: false 撤销。需要先同步到该取消轮次的确定事件边界,或由服务端原子验证撤回条件,不能将“尚未收到输出”当作“未产生输出”。

  2. [P2] 自动撤回完成并应用回退事件前,阻止新 prompt 提交。 App.tsx:17821–17844 先恢复输入框,再异步读取快照和回退,全程没有阻塞提交。用 deferred promise 暂停快照读取后立即发送修正 prompt,实际发送次数已经从 1 变为 2,之后最新消息检查使回退被跳过,错误 prompt 继续留在历史里。另一个测试让回退 HTTP 先成功、延迟 session_rewound:修正 prompt 已添加到本地 transcript 后,真实 SDK reducer 收到旧轮次的回退事件,会把修正 prompt 一并删掉,用户消息从 ["first", "oops typo", "corrected prompt"] 变成 ["first"]。这里需要覆盖整个自动撤回过程的提交阻塞,并等待回退事件应用;现有内联编辑路径的 waitForRewindApplied / pendingEditRewind 已处理同类同步问题。

验证范围:npm run build、npm run typecheck 均通过;针对改动文件运行的原有测试共 5 个文件、1852 个用例通过。额外的竞态复现使用当前提交的真实 App 和 SDK transcript reducer,mock session I/O 以确定性控制 HTTP/SSE 的先后顺序;上述 3 个安全性断言均失败。它们是测试脚本复现,不是对真实模型/daemon 的网络延迟 E2E 实测;服务端回退行为另经源码核对。

@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: merge-ready — 105/105 scripted assertions passed at head ff617efcd960885c6c151c3ef8ad98a4cad72114

Independent maintainer verification of #13488 on a fresh machine (Orange Pi 5, aarch64 Linux, Node v24.13.0, pnpm 11.24.0, Chromium via Playwright 1.61.1). Both arms built from scratch: head ff617ef and base 69d5db2 (the PR's baseRefOid), each served by its own real qwen serve bundle with an isolated QWEN_HOME, driven by real Chromium through the real Web Shell against a scripted OpenAI-compatible model that logs every request it receives.

中文摘要

结论:merge-ready(105/105 断言通过)。 在 aarch64 独立环境从零构建两臂(head ff617ef / base 69d5db2),用真实 daemon + 真实 Web Shell + 脚本化模型复测了 PR 的全部核心主张:

  • A/B 核心证据:模型尚未产出时连按两次 Esc —— head 臂 prompt 回到输入框、该轮从会话历史中消失、无"中断"横幅(01-esc-esc-before-after.png);base 臂 prompt 留在历史且修正 prompt 与错误 prompt 合并为一条消息发给模型(HOLD oops typo S1\nfixed prompt S1,02-resend-before-after.png)。head 臂修正后模型只收到 ['hello first', 'fixed prompt S1']。
  • 不回撤的情形两臂一致:回答已流式输出、工具已执行、输入框有草稿、有排队消息、其他标签页发起的轮次、刷新页面后 —— 6 个场景两臂行为完全相同(普通停止)。
  • 旁观标签页:head 臂撤回后第二个标签页同步消失,且其输入框不受影响。
  • 12 连撤:head 12/12 成功且模型最终只收到干净 prompt;base 臂因合并 bug 级联卡死(预期内)。
  • 变异验证:cancelledTurnProducedNothing 守卫强制恒 true / 恒 false 时,单测与 App 集成测试均如期转红(06-mutation-matrix.png),恢复后复绿 —— 新增测试非空转。
  • 单测:PR 列出的 4 个聚焦套件全绿(8+14+3+2);web-shell 全量 412 文件 10805 用例中 10 个失败已全部归因(6 个是我自己并行变异实验的污染、3 个负载下计时 flake、1 个 base 上同样失败的既有 BranchPickerPopover 断言),与 PR 无关。
  • 未覆盖:SSH 工作区(作者同样未覆盖)、分屏 ChatPane、daemon API 层 27/27 回退时序复测(e2e 的 S11 即时取消已覆盖等价路径)、@smoke Playwright mock-daemon 套件(CI 已绿)。

Central claim and A/B proof

Claim: cancelling a composer prompt before the turn produced anything (double-Esc / stop button) returns the prompt to the composer and rewinds the turn out of the session; everything that already produced output, has a draft, a queue, or a foreign owner keeps today's plain stop.

The harness (harness/e2e.cjs, 14 scenarios) ran identically against both arms; harness/check-results.mjs encodes per-arm expectations (base cells assert the broken behaviour — a base red cell is a pass). Witness: 04-assertions-head.png (50/50), 05-assertions-base.png (43/43).

scripted assertions, head arm
scripted assertions, base arm

Scenario base 69d5db2 head ff617ef
S1 model silent, Esc Esc prompt stays, composer empty, banner shown prompt back in composer, turn gone, no banner
S1 then send correction (wire) model receives ['hello first', 'HOLD oops typo S1\nfixed prompt S1'] model receives ['hello first', 'fixed prompt S1']
S1 reload both prompts persist only the corrected one
S2 reasoning-only, Esc Esc prompt + thoughts stay taken back
S7 stop button prompt stays, banner taken back
S8 first prompt of session prompt stays, banner taken back
S11 Enter,Esc,Esc no pause prompt stays, banner; wire merged taken back; wire clean
S12 pasted image prompt+image stay; resend wire merged incl. [image: image/png] text+image back in composer; resend carries the image marker once
S13 12 take-backs in a row cascades: each resend merges with the stalled HOLD text and never completes (expected broken) 12/12 taken back; final wire ['hello first', 'final real prompt S13']
S9 observer tab prompt stays in both tabs turn disappears in the observer too; observer composer untouched
S3 answer streaming plain stop plain stop (identical)
S4 tool already ran plain stop plain stop (identical)
S5 draft typed during turn plain stop, draft kept plain stop, draft kept (identical)
S6 follow-up queued plain stop, queue drains plain stop, queue drains (identical)
S10 other tab cancels this tab's prompt plain stop both tabs plain stop both tabs (identical)
S14 reload mid-turn then Esc Esc plain stop plain stop (identical)

Esc twice before the model answers: before and after
Sending the corrected prompt: before and after
A prompt with a pasted image: before and after

Test efficacy (vacuity check)

The PR adds 306 lines to App.test.tsx plus three smaller suites. The load-bearing guard cancelledTurnProducedNothing was mutated both ways (06-mutation-matrix.png):

mutation matrix

build cancelledTurn.test.ts App.test.tsx take-back group
unmutated control 8/8 pass 14/14 pass
M1 guard forced true (take back everything) 6 failed 1 failed (only stops the turn when the answer had started)
M2 guard forced false (never take back) 2 failed 5 failed
restored 8/8 pass —

Both mutations die against the new tests; the restored source is green. The tests pin the guard.

Targeted gates (head ff617ef)

gate result
client/utils/cancelledTurn.test.ts 8/8 pass
client/App.test.tsx -t "cancelling a prompt before it produced anything" 14/14 pass
client/daemon/session/DaemonSessionProvider.test.tsx -t "refreshes recovery after" 3/3 pass
client/daemon/session/actions.test.ts -t "failed rewind calls" 2/2 pass
packages/web-shell full unit suite 412 files, 10,805 tests: 10,795 pass; 10 failures fully attributed — 6 were contamination from my own mutation experiment running against the same tree concurrently (re-run green on a quiet machine), 3 timing-sensitive flakes under that load (re-run green), and 1 pre-existing BranchPickerPopover focus assertion that fails byte-identically on base (69d5db2) and head — not attributable to this PR

Findings

None blocking. One observation, identical on both arms and matching the author's published results: a pasted image rides the wire as an inline [image: image/png] text marker rather than an image_url part, so the "resend carries the image once" claim was verified in that form. Not introduced by this PR.

Not covered

  • SSH workspaces (rewind is rejected there; the composer-restore fallback is unit-tested but not run against a real remote — same gap the author declared).
  • Split-view ChatPane keeps the plain stop by design (declared out of scope by the PR).
  • Daemon-API timing probe (the PR's "rewind lands 27/27 immediately after cancel" claim) — not re-run as raw HTTP; the e2e S11 scenario (Enter, Esc, Esc with no pause) covers the same race through the real client and passed on head.
  • @smoke Playwright mock-daemon suite — CI ran it green; my real-daemon A/B is the stronger evidence for this feature.
  • macOS/Windows — browser-side only change, aarch64 Linux verified; other desktops left to CI per the PR.

Methodology

Two git worktrees at ff617ef (head) and 69d5db2 (base). Head: corepack pnpm install --frozen-lockfile (13m22s, cold store) + npm run build + npm run bundle. Base: node_modules hardlinked from head (cp -al; the PR touches no package.json/lockfile), then full build + bundle. Control cleanliness was asserted, not assumed: readlink -f base-tree/node_modules/@qwen-code/{qwen-code-core,web-shell} resolves into the base tree. Each arm ran node dist/cli.js serve --port 18932 --token rigtoken with an isolated QWEN_HOME (auth openai + modelProviders.openai registering fake-model — without the registry entry the daemon rejects the client's POST /session/:id/model and a "Set model failed" toast pollutes the no-toast assertions; environmental, unrelated to the PR). The scripted model (harness/fake-model.cjs) keys behaviour off the last user text (HOLD stalls, THINK streams only reasoning, SLOWTEXT streams partial text, TOOL calls a tool) and logs every request's full user-message list — the wire oracle for the merged-prompt claim. Chromium 1.61.1 headless on aarch64. Harness scripts, raw per-scenario JSON (out-head/, out-base/), console logs and screenshots are in the artifact directory. Raw results diff cleanly against the author's published results-main.json / results-this-pr.json where scenarios overlap.

Machine: Orange Pi 5 (RK3588S, 8 cores), Armbian Linux aarch64, Node v24.13.0, pnpm 11.24.0.

Review on the take-back found two races:

1. The "produced nothing" check ran when the cancel returned, but the
   turn's output can still be on the SSE stream at that point. Decide
   on the daemon's terminal event instead: the take-back arms on cancel
   and runs from the prompt settlement bus, after the provider has
   flushed every block the turn produced. A turn that completed or
   failed on its own keeps its result; a settlement that takes longer
   than 10 s lapses.

   The settled turn is recognised by the admitted prompt id, which
   `onAdmitted` now carries. When Esc Esc cuts the admission response
   short, the daemon may still have taken the prompt, so the terminal
   frame's originator stamp identifies the tab's own turn instead; the
   settlement event now carries it.

2. Prompts could be sent while the rewind was in flight, and a
   late `session_rewound` would then drop the correction too. The
   take-back holds prompts from before the snapshot read until the
   rewind has shown up in the transcript (or is known not to happen),
   through the same pending-rewind state the inline edit path uses,
   which it now shares under a neutral name. A daemon refusal releases
   the hold at once; an unknown failure holds for at most 2 s.
@wenshao
wenshao dismissed a stale review via d65f8b1 October 6, 2026 07:52
@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Both points are right, and both are fixed in d65f8b171f (pushed; PR body updated to match). Thanks for constructing the races with the real App and reducer — the two new scenarios below are the real-stack versions of them.

P1 — decide on the turn's terminal event, not when the cancel returns. The take-back no longer runs from cancel()'s promise. handleCancel only records the prompt it is cancelling; the decision runs from the prompt settlement bus (useDaemonPromptSettled), i.e. on the daemon's turn_complete / turn_error for that prompt. The provider flushes every buffered transcript block before it publishes a settlement, and the bridge publishes the terminal frame only after the agent has wound down, so by the time the "produced nothing" check runs the transcript holds everything the turn produced. A turn that completed or failed on its own keeps its result (outcome !== 'cancelled'), and a settlement that takes longer than 10 s lapses.

The settled turn is matched by the admitted prompt id, which onAdmitted now carries. One case needed more: Enter → Esc → Esc with no pause cancels before the admission response reaches the tab, yet the daemon has usually taken the prompt (your P1 scenario in reverse — the tab knows nothing about a turn that exists). The terminal frame's originatorClientId identifies the tab's own turn there; DaemonPromptSettledEvent now carries it (internal field; the host projection in assistant-turn-settlement.ts is unchanged). An intermediate build that matched by prompt id only regressed exactly this scenario on the real stack (turn kept, resend merged) — kept as results-round2-promptid-only-build.json.

P2 — hold prompts until the rewind has landed. The take-back sets the pending-rewind state before it reads the snapshots and leaves it set until the transcript shows the rewind (the same sessionWriteBlocked the inline edit path drives; pendingEditRewind is now pendingRewind, shared by both). A daemon refusal (DaemonHttpError, e.g. an SSH workspace) releases it immediately; no rewind issued releases it; an unknown failure gives the event up to 2 s and then releases. Your two shapes — correction while the snapshot read is deferred, and correction between the rewind's HTTP response and session_rewound — are now unit tests (holding prompts back while the turn is taken back): the correction is refused (returns false, draft stays) and writeBlocked is observed true, then false once the turn is gone. Enter during the hold is refused rather than queued; locally the hold is tens of milliseconds.

Verification. Real daemon + real Chromium, same harness as before, client built from d65f8b171f (round2):

  • Answer lands as the cancel does — scripted model answers at 900 ms, Esc Esc at 700–1200 ms, 10 runs: 0 violations (7 runs where the model had delivered → answer and prompt kept; 3 → taken back).
  • Correction sent ~130 ms after the prompt is back — sent once, not merged, not lost; model receives ["hello first", "fixed prompt S16"], same after reload.
  • Enter, Esc, Esc with no pause — taken back via the originator stamp; wire clean.
  • The 14 earlier scenarios unchanged (12/12 repeated take-backs, observer tab, attachments, all plain-stop cases).
  • Unit: the two race shapes plus settlement outcome/timeout/other-prompt/other-client/write-blocked/refusal/unknown-failure cases; packages/web-shell 412 files / 10818 tests green; 28 guard mutations each killed by a new test, dropping the originator stamp fails the provider's new test.
  • One caveat, stated in the PR: a single stop-button run in the first full pass stopped without taking back (no snapshot read after the terminal in the daemon log) and did not recur in a second full pass, 20 isolated runs, or 4 back-to-back runs; raw result kept.
中文

两点都成立,均已在 d65f8b171f 修复(已推送,PR 正文同步更新)。感谢用真实 App 和 reducer 构造出这两个竞态——下面两个新场景就是它们在真实栈上的版本。

P1 —— 以该轮的终点事件为判定点,而不是取消返回时。 撤回不再挂在 cancel() 的 promise 上。handleCancel 只记录正在取消的 prompt;判定改在 prompt 结算总线(useDaemonPromptSettled)上运行,即 daemon 对该 prompt 的 turn_complete / turn_error。provider 在发布结算前会先 flush 所有缓冲的 transcript 块,bridge 也只在 agent 收尾之后才发布终点帧,所以"无产出"检查运行时,transcript 已经包含该轮产生的全部内容。自行完成或失败的轮次保留结果(outcome !== 'cancelled'),超过 10 s 才结算的撤回自动作废。

结算的轮次按 admission 返回的 prompt id 匹配(onAdmitted 现在带上它)。有一种情况需要补充:Enter → Esc → Esc 不停顿时,取消发生在 admission 响应到达标签页之前,但 daemon 通常已经接纳了这条 prompt(正是 P1 场景的反面——标签页对一个真实存在的轮次一无所知)。这时用终点帧的 originatorClientId 识别本标签页自己的轮次;DaemonPromptSettledEvent 新增该字段(内部字段;assistant-turn-settlement.ts 的 host 投影不变)。一个只按 prompt id 匹配的中间构建在真实栈上恰好在这个场景退化(该轮保留、重发合并)——结果保留为 results-round2-promptid-only-build.json。

P2 —— 暂扣 prompt 直到回退落地。 撤回在读取快照之前就设置 pending-rewind 状态,并一直保持到 transcript 显示回退(就是内联编辑路径驱动的那个 sessionWriteBlocked;pendingEditRewind 改名为 pendingRewind,两者共用)。daemon 拒绝(DaemonHttpError,如 SSH 工作区)立即解除;未发起回退也解除;未知失败给事件最多 2 s 然后解除。你提出的两种形态——快照读取被挂起时发修正、回退 HTTP 响应与 session_rewound 之间发修正——现在都是单元测试(holding prompts back while the turn is taken back):修正被拒绝(返回 false,草稿保留),writeBlocked 先为 true、该轮消失后为 false。暂扣期间 Enter 是被拒绝而非排队;本地暂扣时长为数十毫秒。

验证。 真实 daemon + 真实 Chromium,harness 同前,客户端由 d65f8b171f 构建(round2):

  • 回答与取消同时落地——脚本模型 900 ms 后回答,Esc Esc 在 700–1200 ms 之间,10 轮:0 次违规(模型已交付的 7 轮回答与 prompt 保留;3 轮撤回)。
  • prompt 回到输入框约 130 ms 后发送修正——只发一次,未合并,未丢失;模型收到 ["hello first", "fixed prompt S16"],刷新后一致。
  • Enter、Esc、Esc 不停顿——通过 originator 戳撤回;wire 干净。
  • 此前 14 个场景不变(12/12 连续撤回、旁观标签页、附件、所有普通停止的情形)。
  • 单测:上述两种竞态形态,加上结算结果/超时/其他 prompt/其他客户端/写阻塞/拒绝/未知失败等用例;packages/web-shell 412 文件 / 10818 用例全绿;28 个守卫变异每个都被新测试杀死,去掉 originator 戳会让 provider 新增测试失败。
  • 一个已写进 PR 的保留项:第二轮第一遍全量里有一次停止按钮场景只停止、未撤回(daemon 日志里终点事件后没有读取快照),第二遍全量、20 次单跑、4 次连跑均未复现;原始结果已保留。

@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

重新验证当前 head d65f8b171f0a2e7cd299d8ab987ed32c75186ccd 后:P1 已修复;P2 的正常路径已修复,但未知失败路径仍可复现,尚不能标记为完全解决。

  • P1:已修复。 判定现在等待对应轮次的 settlement,provider 在发布前先 flush transcript。独立测试分别让 assistant 和 tool 输出晚于 cancel HTTP、早于 terminal 到达,均保留输出,快照读取和回退调用均为 0。
  • P2:正常路径已修复。 快照读取挂起、回退 HTTP 已成功但 SSE 未到时,修正 prompt 均被阻止提交;即使成功响应后等待超过 2 秒,阻塞仍保持。应用真实 SDK 的回退事件后再提交,历史正确保留 ["first", "corrected prompt"]。

仍需修复 [P2]:未知回退失败后,不能仅因等待 2 秒就解除提交阻塞。 App.tsx:17909–17917 忽略 waitForRewindApplied() 返回的 false,随后 finally 无条件释放 pending 状态。网络错误或客户端超时并不能证明 daemon 没有执行回退:客户端 timeout 只竞争 promise,bridge 也明确说明已发出的回退无法取消。因此原来的迟到事件删掉新消息的问题只是延后了 2 秒。

独立复现:令回退请求以 TypeError('fetch failed') 拒绝,推进 fake timer 2100 ms,发送修正 prompt,再应用迟到的真实 SDK session.rewound 事件。实际结果:

blocked: true -> false
sendPrompt calls before rewind event: 2
user texts before event: ["first", "oops typo", "corrected prompt"]
user texts after event:  ["first"]

需要在结果未知时继续阻止新 prompt,直到回退事件应用或通过会话重载/权威状态确认结果;等待超时本身不应当作为安全解锁的依据。现有未知失败测试只覆盖事件在等待窗口内到达,建议补上窗口过期后才到达的用例。

本次验证:npm run build、npm run typecheck 通过;改动文件相关原有测试 5 个文件 / 1865 个用例全部通过;额外独立时序测试 6 通过、1 失败,失败即上述残留问题。时序复现使用真实 App 和 SDK reducer、mock session I/O,未把它宣称为真实 daemon 网络故障 E2E。CI 产品测试与 E2E 均绿;机器人评审及 fallback 任务失败的日志为其 GitHub 账号 HTTP 403(account suspended),与本次代码测试失败无关。

@qwen-code-review-bot

qwen-code-review-bot commented Oct 6, 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 16bd737. 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 2 render-shaping files:

  • packages/web-shell/client/App.tsx
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.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

…e daemon answers

A rewind whose request or response was lost may still have been applied:
the daemon cannot take back a rewind it has dispatched, so a timed wait
before releasing prompts only delayed the late `session_rewound` deleting
the correction sent in between. Keep the hold and ask the daemon instead.
It lists a turn's snapshot while the turn is in its history and never
reuses a snapshot id, so the target still listed on two readings means no
rewind happened; the target gone means it did, and the transcript lifts
the hold when the event lands. Keep asking while the daemon cannot be
reached. Stop asking once the hold has lifted, and release at once when
the snapshots could not be read before any rewind was issued.
@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed — the 2 s wait was a timer standing in for something the tab could not know. Fixed in ccdac0bdf5 (pushed; PR body updated).

What changes. After a rewind call fails for any reason other than a daemon refusal, the hold now stays until the daemon has answered, never until a timer runs out. The daemon lists a turn's snapshot in GET /rewind/snapshots only while that turn is in its API history (getRewindableUserTurnCount() is computed from the live history), and a snapshot id is never reused (Session.turn is monotonic across rewinds), so re-reading the listing is the authority:

  • target still listed → no rewind happened → prompts are released;
  • target gone → the rewind landed → the hold lasts until the transcript shows it (session_rewound, or the reconnect replay);
  • listing unreachable → ask again (0.5 s, 1 s, 2 s, …) for as long as the hold stands.

"Still listed" has to be seen twice: one reading can be answered from the same read of the agent's pipe as a rewind still queued there, before that rewind truncates — the second reading is only sent after the first was answered. Two smaller things fell out of it: a failed snapshot read before any rewind was issued releases at once (nothing can land), and the loop stops as soon as the hold has lifted.

Your scenario is a unit test now — rewind rejects with TypeError('fetch failed'), the daemon's listing no longer has the turn, the clock is moved 2.1 s, the correction is refused, then the late session_rewound lifts the hold and the correction goes out once (holds prompts until the event arrives when the daemon no longer lists the turn). Next to it: released once the daemon has twice listed the turn as still there; keeps asking while the daemon cannot be reached; stops asking once the transcript shows the rewind; releases when the snapshots cannot be read because no rewind was issued. 11 mutations of the outcome check: 10 fail a test, the one survivor only flattens the retry backoff.

One note for the harness: a daemon that goes on listing the turn and later emits session_rewound for it cannot exist — the listing is derived from the history the rewind truncates. If getRewindSnapshots is mocked to return the pre-rewind listing after the failed call, the fix will (correctly) release; with the listing truncated the hold stays.

Real stack (Playwright route interception in front of the real daemon, the composer's contenteditable polled to time the hold; raw results in pr-13488/round3):

  • request lost before the daemon: asked at +41 ms and +546 ms after the restore, listed both times, released at +565 ms; text typed and Enter pressed during the hold changed and sent nothing;
  • daemon applied the rewind, browser lost the response: 200 at +34 ms, the one reading at +41 ms no longer listed the turn, so the daemon's answer released nothing; the hold lifted with the event at +91 ms; model receives ["hello first", "fixed prompt S17b"];
  • request lost and the daemon unreachable for the next two readings: failed at +32 ms and +535 ms, answered at +1540 ms and +2046 ms, released at +2064 ms — past the old timer, and only after two answers.

Two things the PR body now states plainly. During the hold the composer is read-only (the shared sessionWriteBlocked drives EditorView.editable), so typing is dropped rather than refused at Enter — tens of ms on the normal path, about half a second after a lost request. And the residual: a rewind the daemon still has queued when both readings are answered would land after the release; that takes the daemon sitting on an admitted rewind across that exchange while answering status reads. Serving the listing through the same history-mutation gate on the daemon would close it; I'd do that as a follow-up rather than widen this PR, if you think it is worth closing.

The 16 earlier scenarios were re-run twice on the new build. In the first pass one of the twelve take-backs in a row (a reasoning-only turn) stopped without being taken back — a plain stop; the later rounds were taken back and the final prompt reached the model merged with that kept turn, as on main. It did not recur in the second full pass or in 9 isolated runs (108/108 rounds); kept as results-this-pr-round3-first-pass.json, and noted in the PR body next to the round-2 stop-button miss.

中文

同意——那 2 秒等待只是用计时器替代了标签页并不掌握的信息。已在 ccdac0bdf5 修复(已推送,PR 正文同步更新)。

改了什么。 回退调用因 daemon 拒绝以外的任何原因失败后,暂扣现在一直持续到 daemon 给出答案,不再由计时器解除。daemon 只在某一轮仍在其 API 历史中时才会在 GET /rewind/snapshots 里列出该轮的快照(getRewindableUserTurnCount() 由实时历史算出),且快照 id 永不复用(Session.turn 跨回退单调递增),因此重新读取列表就是权威状态:

  • 目标仍被列出 → 回退没有发生 → 放行;
  • 目标消失 → 回退已落地 → 暂扣持续到 transcript 显示它(session_rewound,或重连回放);
  • 列表读不到 → 只要暂扣仍在就反复询问(0.5 s、1 s、2 s……)。

"仍被列出"必须看到两次:一次读取可能与仍排在 agent 管道里的回退出自同一次读取、在该回退截断之前被应答——第二次读取只在第一次被应答之后才发出。顺带两处:在发起任何回退之前快照读取失败则立即放行(没有东西能落地);暂扣一旦解除,循环即停止。

你的场景现在是单元测试——回退以 TypeError('fetch failed') 拒绝,daemon 列表里已没有该轮,时钟拨快 2.1 s,修正被拒绝,随后迟到的 session_rewound 解除暂扣、修正只发出一次(holds prompts until the event arrives when the daemon no longer lists the turn)。旁边还有:daemon 两次列出该轮仍在时放行;daemon 不可达时持续询问;transcript 显示回退后停止询问;因未发起回退而快照读不到时放行。对结果确认逻辑做了 11 个变异:10 个让测试失败,唯一存活的只是把重试退避拉平。

关于 harness 的一点说明:一个持续列出该轮、随后又为它发出 session_rewound 的 daemon 是不存在的——列表正是由回退所截断的那份历史算出的。若 getRewindSnapshots 的 mock 在失败的回退之后仍返回回退前的列表,修复会(正确地)放行;把列表 mock 成已截断,暂扣就会保持。

真实环境(在真实 daemon 前用 Playwright 路由拦截,轮询输入框的 contenteditable 为暂扣计时;原始结果在 pr-13488/round3):

  • 请求在到达 daemon 前丢失:回填后 +41 ms 和 +546 ms 两次询问,两次都列出,+565 ms 放行;暂扣期间输入的文字和按下的 Enter 什么都没改变、什么都没发出;
  • daemon 已执行回退、浏览器丢失响应:+34 ms 返回 200,+41 ms 的唯一一次读取已不再列出该轮,因此 daemon 的回答没有放行任何东西;暂扣在 +91 ms 随事件解除;模型收到 ["hello first", "fixed prompt S17b"];
  • 请求丢失且随后两次读取不可达:+32 ms 和 +535 ms 失败,+1540 ms 和 +2046 ms 得到回答,+2064 ms 放行——晚于原先的计时器,且只在两次回答之后。

PR 正文现在明确写了两点。暂扣期间输入框是只读的(共用的 sessionWriteBlocked 驱动 EditorView.editable),所以输入会被丢弃而不是在 Enter 时被拒绝——正常路径几十毫秒,请求丢失后约半秒。以及残余风险:若两次读取被应答时 daemon 仍把该回退排在队列里,它会在放行之后落地;这需要 daemon 在应答状态读取的同时把一个已接纳的回退压在队列里跨过这一来回。在 daemon 侧让列表读取也经过同一个历史变更闸门就能堵上;若你觉得值得堵,我更倾向于作为 follow-up 来做,而不是扩大这个 PR。

之前的 16 个场景在新构建上复跑了两遍。第一遍里连续 12 次撤回中有一次(只有推理的轮次)只停止、未撤回——是普通停止,之后各轮均撤回,最终 prompt 与那一轮保留下来的 prompt 合并后到达模型,与 main 一致。第二遍全量和 9 次单独运行(108/108 轮)均未复现;保留为 results-this-pr-round3-first-pass.json,并与第二轮的停止按钮那次一起写进了 PR 正文。

@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

重新验证当前 head ccdac0bdf5668882b9790b9b983768dde58f8104:P1 仍已修复;P2 在回退已执行、事件迟到的路径已修复,但回退仍排队时的问题可以复现,尚不能标记为完全解决。

[P2] 两次快照中仍有目标,不能证明已接纳的回退不会再执行。 App.tsx:996–998 在连续两次看到目标时返回 true,随后 17971–17979 解除提交阻塞。实际 bridge 中,回退排在 entry.promptQueue 上,而 快照查询直接发送 ACP status 请求,不等待该队列。因此两次回答只能证明“目前尚未截断”,不能证明“这次回退已确定不会执行”。

这次先用生产 bridge 和内存 ACP 测试通道验证后台时序,再用真实 App 与 SDK reducer 复现界面影响:

  1. 挂起一个合法的并发 branch 操作,接纳其后面的 rewind;branch 不修改源会话快照。
  2. 两次快照读取相隔 550 ms,均返回目标;断言此时 rewind 尚未派发。
  3. 前端的 rewind 请求以未知网络错误结束后,按上述两次回答解除阻塞,接受修正消息。
  4. 放行 branch,生产 bridge 随后执行 rewind、发布 session_rewound,最后执行修正 prompt。App 应用迟到的真实 SDK 回退事件时,修正消息被一并从界面历史中删除。
heldInitially: true
heldAfterBothReads: false
sendPrompt calls: 2
user texts before event: ["first", "oops typo", "corrected prompt"]
user texts after event:  ["first"]
AssertionError: expected [ 'first' ] to include 'corrected prompt'

两次“仍有目标”的回答都发生在回退执行之前,不存在“已经截断却继续返回旧快照”的假设。后台可继续执行修正 prompt;这里验证的缺陷是其对应用户消息在当前界面消失。这个残留属于原 P2 的正确性问题,建议在本 PR 中关闭:解除阻塞须有已接纳回退完成或确定不再执行的确认,状态查询须与该操作正确排序。确认机制需要覆盖尚在 bridge 队列中、还未派发给 agent 的回退。

已通过的独立复测包括:延迟 assistant/tool 输出仍保留;正常回退期间阻止提交;目标已消失时等待 10 秒仍不放行,直到事件应用;请求确实未送达时两次查询后放行;查询失败重试;发起回退前读取失败立即放行。

验证结果:npm run build、npm run typecheck 通过;相关原有测试 5 文件 / 1871 用例全部通过;额外验证 9 个案例,8 通过、1 失败,失败即上面的界面历史断言。后台验证使用真实 bridge 队列与内存 ACP 通道,前端验证使用真实 App/reducer 与 mock session I/O;本次没有把它宣称为完整 daemon + 浏览器网络故障 E2E。

A rewind waits its turn on the session's prompt queue — behind a branch,
say — and cannot be taken back once admitted, while the snapshot listing
went straight to the agent. A caller asking whether its rewind landed
could so be told the turn was still there while the bridge was holding
that very rewind in its queue, and release work the rewind then dropped.
Keep the settled state of the last admitted rewind on the session entry
and have the listing wait for it; a failed rewind does not block later
listings. Nothing else waits on it.
@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Right — two readings could only ever show "not truncated yet", not "will not run"; the ordering has to live where the queue is. Closed in this PR at the bridge, in 16bd737bd3 (pushed; PR body updated).

What changes. packages/acp-bridge/src/session-control-plane.ts: the session entry now keeps rewindTail, the settled state of the last admitted rewind (set synchronously at admission, next to the promptQueue chain, always resolving). getRewindSnapshots awaits it before sending the status request to the agent. So a listing requested while an admitted rewind is still queued behind a branch is answered only after the branch has released and the rewind has run — and since the agent processes the listing after it has responded to the rewind, the agent side needs nothing. Nothing else waits on it: prompts, branches and other status reads are unchanged, and a rewind that fails does not block later listings.

Tests. Two bridge tests on the in-memory ACP channel, the shape you used: a branch gated open, a rewind admitted behind it, getRewindSnapshots called meanwhile — not answered for as long as the branch holds (50 ms probe), then answered after both ran, with the agent seeing session/branch, session/rewind, rewind_snapshots in that order; and a failed rewind followed by a listing that is still answered. Removing the await fails the first test. packages/acp-bridge: 54 files / 2597 tests green; packages/web-shell: 412 files / 10822 green; the Web Shell side is unchanged apart from the comment on rewindMissed.

Real stack, bundle rebuilt from 16bd737bd3: all 19 scenarios as before (twelve take-backs in a row 12/12, answer-lands-as-the-cancel-does 0 violations, request lost released at +571 ms after two listings, response lost held until the event, daemon unreachable released at +2063 ms after two answers). Raw results in pr-13488/round4.

The two readings stay as the client-side half: the first can be answered while the daemon is still reading the rewind's own request (same event-loop wake-up, body not yet parsed), the second is sent only after the first came back. What that leaves is a rewind request reaching the daemon more than half a second after the browser reported it failed — a timed-out request has been at the daemon for 30 s by then, and one the browser aborted is either already there or never arrives. Stated as such in the PR body.

中文

同意——两次读取只能证明"尚未截断",证明不了"不会再执行";排序必须放在队列所在的那一层。已在本 PR 内于 bridge 侧关闭,见 16bd737bd3(已推送,PR 正文同步更新)。

改了什么。 packages/acp-bridge/src/session-control-plane.ts:会话条目新增 rewindTail——最后一个已接纳回退的完成状态(在接纳时与 promptQueue 链一起同步设置,始终 resolve)。getRewindSnapshots 先等它,再向 agent 发状态请求。因此,在已接纳的回退仍排在 branch 之后时请求的列表,只会在 branch 放行、回退执行完之后才被应答;agent 处理列表时已经应答过回退,所以 agent 侧无需改动。其他一切不等它:prompt、branch、其他状态读取不变;失败的回退不会阻塞之后的列表。

测试。 两个用内存 ACP 通道的 bridge 测试,与你的形态相同:闸住一个 branch,在它之后接纳一个回退,期间调用 getRewindSnapshots——branch 不放行就一直不被应答(50 ms 探测),两者执行完后才应答,agent 看到的顺序是 session/branch, session/rewind, rewind_snapshots;以及一个失败的回退之后列表仍被应答。去掉这个 await 会让第一个测试失败。packages/acp-bridge 54 文件 / 2597 用例通过;packages/web-shell 412 文件 / 10822 用例通过;Web Shell 侧除 rewindMissed 的注释外没有改动。

真实环境,用 16bd737bd3 重新打包 bundle:19 个场景与此前一致(连续 12 次撤回 12/12,回答与取消同时落地 0 违规,请求丢失在两次列表后 +571 ms 放行,响应丢失保持到事件到达,daemon 不可达在两次回答后 +2063 ms 放行)。原始结果在 pr-13488/round4。

两次读取作为客户端一侧保留:第一次可能在 daemon 还在读取回退请求本身时就被应答(同一次事件循环唤醒、body 尚未解析完),第二次只在第一次返回之后才发出。剩下的只有:回退请求在浏览器报告失败半秒多之后才到达 daemon——超时的请求此时已在 daemon 那里 30 s,浏览器中止的请求要么已经到了,要么永远不会到。PR 正文如此写明。

@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

重新验证当前 head 16bd737bd3be827e4d4c312d71e30b0d0829a675:此前报告的 P1、P2 均已通过复测,本轮未发现新的阻塞问题。

  • P1 保持修复。 独立测试再次让 assistant/tool 输出晚于 cancel HTTP、早于 settlement 到达,输出均被保留,未触发回退。
  • P2 的排队路径已修复。 rewindTail 在回退接纳时与 promptQueue 同步设置,快照查询先等待该回退完成,因此上轮“已接纳回退仍排队,两次读取却提前返回旧目标”的时序已被阻断。

本次将生产 App 的快照查询、回退和更正提交直接接入新构建的生产 bridge,以内存 ACP 通道控制 branch 完成时机;模拟调用方丢失回退 HTTP 结果,并延迟向 App 投递 bridge 实际发布的回退事件。结果:

  1. branch 挂起、rewind 已接纳时,等待 550 ms 后,结果确认查询仍挂起,修正消息未提交。
  2. branch 放行、rewind 完成后,查询返回目标已消失;App 仍保持阻塞,直到真实 SDK reducer 应用 session_rewound。
  3. 此后修正消息只提交一次,并通过 bridge 执行;最终用户消息为 ["first", "corrected prompt"]。

额外覆盖了已派发但未完成的回退、失败回退后的查询、多个已接纳回退、其他状态查询与另一会话隔离,以及前几轮的迟到输出、快照/事件等待、目标消失后等待 10 秒、查询失败重试、未发起回退时读取失败等路径。独立验证 13/13 通过(bridge 5、App 8)。

常规验证也通过:根目录 npm run build、npm run typecheck;原有 bridge 测试 994/994;Web Shell 相关原有测试 5 文件 / 1871 用例。已检查新增字段的全部读写及回退弹窗、内联编辑、自动撤回、VS Code 编辑、SDK 和会话所属 runtime 路由的调用链。

验证范围:上述联合测试使用真实 App、bridge、SDK reducer 和内存 ACP 通道,agent 操作与 HTTP 结果丢失由测试夹具控制;本次未额外运行完整浏览器网络故障 E2E。

发评时 CI 尚有 Web Shell E2E、视觉预览等任务排队或运行中;以上通过数均为本地实际执行结果。

@wenshao
wenshao enabled auto-merge October 6, 2026 14:44

@qqqys qqqys 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.

Reviewed head: 16bd737bd3be827e4d4c312d71e30b0d0829a675 (base main).

Approve. This PR had no prior review — zero reviews, zero threads, zero inline findings — so there is no historical blocking question to re-verify. A Critical-only scan of the production surface found nothing blocking.

Critical-only scan

Production code is ~387 lines across seven files, dominated by App.tsx (+304/−21); the rest is a 30-line pure predicate, the bridge control plane, and supporting types. The risk in a feature like this is a stranded hold that leaves the composer permanently read-only, a lost draft, or a take-back that destroys something the user wanted, so that is where the scan went.

The eligibility predicate fails safe. cancelledTurnProducedNothing returns true only when every block after the prompt is thought, status, error, debug or prompt_cancelled. Any other kind — an answer, a tool call, shell output, a permission request, a later user message — keeps the turn, and so does any block kind not in the list, so a future addition defaults to not taking the prompt back. Its doc comment also records the ordering precondition it depends on: the predicate is only meaningful once the daemon has settled the turn, since before that "nothing received yet" is not "nothing produced".

The hold cannot strand by accident. rewindMissed re-checks stillHeld() at the top of every iteration, so the moment the hold is lifted by any other path the polling loop returns instead of spinning; it also returns on target-gone (the rewind landed) and on the target being listed on two consecutive readings (the rewind missed, which lets the caller's finally { if (!rewound) release(); } run). While the daemon is unreachable it backs off 500 ms → 1 s → 2 s capped, and listed stays undefined through a throw so a failure never masquerades as either verdict. The one remaining hold-forever shape is a daemon that stays unreachable after a rewind was issued — and that is the deliberate, documented fail-closed choice: the bridge cannot retract a rewind it admitted, so releasing on a timer could let a prompt be sent and then dropped by a rewind that lands afterwards, which is silent data loss rather than a read-only composer.

The release is identity-guarded. The cancel path clears with setPendingRewind((current) => (current === pending ? null : current)), so a release can never clobber a newer hold another path has since set.

Generalizing the shared hold did not regress the edit path. Renaming pendingEditRewind → pendingRewind made recover optional, which is exactly the shape where an existing caller silently loses its callback. It did not: the inline edit-and-resend site at App.tsx:16262 still passes recover, and only the new cancel path omits it. The layout effect that clears the hold calls recover?.() solely when pendingRewind.sessionKey === logicalSessionKey and pendingRewind.owner.isCurrent(), so a hold left over from a previous session cannot fire a recovery into the wrong one. There are also two independent lift paths — the transcript's user-turn count dropping below turnIndex, and the session_rewound event — so the hold does not depend on a single signal arriving.

The daemon-side ordering fix cannot deadlock. getRewindSnapshots now awaits entry.rewindTail so a listing never describes a turn the bridge has already agreed to drop. rewindTail is assigned rewindResult.then(() => undefined, () => undefined) — both arms swallow — so it always resolves, and the invariant is recorded in the field's doc comment: "Always resolves — a failed rewind must not block later listings." A missing entry throws SessionNotFoundError rather than hanging, and the tail is read at call time so the listing waits on the latest admitted rewind. entry.promptQueue keeps the same settled promise it had before, so prompt queueing is unchanged.

The new field is populated, not a dead switch. originatorClientId on DaemonPromptSettledEvent is threaded from the daemon's terminal frame through promptSettledFromTurnEvent (spread into both the turn_error and the completed/cancelled returns, and at the second mapping site) and consumed by the take-back matcher in App.tsx (event.originatorClientId === takeBack.clientId), which is what lets a submitter recognise its own turn when the admission response never reached the tab. The chain is complete at both ends.

Supporting changes are consistent. onAdmitted?.({ promptId: accepted.promptId }) now supplies the prompt id at admission rather than nothing, session_rewound joins the events that advance the session recovery generation ("a rewind can drop the interrupted turn itself"), and the new silent option routes errors past dispatchActionError(addNotice, …) so a rewind nobody asked for fails without a toast.

No Critical found.

CI

No failing checks at this head: 19 pass, 8 skipped, 3 pending. Nothing to attribute to this PR.

Not scanned — disclosed, not asserted clean

I did not execute any suite, so the ~1,030 lines of new tests (App.test.tsx +738, DaemonSessionProvider.test.tsx +128, bridge.test.ts +96, cancelledTurn.test.ts +44, actions.test.ts +24) are unrun here and I assessed them only where they bear on the paths above. I did not verify the daemon-side producer that stamps every terminal frame with the submitting client — that lives beyond the bridge files this PR changes, and the client-side chain above is what I confirmed. I did not drive the double-Esc, stop-button, queued-prompt, retry or SSH-workspace paths in a live browser. I report no Critical in those areas because I found none where I looked, not because I proved absence.

Scope note

Approval is bound to commit 16bd737b. It covers the hold/release state machine, the eligibility predicate, the bridge ordering change and the field threading. It is not a judgement about the interaction design, and it does not stand in for the pending checks or a live end-to-end run of the cancel flow.

@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

wenshao added a commit to wenshao/qwen-code that referenced this pull request Oct 6, 2026
@wenshao

wenshao commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: merge-ready — 128/128 scripted assertions passed at head ccdac0bdf5668882b9790b9b983768dde58f8104

Follow-up round (4th) of maintainer verification, run on macOS/Intel, Node v25.8.0, Chromium via Playwright 1.61.1. Three arms built from scratch here: base 9cdb0f38 (the PR's base, also the merge-base with current main), head ccdac0bdf5, and intermediate = the head tree with only this round's commit's App.tsx reverted to d65f8b171f (sha256-verified overlay and byte-for-byte restore), so any head↔intermediate difference is attributable to ccdac0bdf5 alone. Every arm is a real qwen serve daemon serving its own built Web Shell bundle to real headless Chromium against a scripted OpenAI-compatible model that logs every request's user-message parts. Nothing about the daemon, client, or browser is mocked.

中文摘要

结论:merge-ready(128/128 断言通过;head 68 / base 31 / intermediate 29)。 三臂均为真实 daemon + 真实 Web Shell + 真实 Chromium + 脚本化模型;中间臂与 head 只差本轮 commit 的 App.tsx(sha256 校验覆盖与还原)。

  • 先前发现状态:P1(判定早于轮次输出同步)与 P2 正常路径已在 d65f8b171f 修复,本轮复测成立(S3 回答已流式输出时取消 → 普通停止、轮次保留、0 次回退;F1/F3 实测 hold 在 +450…+697 ms 生效、期间修正被拒)。P2 未知失败路径(2 s 定时器)在 ccdac0bdf5 修复 —— 与中间臂 A/B 直接证明:head 在 F3 中 +2619 ms 才释放且向 daemon 询问 3 次(2 失败 / 3 应答),中间臂 +2540 ms 释放且一次都没再问;D1 中 head 在 daemon 永久不可达时 12 s 仍保持只读,中间臂 2 s 即放行。
  • 核心主张复测(base vs head,r4-01/r4-02/r4-03):模型未产出时连按两次 Esc —— head 臂 prompt 回到输入框、该轮从历史消失、无"中断"横幅、daemon 真回退;修正 prompt 单独送达模型 [["hello first"],["fixed prompt S1"]],刷新后一致。base 臂 prompt 留在历史、出现横幅、修正与错误 prompt 落在同一条 user message。
  • 承重前提实测(P0,双臂 A/A 一致,r4-04):快照按轮创建;回退后目标从列表消失;下一轮序号为 3 而非 2(id 不复用)—— 正是新客户端逻辑依赖、而单测用 mock 假设的两点。
  • 不回撤的情形两臂一致:回答已流式输出(S3)、轮次期间有草稿(S4)→ 普通停止、0 次回退、草稿保留;停止按钮与 Esc Esc 同路径(S5)。
  • 测试有效性(r4-07):7 个守卫变异中 6 个被新增测试杀死(M0 阳性对照 2 红,M2/M3/M4/M5/M7 各 1–2 红且指名到具体用例);2 个存活者已分类(M6 退避拉平,与作者自陈一致;M1 为轮询流量优化、行为中性,见 Findings)。
  • 门禁:PR 列出的 4 个聚焦套件全绿(8 + 30 + 4 + 2),web-shell tsc --noEmit 干净;全量套件由 CI Test (ubuntu-latest) 覆盖(绿)。
  • 未覆盖:SSH 工作区、分屏 ChatPane、真实栈"仅思考"格(脚本模型的 reasoning_content 在该栈不产生 transcript 块,改由单测 + M0 覆盖)、"响应丢失且事件被抑制"的真实栈窗口(需 daemon 侧故障注入;由单测 + P0 前提覆盖)、作者自陈残余竞态的端到端构造、第一轮的 12 连撤/粘贴图片/旁观标签页场景(增量 commit 未触及,本轮未复测)。

Previous findings — status at the new head

# finding status at ccdac0bdf5, re-measured
P1 take-back decided before the turn's output was synced; late output deleted with the turn fixed in d65f8b171f; re-measured: S3 cancels after answer text is in the transcript → plain stop, turn kept, 0 rewinds
P2 normal corrections submittable during the rewind window, then deleted by the event fixed in d65f8b171f; re-measured: hold observed engaged at +450…+697 ms (composer contenteditable="false"), correction typed+Enter during it refused
P2 unknown failure a 2 s timer released the hold with the outcome unknown fixed in ccdac0bdf5; proven by the intermediate-arm A/B below

Central claim, A/B against base

Esc twice before the model answers: base vs head

After sending the correction: base vs head

Wire oracle

scenario base 9cdb0f38 head ccdac0bdf5
S1 model silent, Esc Esc composer empty; history keeps the typo; interrupted banner; 0 rewinds prompt back in composer; history ["hello first"]; no banner; 1 daemon rewind
S1 correction (wire) one user message with both texts two messages, typo absent
S1 after reload typo persists ["hello first","fixed prompt S1"]
S3 answer streaming, Esc Esc plain stop identical: plain stop, 0 rewinds
S4 draft during turn, Esc Esc draft kept identical: draft kept, 0 rewinds
S5 stop button plain stop taken back, 1 rewind
P0 premise (A/A both arms) snapshots per turn; target gone after a real rewind; next ordinal 3, not 2 identical

Daemon premise

P0 matters because the new client logic resolves an unknown rewind outcome by re-reading GET /session/:id/rewind/snapshots, which is only sound if that listing follows live history and never reuses an id — the two facts the unit tests mock. Measured against the real daemon on both arms: they hold.

The delta commit, isolated: hold A/B head vs intermediate

Hold A/B

scenario arm hold engaged released at evidence
F1 rewind request lost before the daemon head +691 ms +1029 ms 3 listing reads (initial + two "still listed")
intermediate +697 ms +2606 ms 1 read — the 2 s timer, no daemon answer
F3 request lost + two readings unreachable head +450 ms +2619 ms 2 failed / 3 answered reads
intermediate +453 ms +2540 ms 0 failed / 1 answered — never asked again
D1 daemon unreachable indefinitely head yes never (held at +12 s) correction refused; reload escapes
intermediate yes ~2 s correction accepted into the unknown window

F2 (daemon applied the rewind, browser lost the response) is a safety cell, not a discriminator: the live event stream delivers session_rewound within ~100 ms, so both arms end with ["hello first","fixed prompt F2"], unmerged, surviving a reload.

Read-only composer during an unbounded hold

Test efficacy

Mutation matrix

Control green (30 + 8). Six of seven guard mutations killed, each red test named: M0 positive control (2 red), M2/M3 (releases prompts once the daemon has twice listed the turn as still there), M4 (stops asking once the transcript shows the rewind), M5 (releases prompts when the snapshots cannot be read), M7 (2 red). Survivors: M6 (flatten the retry backoff — the author-disclosed one) and M1, discussed below.

Assertions

Targeted gates

cancelledTurn.test.ts 8/8 · App.test.tsx -t "cancelling a prompt before it produced anything" 30 passed · DaemonSessionProvider.test.tsx (recovery/originator) 4 passed · actions.test.ts -t "failed rewind calls" 2 passed · web-shell tsc --noEmit clean. Full suite/lint left to CI: Test (ubuntu-latest, Node 22.x) pass; note web-shell E2E Smoke was still pending when this was posted.

Findings (non-blocking)

  1. Survivor M1 is behaviour-neutral but untested. Deleting if (listed === false) return false; from rewindMissed left all 30 take-back tests green. Tracing every reachable path shows both variants end in rewound = true waiting for the transcript; the clause only stops the polling loop early, so it bounds polling traffic rather than deciding an outcome. Coverage gap on an optimisation, not a defect — a test counting listing reads after the transcript lifts would pin it. Suggestion only.
  2. The hold has no in-UI explanation or escape when the daemon never answers. D1: composer read-only 12+ s, correction refused, recovery only via switching session (by construction, rewindSyncBlocked is session-scoped) or reload (measured). This is the stated design and the safe side of the trade, but a user staring at a frozen composer gets no hint why. Suggestion: surface the pending-rewind state in the placeholder or a toast.
  3. The disclosed residual is real but narrow; its three doors, from source. requestSessionStatus (the listing) calls extMethod directly (session-control-plane.ts:6874-6882) and bypasses entry.promptQueue, while rewindSession chains onto it (:15432). Queue occupants that can hold a rewind >500 ms without tripping the admission guard (pendingPromptCount/promptActive/backgroundTurn) are branchSession (:12402), the cwd-change/transfer chain (:12459), and the quarantine-gated operation at :15202. Not constructed end-to-end (needs daemon-side fault injection); the proposed follow-up of serving the listing through the same history-mutation gate would close it.

Not covered

SSH workspaces and split-view ChatPane (same gaps declared by the author); a real-stack reasoning-only turn (the scripted model's reasoning_content produced no transcript blocks in this stack, so S2 measured "nothing recorded" — the thoughts rule is covered by cancelledTurn.test.ts and pinned by M0); the delayed-session_rewound window on the real stack (unit test + P0 premise instead); the residual race end-to-end; round-1 scenarios not re-measured at this head (12-in-a-row, pasted image, observer tab, other-tab cancel, reload-mid-turn — the delta touches only the unknown-failure path); the full web-shell suite, lint and @smoke locally; non-macOS browsers.

Methodology

Detached worktrees at base/head plus an overlay arm; head installed with corepack pnpm install --frozen-lockfile, base reusing it via cp -al (no lockfile change) with readlink -f asserting workspace links resolve into the base tree; bundles proven to carry pre-fix source (cancelledTurn.ts absent from base, rewindMissed absent from the intermediate bundle). Oracles: the daemon's /session/:id/transcript, its lifecycle log markers (prompt enqueued, prompt turn completed, cancel sent, session rewind completed, rewind snapshots loaded), the scripted model's per-request user-part log, and the composer's contenteditable/text. Faults via Playwright route interception on the two rewind routes (separate globs — one pattern silently misses the listing). Assertions in harness/check.cjs, written from the PR's claims before the runs were inspected; base/intermediate cells asserting the broken behaviour count as passes. Harness bugs found and fixed on my side, none in the PR: Node 25 marks a fully-read request destroyed (truncated every scripted SSE response after its first frame); the CodeMirror placeholder reads as composer text when empty; the hold timer first mis-read "not yet engaged" as "released". Raw logs, wire logs, per-scenario JSON, gate logs and the mutation matrix are in the run artifacts.

@yiliang114 yiliang114 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.

LGTM at 16bd737b. I re-checked the races from the earlier review rounds against the current head and all of them are closed:

  • The take-back decides on the turn's settlement event (useDaemonPromptSettled), not when cancel returns, so output still in flight keeps the turn.
  • The composer hold spans the whole rewind, and an unknown-outcome failure no longer releases on a timer — rewindMissed asks the daemon instead. The two-consecutive-listed rule is sound now that getRewindSnapshots awaits rewindTail, which is set synchronously at rewind admission on the bridge.
  • The hold's clear sites are exhaustive — transcript showing the rewind, explicit release on refusal / no-rewind / proven-missed, and the session-switch path — so no reachable strand beyond the disclosed daemon-unreachable case.

Also verified: originatorClientId is bridge-stamped from validated admission state rather than client-supplied, so one tab cannot take back another client's turn; bareTurnIndex plus the newest.turnIndex check pin the rewind to this tab's own newest turn; the silent flag is opt-in and leaves the existing edit-and-resend callers' error notices unchanged.

CI green on head.

@wenshao
wenshao added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 28e0f3a Oct 6, 2026
68 of 69 checks passed
@qwen-code-review-bot

qwen-code-review-bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Qwen Triage finished — view run. See the stage comments in this thread for the result.

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — one thing up front: this merged at 15:35 UTC on 2026-10-06 (merge commit 28e0f3a8, two human approvals already standing) while this triage run was still in flight. The /triage request went in at 14:58, 37 minutes before the merge. So this is a retrospective gate pass — nothing below gated anything, and no approve/request-changes action is possible or meaningful on a merged PR.

Template looks good ✓ — every heading is present and actually filled in, including a real reviewer test plan, a per-scenario before/after table, and a complete Chinese translation.

Problem: observed, not theoretical. The PR measures main against a real daemon with a scripted model and shows the concrete consequence rather than asserting one: the corrected prompt reaches the model merged with the mistaken one — the author reports the model receiving "HOLD oops typo S1\nfixed prompt S1" as a single user message — plus an "interrupted · Continue execution" banner offering to resume a turn the user just deliberately abandoned. There's no linked issue, but there is a reproduction with before/after artifacts and raw harness output, and a parity argument that doesn't depend on them: the TUI already restores a cancelled prompt (AppContainer.tsx), so the two surfaces disagreed about what Esc Esc means. That clears the problem-existence bar comfortably.

Direction: aligned. Esc Esc is a core gesture, and "cancelling a prompt you just mistyped should not leave it in the model's history" is a user-facing correctness gap, not a solution looking for a problem. claude-code's CHANGELOG has no entry for restoring a cancelled prompt to the composer, but Esc Esc / rewind / interrupted-turn handling is an actively fixed area there (rewind-menu responsiveness, the Interrupted-row hint, stop semantics for queued messages), so the direction matches where the reference product is going.

Size: the change spans two packages (acp-bridge, web-shell), which brings it under the cross-package clause of the core-module gate. It's clear on both counts regardless — the author has admin on this repo, so the two-tier gate doesn't apply, and at 416 production lines against 1032 test lines it's under the 500-line escalation threshold and well under the 1000-line advisory. Breakdown: 22 production lines in acp-bridge (bridge types + session control plane), 394 in web-shell, of which 325 sit in App.tsx. The test-to-production ratio (~2.5:1) is worth naming on its own — this is not a PR that shipped a concurrency change untested.

Approach: the scope feels right, and it reuses rather than invents. The prompt hold is the same mechanism the inline edit-and-resend path already used, generalized and renamed (pendingEditRewind → pendingRewind) instead of duplicated, and the composer-restore sequence mirrors that existing path. Deciding at the turn's terminal event rather than at cancel time is the correct call and the harder one — the round-2 evidence in the PR body is exactly the case that proves it, where output still in flight when the cancel landed would otherwise be dropped.

One honest architectural question, offered as a question and not a blocker: this places "a cancelled turn that produced nothing gets dropped" in the client, which is why correctness needs four cooperating pieces — snapshot listing, a prompt hold, an originator stamp on the settlement, and a bridge-side ordering fix so a listing can't be answered ahead of a rewind it has already admitted. The daemon owns the history and already stamps terminal frames with the submitting client, so it could own the decision and every client would get it by construction. The counter-argument is equally real: the TUI already does its own restore, so moving this daemon-side would change behaviour for a second client that didn't ask for it. Worth revisiting if a third surface ever needs the same semantics — three client-side copies of a race-prone state machine is where this gets expensive.

Risk: no elevated risk signals. None of the changed files match the high-risk paths from the repo's revert-history analysis (shell.ts, mcp-client, geminiChat, acpConnection, sandbox/relaunch, and friends).

Merged, so there's nothing to gate. Code review findings and the CI evidence are in the next comment. 🔍

中文说明

感谢贡献!先说明一点:本 PR 已于 2026-10-06 15:35 UTC 合并(合并提交 28e0f3a8,此前已有两位人工 reviewer 批准),而本次 triage 运行当时仍在进行中。/triage 请求发出于 14:58,比合并早 37 分钟。因此这是一次事后的准入复查——以下内容没有拦截任何东西,对已合并的 PR 也无法再执行 approve / request-changes。

模板完整 ✓ —— 所有必需小节都存在且有实质内容,包含可执行的 reviewer 测试计划、逐场景 before/after 表格,以及完整的中文翻译。

问题: 已观测到的问题,不是理论性加固。PR 用真实 daemon + 脚本化模型对 main 做了实测,给出的是具体后果而不是断言:更正后的 prompt 会与写错的那条合并送给模型——作者报告模型收到的是单条用户消息 "HOLD oops typo S1\nfixed prompt S1"——同时还会出现"已中断 · 继续执行"的横幅,提示用户恢复一个他刚刚主动放弃的轮次。虽然没有关联 issue,但有复现、有 before/after 证据与原始 harness 输出;另有一个不依赖这些材料的对等性论据:TUI 早已实现取消后回填(AppContainer.tsx),两个界面此前对 Esc Esc 的语义理解不一致。问题存在性这一关通过得很干脆。

方向: 对齐。Esc Esc 是核心手势,"刚打错的 prompt 被取消后不应留在模型历史里"是面向用户的正确性缺口,而不是为方案找问题。claude-code 的 CHANGELOG 没有"把被取消的 prompt 回填输入框"的条目,但 Esc Esc / rewind / 中断轮次处理在该产品里是持续修复的活跃区域(rewind 菜单响应性、Interrupted 行提示、排队消息的停止语义),所以方向与参考产品的演进一致。

规模: 改动跨两个 package(acp-bridge、web-shell),因此落入核心模块门禁的"跨 package"条款。但两条都不构成阻碍——作者在本仓库拥有 admin 权限,两级门禁不适用;并且生产代码 416 行 vs 测试 1032 行,低于 500 行的升级阈值,也远低于 1000 行的大 PR 建议线。细分:acp-bridge 22 行生产代码(bridge 类型 + session control plane),web-shell 394 行,其中 325 行在 App.tsx。测试与生产代码约 2.5:1 的比例本身值得点出——这不是一个未加测试就提交并发改动的 PR。

方案: 范围合理,且是复用而非新造。prompt 挂起(hold)沿用了 inline edit-and-resend 路径已有的机制,做了泛化与重命名(pendingEditRewind → pendingRewind)而不是复制一份;输入框回填序列也沿用了该既有路径。在轮次的终点事件而非取消时刻做判定,是更难但正确的选择——PR 描述里 round 2 的证据恰好证明了这一点:取消落地时仍在传输中的输出,否则会被一并丢弃。

一个诚恳的架构问题,作为问题提出而非阻塞项:这个改动把"取消且无产出的轮次应被丢弃"放在了客户端,因此正确性需要四个部件协同——快照列举、prompt 挂起、settlement 上的 originator 标记,以及 bridge 侧的排序修复(避免列举请求抢在已被接纳的 rewind 之前被应答)。历史归 daemon 所有,且 daemon 已经在终点帧上标记了提交方 client,因此这个判定本可以由 daemon 承担,所有客户端都能天然获得一致行为。反方理由同样成立:TUI 已经自行实现回填,若下沉到 daemon 会改变另一个并未提出该需求的客户端的行为。如果将来第三个界面也需要同样的语义,值得重新考虑——三份客户端实现、各自维护一个易竞态的状态机,才是这个方案真正变贵的地方。

风险: 无升级风险信号。改动文件均未命中本仓库 revert 历史分析得出的高风险路径(shell.ts、mcp-client、geminiChat、acpConnection、sandbox/relaunch 等)。

PR 已合并,无门禁可执行。代码审查发现与 CI 证据见下一条评论。🔍

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

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Retrospective review — the PR merged before this run posted (see the Stage 1 comment). Static review only: per this gate's rules I did not build, run, or execute anything from the PR's tree. The testing evidence below is the PR's own CI, read through the API. Nothing here was verified by driving the product.

Code review

The interesting risk in this change is not the feature, it's the concurrency — a client-side rewind that can drop a turn it shouldn't, or a hold that never lifts. I went after those specifically, against the pre-PR tree rather than the PR's own description. Three things I expected to be problems are not:

The new SessionNotFoundError throw in getRewindSnapshots is not a new error path. The added early guard looks like it changes what an unknown session returns, but requestSessionStatus — the function the call previously fell straight through to — already throws exactly new SessionNotFoundError(sessionId) as its first statement (session-control-plane.ts:6870). The guard exists because the code now has to read entry.rewindTail before delegating. Error semantics for the HTTP route (serve/routes/session.ts:10212) and the VS Code embedded caller are unchanged.

await entry.rewindTail cannot park a snapshot listing behind a long turn. This was my main deadlock/latency worry: rewinds queue on entry.promptQueue, so if a listing had to wait out a queued rewind, and a rewind could sit behind a running prompt, opening the rewind picker mid-turn would hang. It can't — rewindSession rejects synchronously with SessionBusyError when pendingPromptCount > 0 || promptActive || backgroundTurn (session-control-plane.ts:15421-15430), so a rewind is only ever admitted into an idle queue. rewindTail is also rewindResult.then(() => undefined, () => undefined) — it swallows both outcomes, so a failed rewind can't wedge every later listing, which is what its own doc comment claims. And the client bounds the wait anyway via withActionTimeout. The ordering fix does what it says without introducing a stall.

No dead switches. Both new option/field additions have live producers and consumers, which is the failure mode I check for first: originatorClientId is genuinely stamped upstream on the bridge envelope (eventBus.ts:96, set at session-control-plane.ts:1378 and :1416), flows through promptSettledFromTurnEvent into the settlement event, and is read by settlesCancelledPrompt — so the "admission response never reached the tab" fallback is real, not decorative. The new silent flags follow an existing convention rather than inventing one (types.ts:436 and :606, read at actions.ts:2598, :2998, :3031), are set to true by the take-back path, and are read at both throw sites.

Reuse is good throughout: the composer-restore sequence mirrors the existing inline edit-and-resend path (App.tsx:16578-16583) call for call instead of growing a parallel helper, cancelledTurnProducedNothing is a 30-line pure predicate in its own file with its own test, and the identity matching reuses matchesUserMessageIdentity including its allowLocalId third parameter. The guard ordering in takeBackCancelledPrompt is right — bareTurnIndex() is re-evaluated after the await on the snapshot listing, so a transcript that moved during the round trip is caught rather than trusted.

Non-blocking, named so they're on the record rather than lost:

  • recover? was made optional but every call site still passes it. The base type required it and the one existing call site supplied it (App.tsx:16131-16136); the new take-back site supplies it too. So the ? and the pendingRewind.recover?.() guard defend a case that cannot occur. Trivial, but it's exactly the kind of unrequested flexibility AGENTS.md asks not to add.
  • rewindMissed is an unbounded for (;;) poll. Its liveness depends entirely on stillHeld() — hold lifted, unmount, or session change — not on any attempt cap, pacing at 0.5 s → 1 s → 2 s forever while the daemon is unreachable. That's a deliberate, documented decision (a timer can't safely release a hold whose rewind outcome is unknown, and the author is right that it can't), and the failure mode is correct: it keeps asking rather than guessing. Flagging it because it's the one loop in the change with no bound, and a future reader could mistake that for an oversight.
  • The stuck-composer tradeoff is now reachable from a much more common gesture. If the daemon applies the rewind but session_rewound never reaches the transcript, the composer stays read-only until reconnect or reload. The PR body is upfront about this and it's the same tradeoff the pre-existing inline-edit path carries — but inline edit is a rare, deliberate action, while Esc Esc is reflexive. The exposure widened even though the mechanism didn't. Not a defect; worth knowing when the first report arrives.
  • The Test Plan's platform deferral points at coverage this PR's CI never runs. "macOS and Windows were not run locally; the change is browser-side only and is left to CI there" — but test_macos (ci.yml:1625-1634) and test_windows (ci.yml:1731-1741) are gated to merge_group / schedule / workflow_dispatch, so on a pull_request event they report skipped, which is exactly what they did on this head. Those legs will only see the merged tree via the nightly schedule: cron '17 19 * * *' on main. Given the change is browser-side JS and the ubuntu leg is green I don't think this hid anything, but the Tested-on table's ⚠️ reasoning was inaccurate, and that matters because it's the sentence a reviewer relies on to not check.
  • No design doc for a change with this much concurrency semantics. AGENTS.md asks for one under docs/design/ (English + zh-CN) for non-trivial work touching multiple files, and this is 416 production lines across two packages with a four-piece ordering invariant. The PR body genuinely carries most of that rationale — arguably better than a design doc would, since it's tied to measured evidence — so this is a note about the convention, not a gap in the reasoning. The invariant that a snapshot listing must be ordered after every admitted rewind now lives in a doc comment on rewindTail, which is the right place, but it's the kind of thing that gets broken by someone who never reads it.
sequenceDiagram
    participant P1 as User (Web Shell tab)
    participant P2 as App.tsx handleCancel
    participant P3 as Daemon session
    participant P4 as DaemonSessionProvider
    participant P5 as takeBackCancelledPrompt
    participant P6 as acp-bridge control plane
    P1->>P2: Esc Esc (cancel the turn)
    P2->>P2: snapshot the in-flight prompt and its id
    P2->>P3: cancel
    P3-->>P4: turn_complete or turn_error (terminal frame)
    P4->>P5: prompt settled, outcome cancelled, originator stamped
    P5->>P5: nothing decided yet - inspect what the turn produced
    P5->>P1: restore text, images, files into the composer
    P5->>P5: hold prompts (composer read-only)
    P5->>P6: getRewindSnapshots (silent)
    P6->>P6: await rewindTail so the listing follows admitted rewinds
    P6-->>P5: newest snapshot
    P5->>P6: rewindSession (rewindFiles false, silent)
    P6->>P3: session rewind
    P3-->>P4: session_rewound
    P4->>P5: transcript caught up - hold lifts, prompts released
Loading
Files changed (all 12)
File What changed
packages/web-shell/client/App.tsx The change proper - in-flight prompt tracking, the take-back state machine, the generalized hold, and the unknown-outcome poll. 325 production lines.
packages/web-shell/client/App.test.tsx 740 lines of tests against the real App - each guard, each race, and a negative case per guard.
packages/web-shell/client/utils/cancelledTurn.ts New 30-line pure predicate - do the blocks after the prompt count as "nothing produced".
packages/web-shell/client/utils/cancelledTurn.test.ts New - pins that thoughts and notices pass, and that assistant, tool, shell, permission and user blocks all keep the turn.
packages/web-shell/client/daemon/session/types.ts Adds originatorClientId to the settlement event and a silent flag to the two rewind actions.
packages/web-shell/client/daemon/session/actions.ts Threads the prompt id into onAdmitted and honors silent at both throw sites.
packages/web-shell/client/daemon/session/actions.test.ts New - failed rewind calls report a notice unless silent.
packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx Carries the originator onto the settlement and re-reads recovery status when a rewind drops the interrupted turn.
packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx New - originator propagation and post-rewind recovery refresh.
packages/acp-bridge/src/session-control-plane.ts The ordering fix - rewindTail per session entry, awaited before a snapshot listing is answered.
packages/acp-bridge/src/bridgeTypes.ts Doc comment recording the new ordering guarantee on the interface.
packages/acp-bridge/src/bridge.test.ts New - a listing waits for a rewind queued behind a gated branch, and a failed rewind does not block later listings.

Testing evidence — the PR's own CI

What this section carries: check-run results for the reviewed head, read through the API. 81 check-runs on 16bd737bd3be827e4d4c312d71e30b0d0829a675 — 25 success, 53 skipped, 3 cancelled, 0 failure, 0 pending. All three pull_request-event workflow runs (Qwen Code CI, SDK Java, Web-shell Visuals) completed success.

The three cancelled checks are bot orchestration, not the PR's CI — fallback-comment, review-pr, delay-automatic-review, all superseded when the review lane was cut short by the merge. No log excerpt to quote, because nothing failed.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Lint & Static (ubuntu-latest, Node 22.x) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Capture web-shell visuals (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
SDK Java legs (Java 11 / 17 / 21, macOS, Windows, Real daemon E2E, MySQL 8.4, MariaDB, Flyway uniqueness) success
Test (macos-latest, Node 22.x) skipped — event-gated off pull_request
Test (windows-latest, Node 22.x) skipped — event-gated off pull_request
Integration Tests (CLI, No Sandbox) skipped
fallback-comment / review-pr / delay-automatic-review cancelled (bot orchestration, superseded by the merge)

Two honest gaps in that signal, both about what green does not prove here:

The macOS and Windows unit legs are skipped by design on PR events, so the head that merged was only ever unit-tested on Linux. Not a defect in the PR — it's the CI configuration — but it means the Tested-on table's deferral to CI was deferring to a run that doesn't exist on this trigger.

More importantly, the suite passing does not by itself establish that the suite pins the change. The author reports 28 targeted mutations of the take-back path, each failing at least one new test, with one surviving mutation (the flat backoff, which only paces retries) — that is the right kind of evidence and it's a strong claim, but it is the author's own measurement, run on the author's own harness on Linux, not something I re-ran or could re-run from here. The same applies to the 19-scenario real-stack table (real qwen serve, real Web Shell in Chromium, scripted model) and to the two non-recurring misses the author disclosed against their own change — 2 misses across several hundred take-backs, both degrading to a plain stop rather than a wrong rewind. Disclosing those was the right call and is a point in the PR's favour; I'm attributing them, not adopting them.

Sandboxed verification would settle the part CI can't: @qwen-code /verify — that the hold genuinely survives an unknown rewind outcome (request lost, response lost, daemon unreachable) and that dropping either the originator fallback or the two-reading rule in rewindMissed fails a test, is not observable from a green suite, since all three are timing paths whose unit tests use controlled clocks. Post-merge this is a confirmation rather than a gate, so it's optional — but if anyone ever reports a composer stuck read-only after Esc Esc, that A/B is the fastest way to find out whether the guard or the timing is at fault.

中文说明

事后审查——PR 已在本条评论发出前合并(见 Stage 1)。仅做静态审查:按本门禁规则,我没有构建、运行或执行 PR 代码树中的任何内容。下方测试证据来自 PR 自身的 CI,通过 API 读取。本次审查没有通过实际操作产品来验证任何行为。

代码审查。 这个改动真正的风险不在功能本身,而在并发——客户端 rewind 可能丢弃不该丢的轮次,或者 hold 永远不解除。我针对这两点做了核查,且是对照 PR 之前的代码树,而不是采信 PR 自己的描述。三处我原本预期会出问题的地方,实际上没有问题:

getRewindSnapshots 中新增的 SessionNotFoundError 并不是一条新的错误路径。 新增的前置判断看起来改变了未知 session 的返回,但此前代码直接落到的 requestSessionStatus,其第一条语句本来就抛出完全相同的 new SessionNotFoundError(sessionId)(session-control-plane.ts:6870)。新增判断只是因为现在必须先读 entry.rewindTail 再委派。HTTP 路由(serve/routes/session.ts:10212)与 VS Code 内嵌调用方的错误语义没有变化。

await entry.rewindTail 不会让快照列举卡在一个长轮次后面。 这是我主要担心的死锁/延迟点:rewind 排队在 entry.promptQueue 上,如果列举必须等完一个排队中的 rewind,而 rewind 又可能排在运行中的 prompt 之后,那么在轮次进行中打开 rewind 选择器就会挂住。实际不会——rewindSession 在 pendingPromptCount > 0 || promptActive || backgroundTurn 时同步拒绝并抛 SessionBusyError(session-control-plane.ts:15421-15430),因此 rewind 只会在空闲队列中被接纳。rewindTail 也是 rewindResult.then(() => undefined, () => undefined)——两种结果都被吞掉,所以失败的 rewind 不会阻塞后续所有列举,与其文档注释所述一致。客户端侧还有 withActionTimeout 兜底。这个排序修复达成了目的,且没有引入停顿。

没有失效开关(dead switch)。 新增的选项/字段都有真实的生产方与消费方,这是我最先检查的失效模式:originatorClientId 确实在上游 bridge envelope 上被标记(eventBus.ts:96,在 session-control-plane.ts:1378 与 :1416 设置),经 promptSettledFromTurnEvent 流入 settlement 事件,并被 settlesCancelledPrompt 读取——所以"准入响应未到达本标签页"这条回退路径是真实生效的,不是装饰。新增的 silent 沿用既有约定而非另创一套(types.ts:436 与 :606,在 actions.ts:2598、:2998、:3031 被读取),由 take-back 路径设为 true,并在两处抛出点被读取。

整体复用做得好:输入框回填序列与既有 inline edit-and-resend 路径(App.tsx:16578-16583)逐调用一致,没有另造平行 helper;cancelledTurnProducedNothing 是独立文件中的 30 行纯函数并配有独立测试;身份匹配复用了 matchesUserMessageIdentity,包括其 allowLocalId 第三个参数。takeBackCancelledPrompt 中守卫顺序正确——bareTurnIndex() 在快照列举的 await 之后重新求值,因此往返期间发生变化的 transcript 会被捕获,而不是被信任。

以下为不阻塞合并、但希望留档的事项:

  • recover? 被改为可选,但所有调用点仍然传它。 基线类型要求该字段,唯一既有调用点也传了(App.tsx:16131-16136);新增的 take-back 调用点同样传了。因此这个 ? 与 pendingRewind.recover?.() 的保护,防的是一个不可能发生的情况。极其微小,但这正是 AGENTS.md 要求不要添加的那类"未被请求的灵活性"。
  • rewindMissed 是一个无上界的 for (;;) 轮询。 它的终止完全依赖 stillHeld()——hold 解除、卸载或会话切换——而不是任何次数上限;daemon 不可达时会以 0.5 s → 1 s → 2 s 的节奏无限重试。这是经过深思且有文档的决策(计时器无法安全释放一个 rewind 结果未知的 hold,作者认为不能这么做是对的),失效模式也是正确的:它会继续询问而不是猜测。之所以点出,是因为这是整个改动中唯一没有上界的循环,后来的读者可能误认为是疏漏。
  • "输入框卡住"这一取舍现在可由更常见的手势触发。 如果 daemon 已执行 rewind 但 session_rewound 始终没有到达 transcript,输入框会保持只读直到重连或刷新。PR 描述对此坦率说明,且这与既有 inline-edit 路径承担的是同一取舍——但 inline edit 是少见的、有意的操作,而 Esc Esc 是反射性动作。机制没变,暴露面变宽了。这不是缺陷;等第一份相关反馈到来时值得记得这一点。
  • 测试计划中的平台豁免,指向的是本 PR CI 根本不会运行的覆盖。 "macOS 与 Windows 未在本地运行;改动仅在浏览器侧,交由 CI 覆盖"——但 test_macos(ci.yml:1625-1634)与 test_windows(ci.yml:1731-1741)被限定为 merge_group / schedule / workflow_dispatch,因此在 pull_request 事件下它们报告 skipped,在这个 head 上正是如此。这两个 leg 只能通过 main 上每夜的 schedule: cron '17 19 * * *' 看到已合并的代码树。鉴于改动是浏览器侧 JS 且 ubuntu leg 全绿,我不认为这掩盖了什么问题,但 Tested-on 表格中 ⚠️ 的理由是不准确的——而这一点很重要,因为那正是 reviewer 赖以免于自查的那句话。
  • 一个具有这么多并发语义的改动没有设计文档。 AGENTS.md 要求跨多文件的非平凡工作在 docs/design/ 下提供设计文档(英文 + zh-CN),而这是跨两个 package 的 416 行生产代码,并带有一个由四部件构成的排序不变量。PR 描述实际上承载了其中大部分设计理由——可以说比设计文档更好,因为它与实测证据绑定——所以这是关于约定的提醒,而不是推理上的缺口。"快照列举必须排在每个已接纳的 rewind 之后"这一不变量现在写在 rewindTail 的文档注释里,位置是对的,但这类东西容易被从未读过该注释的人破坏。

测试证据。 本节承载的是:通过 API 读取的、针对被审查 head 的 check-run 结果。16bd737bd3be827e4d4c312d71e30b0d0829a675 上共 81 个 check-run——25 success、53 skipped、3 cancelled、0 failure、0 pending。三个 pull_request 事件的 workflow 运行(Qwen Code CI、SDK Java、Web-shell Visuals)全部 success 完成。三个 cancelled 是 bot 编排任务而非 PR 的 CI(fallback-comment、review-pr、delay-automatic-review,因合并导致审查流程被截断而被取代)。没有失败,因此没有日志摘录可引。

该信号有两处需要坦率说明的空白,都关乎"绿灯不能证明什么":macOS 与 Windows 单元测试 leg 在 PR 事件下按设计为 skipped,因此被合并的 head 只在 Linux 上跑过单元测试——这不是 PR 的缺陷,而是 CI 配置使然,但意味着 Tested-on 表格对 CI 的豁免,豁免给了一个在该触发条件下并不存在的运行。

更关键的是,测试套件通过本身并不能证明该套件钉住了这个改动。作者报告对 take-back 路径做了 28 处定向变异,每处都至少使一个新测试失败,仅有一处变异存活(扁平退避,它只影响重试节奏)——这是正确类型的证据,也是很有力的主张,但这是作者本人的度量,在作者自己的 harness、Linux 环境上运行,我没有重跑也无法在此重跑。 同理适用于 19 场景的真实栈表格(真实 qwen serve、Chromium 中的真实 Web Shell、脚本化模型),以及作者主动披露的两次未复现失误——在数百次 take-back 中 2 次失误,且都退化为普通停止而非错误的 rewind。主动披露是正确做法,也是这个 PR 的加分项;我是在标注出处,而不是代为采信。

沙箱化验证可以解决 CI 解决不了的部分:@qwen-code /verify——hold 是否真的能在 rewind 结果未知的情况下保持(请求丢失、响应丢失、daemon 不可达),以及删除 originator 回退或 rewindMissed 中的"两次读取"规则是否会使测试失败,都无法从绿灯套件中观察到,因为这三条都是时序路径,而其单元测试使用的是受控时钟。合并之后这属于确认而非门禁,因此是可选的——但如果将来有人报告 Esc Esc 之后输入框卡在只读状态,这个 A/B 是判断问题出在守卫还是时序上的最快方式。

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

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — I went looking for the four things that break changes like this and all four came back clean on verification against the pre-PR tree; what's left is named nits and one honest reservation about where the complexity now lives.

No gate action is possible or appropriate here. The PR merged at 15:35 UTC on 2026-10-06 (28e0f3a8) with two human approvals already standing, 37 minutes after the /triage request that started this run. So this is a reflection, not a verdict with teeth: I'm not approving (an approval on a merged PR attests to nothing, and there's nothing left to gate), not requesting changes (I found nothing that would have justified it), and not deferring to a maintainer (there's no unresolved question that needs a human call — the two things worth the author's attention are in the Stage 2 comment and the author is a maintainer who will see them).

On the substance. My independent proposal before reading the diff was: reuse the existing edit-and-resend rewind and its prompt hold, and decide at the turn's terminal event rather than at cancel time, because at cancel time you cannot distinguish "nothing produced yet" from "nothing produced". The PR does exactly that — and the second half of it is the part I would probably have gotten wrong under time pressure, since deciding at cancel time is simpler, feels correct, and passes every test you'd think to write first. The PR body documents an intermediate build that made precisely that mistake and the scenario that caught it. That's the strongest single signal in this review: the author built the wrong version, found it, and kept the evidence instead of quietly rewriting history.

The simpler path I'd have reached for — letting the daemon own "a cancelled turn that produced nothing is dropped", so every client inherits it — is the one I raised in Stage 1, and I'll leave it as a question rather than a criticism. It would remove the need for the originator stamp, the snapshot round trip, and the bridge ordering fix, all of which exist only because the decision lives client-side. But it would also change behaviour for the TUI, which already does its own restore and didn't ask for this. Choosing the smaller blast radius over the cleaner architecture is a defensible call, and it's the one AGENTS.md would favour. If a third surface ever needs the same semantics, that's the moment to move it down.

Would I curse or thank the author in six months? Thank, mostly. The doc comments sit exactly where the invariants are non-obvious — on rewindTail, on the two-reading rule in rewindMissed, on why no timer may release a hold of unknown outcome — and they explain why rather than restating the code, which is the house style. The test names read as a specification of the guards rather than a list of scenarios. The mutation testing means the tests are load-bearing rather than decorative. And the two non-reproducing misses were disclosed against the author's own change, with the raw JSON kept, which is the behaviour I'd want from anyone touching a race-prone path.

My one real reservation, and it's the reason this is 4/5 and not 5/5: this adds 325 production lines to an App.tsx that is already 22,714 lines. The take-back machinery — in-flight prompt tracking, the settlement matcher, the hold lifecycle, the unknown-outcome poll — is a coherent unit with a single responsibility and a well-defined boundary, and it now lives inline in the largest file in the client alongside everything else. Extracting it into a hook would have made the state machine reviewable on its own terms, and would give the next person who touches cancel semantics a file they can hold in their head instead of a 22k-line one they have to search. To be fair: App.tsx was already in that condition, the inline edit-and-resend path this generalizes lives there too, and extracting it would have widened the diff and delayed a real fix — so I don't think this was the wrong call for this PR. But the file got meaningfully worse, and that debt compounds quietly rather than announcing itself. Worth a follow-up, not a revert.

Am I approving-equivalent because I ran out of reasons to say no? I don't think so. The specific failure modes I checked — a snapshot listing parking behind a long turn, a new error path for unknown sessions, an unpopulated field read as a fallback, a guard no test pins — each had a concrete answer in the base code, not just a plausible one. What I'd want before calling this 5/5 is the part nobody can supply from a static review: independent confirmation on a real stack that the hold survives a lost rewind response, which is what the /verify line in Stage 2 is for.

For the record, so nothing here is silently dropped: the two items worth tracking as follow-ups are the test_macos / test_windows event gating (the PR's Tested-on table deferred to CI legs that don't run on pull_request, and a future contributor will make the same assumption), and the missing design doc for a change whose central invariant is now recorded only in a doc comment.

中文说明

信心度:4/5 —— 我专门去找这类改动最容易出问题的四个点,对照 PR 之前的代码树逐一核查,四处都没有问题;剩下的是已点明的小瑕疵,以及一个关于复杂度落点的诚恳保留意见。

此处无法也不应执行任何门禁动作。 PR 已于 2026-10-06 15:35 UTC 合并(28e0f3a8),此前已有两位人工 reviewer 批准,比启动本次运行的 /triage 请求晚 37 分钟。因此这是一次反思,而不是有约束力的结论:我不做 approve(对已合并 PR 的批准不构成任何保证,也没有东西需要拦截),不做 request changes(我没有发现足以支撑它的问题),也不转交 maintainer(没有需要人工裁断的悬而未决问题——两处值得作者注意的事项已写在 Stage 2 评论中,而作者本人就是会看到该评论的 maintainer)。

关于实质内容。在读 diff 之前,我的独立方案是:复用既有的 edit-and-resend rewind 及其 prompt hold,并且在轮次的终点事件而非取消时刻做判定,因为在取消时刻无法区分"还没有产出"和"没有产出"。这个 PR 正是这么做的——而其中第二部分是我在时间压力下很可能做错的地方:在取消时刻判定更简单、感觉上正确,并且能通过你最先想到的所有测试。PR 描述记录了一个恰好犯了这个错误的中间构建,以及捕获它的那个场景。这是本次审查中最有力的单个信号:作者构建了错误的版本、发现了它,并且保留了证据,而不是悄悄改写历史。

我原本会选择的更简路径——让 daemon 承担"取消且无产出的轮次被丢弃"这一判定,从而所有客户端天然继承——正是我在 Stage 1 提出的问题,我在这里把它留作问题而非批评。它可以省去 originator 标记、快照往返以及 bridge 侧排序修复,而这三者之所以存在,只是因为判定放在了客户端。但它同时会改变 TUI 的行为,而 TUI 已经自行实现回填,并未提出这个需求。在更小的影响面与更干净的架构之间选择前者,是一个站得住脚的决定,也是 AGENTS.md 会倾向的决定。如果将来第三个界面也需要同样的语义,那就是把它下沉的时机。

六个月后我会骂作者还是感谢作者?主要是感谢。文档注释恰好落在不变量不明显的位置——rewindTail 上、rewindMissed 的"两次读取"规则上、以及为什么结果未知的 hold 不允许由计时器释放——并且解释的是为什么,而不是复述代码,这符合本仓库风格。测试名称读起来像是守卫条件的规格说明,而不是场景清单。变异测试意味着这些测试是承重的,不是装饰。而两次未复现的失误是作者针对自己的改动主动披露的,并保留了原始 JSON,这正是我希望任何触碰易竞态路径的人都采取的做法。

我唯一真正的保留意见,也是这里是 4/5 而非 5/5 的原因:这个改动向一个已有 22,714 行的 App.tsx 又加了 325 行生产代码。take-back 机制——in-flight prompt 跟踪、settlement 匹配、hold 生命周期、结果未知的轮询——是一个职责单一、边界清晰的完整单元,如今却内联在客户端最大的文件里,和其他一切混在一起。把它抽成一个 hook 会让这个状态机可以按其自身条件被审查,也会让下一个改动取消语义的人面对一个能装进脑子的文件,而不是一个 22k 行、必须靠搜索的文件。公平地说:App.tsx 本来就是这个状态,本次所泛化的 inline edit-and-resend 路径也在里面,而且抽离会扩大 diff、推迟一个真实的修复——所以我不认为这对本 PR 而言是错误选择。但这个文件确实明显变差了,而这笔债务是悄悄累积的,不会自己发声。值得作为后续工作,不值得回退。

我是否因为说不出更多反对理由而给出等同批准的判断?我不认为是。我核查的那些具体失效模式——快照列举卡在长轮次之后、未知 session 的新错误路径、一个从未被赋值却被当作回退读取的字段、一个没有测试钉住的守卫——每一个在基线代码里都有具体答案,而不只是看似合理的答案。要让我给出 5/5,还缺一个静态审查无法提供的部分:在真实栈上独立确认 hold 能在 rewind 响应丢失时保持住,这正是 Stage 2 中那行 /verify 的用途。

留档,以免这里的内容被无声丢弃:两项值得作为后续跟进的事项是 test_macos / test_windows 的事件门控(本 PR 的 Tested-on 表格把覆盖豁免给了在 pull_request 上并不运行的 CI leg,未来的贡献者会做出同样的假设),以及这个改动的核心不变量目前仅记录在一处文档注释中、却缺少设计文档。

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

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because the PR is not open for verification (state=MERGED, draft=false).

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:the PR is not open for verification (state=MERGED, draft=false)。

— Qwen Code · sandboxed verification

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.

4 participants