Repository navigation
feat(web-shell): take back a cancelled prompt that produced nothing - #13488
Conversation
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.
|
评审基于当前提交
验证范围: |
Verdict: merge-ready — 105/105 scripted assertions passed at head
|
| 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) |
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):
| 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
ChatPanekeeps 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.
@smokePlaywright 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.
|
Both points are right, and both are fixed in P1 — decide on the turn's terminal event, not when the cancel returns. The take-back no longer runs from The settled turn is matched by the admitted prompt id, which 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 Verification. Real daemon + real Chromium, same harness as before, client built from
中文两点都成立,均已在 P1 —— 以该轮的终点事件为判定点,而不是取消返回时。 撤回不再挂在 结算的轮次按 admission 返回的 prompt id 匹配( P2 —— 暂扣 prompt 直到回退落地。 撤回在读取快照之前就设置 pending-rewind 状态,并一直保持到 transcript 显示回退(就是内联编辑路径驱动的那个 验证。 真实 daemon + 真实 Chromium,harness 同前,客户端由
|
|
重新验证当前 head
仍需修复 [P2]:未知回退失败后,不能仅因等待 2 秒就解除提交阻塞。 App.tsx:17909–17917 忽略 独立复现:令回退请求以 需要在结果未知时继续阻止新 prompt,直到回退事件应用或通过会话重载/权威状态确认结果;等待超时本身不应当作为安全解锁的依据。现有未知失败测试只覆盖事件在等待窗口内到达,建议补上窗口过期后才到达的用例。 本次验证: |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterℹ️ No screenshot changed against the PR base — but this PR edits 2 render-shaping files:
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 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.
|
Agreed — the 2 s wait was a timer standing in for something the tab could not know. Fixed in 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
"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 One note for the harness: a daemon that goes on listing the turn and later emits Real stack (Playwright route interception in front of the real daemon, the composer's
Two things the PR body now states plainly. During the hold the composer is read-only (the shared 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 中文同意——那 2 秒等待只是用计时器替代了标签页并不掌握的信息。已在 改了什么。 回退调用因 daemon 拒绝以外的任何原因失败后,暂扣现在一直持续到 daemon 给出答案,不再由计时器解除。daemon 只在某一轮仍在其 API 历史中时才会在
"仍被列出"必须看到两次:一次读取可能与仍排在 agent 管道里的回退出自同一次读取、在该回退截断之前被应答——第二次读取只在第一次被应答之后才发出。顺带两处:在发起任何回退之前快照读取失败则立即放行(没有东西能落地);暂扣一旦解除,循环即停止。 你的场景现在是单元测试——回退以 关于 harness 的一点说明:一个持续列出该轮、随后又为它发出 真实环境(在真实 daemon 前用 Playwright 路由拦截,轮询输入框的
PR 正文现在明确写了两点。暂扣期间输入框是只读的(共用的 之前的 16 个场景在新构建上复跑了两遍。第一遍里连续 12 次撤回中有一次(只有推理的轮次)只停止、未撤回——是普通停止,之后各轮均撤回,最终 prompt 与那一轮保留下来的 prompt 合并后到达模型,与 |
|
重新验证当前 head [P2] 两次快照中仍有目标,不能证明已接纳的回退不会再执行。 App.tsx:996–998 在连续两次看到目标时返回 这次先用生产 bridge 和内存 ACP 测试通道验证后台时序,再用真实 App 与 SDK reducer 复现界面影响:
两次“仍有目标”的回答都发生在回退执行之前,不存在“已经截断却继续返回旧快照”的假设。后台可继续执行修正 prompt;这里验证的缺陷是其对应用户消息在当前界面消失。这个残留属于原 P2 的正确性问题,建议在本 PR 中关闭:解除阻塞须有已接纳回退完成或确定不再执行的确认,状态查询须与该操作正确排序。确认机制需要覆盖尚在 bridge 队列中、还未派发给 agent 的回退。 已通过的独立复测包括:延迟 assistant/tool 输出仍保留;正常回退期间阻止提交;目标已消失时等待 10 秒仍不放行,直到事件应用;请求确实未送达时两次查询后放行;查询失败重试;发起回退前读取失败立即放行。 验证结果: |
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.
|
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 What changes. Tests. Two bridge tests on the in-memory ACP channel, the shape you used: a branch gated open, a rewind admitted behind it, Real stack, bundle rebuilt from 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 侧关闭,见 改了什么。 测试。 两个用内存 ACP 通道的 bridge 测试,与你的形态相同:闸住一个 branch,在它之后接纳一个回退,期间调用 真实环境,用 两次读取作为客户端一侧保留:第一次可能在 daemon 还在读取回退请求本身时就被应答(同一次事件循环唤醒、body 尚未解析完),第二次只在第一次返回之后才发出。剩下的只有:回退请求在浏览器报告失败半秒多之后才到达 daemon——超时的请求此时已在 daemon 那里 30 s,浏览器中止的请求要么已经到了,要么永远不会到。PR 正文如此写明。 |
|
重新验证当前 head
本次将生产 App 的快照查询、回退和更正提交直接接入新构建的生产 bridge,以内存 ACP 通道控制 branch 完成时机;模拟调用方丢失回退 HTTP 结果,并延迟向 App 投递 bridge 实际发布的回退事件。结果:
额外覆盖了已派发但未完成的回退、失败回退后的查询、多个已接纳回退、其他状态查询与另一会话隔离,以及前几轮的迟到输出、快照/事件等待、目标消失后等待 10 秒、查询失败重试、未发起回退时读取失败等路径。独立验证 13/13 通过(bridge 5、App 8)。 常规验证也通过:根目录 验证范围:上述联合测试使用真实 App、bridge、SDK reducer 和内存 ACP 通道,agent 操作与 HTTP 结果丢失由测试夹具控制;本次未额外运行完整浏览器网络故障 E2E。 发评时 CI 尚有 Web Shell E2E、视觉预览等任务排队或运行中;以上通过数均为本地实际执行结果。 |
qqqys
left a comment
There was a problem hiding this comment.
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.
|
@qwen-code /triage |
Verdict: merge-ready — 128/128 scripted assertions passed at 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
| 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 |
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
| 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.
Test efficacy
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.
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)
- Survivor M1 is behaviour-neutral but untested. Deleting
if (listed === false) return false;fromrewindMissedleft all 30 take-back tests green. Tracing every reachable path shows both variants end inrewound = truewaiting 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. - 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,
rewindSyncBlockedis 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. - The disclosed residual is real but narrow; its three doors, from source.
requestSessionStatus(the listing) callsextMethoddirectly (session-control-plane.ts:6874-6882) and bypassesentry.promptQueue, whilerewindSessionchains onto it (:15432). Queue occupants that can hold a rewind >500 ms without tripping the admission guard (pendingPromptCount/promptActive/backgroundTurn) arebranchSession(: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
left a comment
There was a problem hiding this comment.
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 —
rewindMissedasks the daemon instead. The two-consecutive-listed rule is sound now thatgetRewindSnapshotsawaitsrewindTail, 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.
|
Thanks for the PR — one thing up front: this merged at 15:35 UTC on 2026-10-06 (merge commit 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 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 ( 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 ( 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 ( 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 合并(合并提交 模板完整 ✓ —— 所有必需小节都存在且有实质内容,包含可执行的 reviewer 测试计划、逐场景 before/after 表格,以及完整的中文翻译。 问题: 已观测到的问题,不是理论性加固。PR 用真实 daemon + 脚本化模型对 方向: 对齐。Esc Esc 是核心手势,"刚打错的 prompt 被取消后不应留在模型历史里"是面向用户的正确性缺口,而不是为方案找问题。claude-code 的 CHANGELOG 没有"把被取消的 prompt 回填输入框"的条目,但 Esc Esc / rewind / 中断轮次处理在该产品里是持续修复的活跃区域(rewind 菜单响应性、Interrupted 行提示、排队消息的停止语义),所以方向与参考产品的演进一致。 规模: 改动跨两个 package( 方案: 范围合理,且是复用而非新造。prompt 挂起(hold)沿用了 inline edit-and-resend 路径已有的机制,做了泛化与重命名( 一个诚恳的架构问题,作为问题提出而非阻塞项:这个改动把"取消且无产出的轮次应被丢弃"放在了客户端,因此正确性需要四个部件协同——快照列举、prompt 挂起、settlement 上的 originator 标记,以及 bridge 侧的排序修复(避免列举请求抢在已被接纳的 rewind 之前被应答)。历史归 daemon 所有,且 daemon 已经在终点帧上标记了提交方 client,因此这个判定本可以由 daemon 承担,所有客户端都能天然获得一致行为。反方理由同样成立:TUI 已经自行实现回填,若下沉到 daemon 会改变另一个并未提出该需求的客户端的行为。如果将来第三个界面也需要同样的语义,值得重新考虑——三份客户端实现、各自维护一个易竞态的状态机,才是这个方案真正变贵的地方。 风险: 无升级风险信号。改动文件均未命中本仓库 revert 历史分析得出的高风险路径( PR 已合并,无门禁可执行。代码审查发现与 CI 证据见下一条评论。🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
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 reviewThe 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
No dead switches. Both new option/field additions have live producers and consumers, which is the failure mode I check for first: Reuse is good throughout: the composer-restore sequence mirrors the existing inline edit-and-resend path ( Non-blocking, named so they're on the record rather than lost:
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
Files changed (all 12)
Testing evidence — the PR's own CIWhat this section carries: check-run results for the reviewed head, read through the API. 81 check-runs on The three cancelled checks are bot orchestration, not the PR's CI —
Two honest gaps in that signal, both about what green does not prove here: The macOS and Windows unit legs are 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 Sandboxed verification would settle the part CI can't: 中文说明事后审查——PR 已在本条评论发出前合并(见 Stage 1)。仅做静态审查:按本门禁规则,我没有构建、运行或执行 PR 代码树中的任何内容。下方测试证据来自 PR 自身的 CI,通过 API 读取。本次审查没有通过实际操作产品来验证任何行为。 代码审查。 这个改动真正的风险不在功能本身,而在并发——客户端 rewind 可能丢弃不该丢的轮次,或者 hold 永远不解除。我针对这两点做了核查,且是对照 PR 之前的代码树,而不是采信 PR 自己的描述。三处我原本预期会出问题的地方,实际上没有问题:
没有失效开关(dead switch)。 新增的选项/字段都有真实的生产方与消费方,这是我最先检查的失效模式: 整体复用做得好:输入框回填序列与既有 inline edit-and-resend 路径( 以下为不阻塞合并、但希望留档的事项:
测试证据。 本节承载的是:通过 API 读取的、针对被审查 head 的 check-run 结果。 该信号有两处需要坦率说明的空白,都关乎"绿灯不能证明什么":macOS 与 Windows 单元测试 leg 在 PR 事件下按设计为 更关键的是,测试套件通过本身并不能证明该套件钉住了这个改动。作者报告对 take-back 路径做了 28 处定向变异,每处都至少使一个新测试失败,仅有一处变异存活(扁平退避,它只影响重试节奏)——这是正确类型的证据,也是很有力的主张,但这是作者本人的度量,在作者自己的 harness、Linux 环境上运行,我没有重跑也无法在此重跑。 同理适用于 19 场景的真实栈表格(真实 沙箱化验证可以解决 CI 解决不了的部分: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
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 ( 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 My one real reservation, and it's the reason this is 4/5 and not 5/5: this adds 325 production lines to an 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 For the record, so nothing here is silently dropped: the two items worth tracking as follow-ups are the 中文说明信心度:4/5 —— 我专门去找这类改动最容易出问题的四个点,对照 PR 之前的代码树逐一核查,四处都没有问题;剩下的是已点明的小瑕疵,以及一个关于复杂度落点的诚恳保留意见。 此处无法也不应执行任何门禁动作。 PR 已于 2026-10-06 15:35 UTC 合并( 关于实质内容。在读 diff 之前,我的独立方案是:复用既有的 edit-and-resend rewind 及其 prompt hold,并且在轮次的终点事件而非取消时刻做判定,因为在取消时刻无法区分"还没有产出"和"没有产出"。这个 PR 正是这么做的——而其中第二部分是我在时间压力下很可能做错的地方:在取消时刻判定更简单、感觉上正确,并且能通过你最先想到的所有测试。PR 描述记录了一个恰好犯了这个错误的中间构建,以及捕获它的那个场景。这是本次审查中最有力的单个信号:作者构建了错误的版本、发现了它,并且保留了证据,而不是悄悄改写历史。 我原本会选择的更简路径——让 daemon 承担"取消且无产出的轮次被丢弃"这一判定,从而所有客户端天然继承——正是我在 Stage 1 提出的问题,我在这里把它留作问题而非批评。它可以省去 originator 标记、快照往返以及 bridge 侧排序修复,而这三者之所以存在,只是因为判定放在了客户端。但它同时会改变 TUI 的行为,而 TUI 已经自行实现回填,并未提出这个需求。在更小的影响面与更干净的架构之间选择前者,是一个站得住脚的决定,也是 AGENTS.md 会倾向的决定。如果将来第三个界面也需要同样的语义,那就是把它下沉的时机。 六个月后我会骂作者还是感谢作者?主要是感谢。文档注释恰好落在不变量不明显的位置—— 我唯一真正的保留意见,也是这里是 4/5 而非 5/5 的原因:这个改动向一个已有 22,714 行的 我是否因为说不出更多反对理由而给出等同批准的判断?我不认为是。我核查的那些具体失效模式——快照列举卡在长轮次之后、未知 session 的新错误路径、一个从未被赋值却被当作回退读取的字段、一个没有测试钉住的守卫——每一个在基线代码里都有具体答案,而不只是看似合理的答案。要让我给出 5/5,还缺一个静态审查无法提供的部分:在真实栈上独立确认 hold 能在 rewind 响应丢失时保持住,这正是 Stage 2 中那行 留档,以免这里的内容被无声丢弃:两项值得作为后续跟进的事项是 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Sandboxed verification: Skipped because the PR is not open for verification (state=MERGED, draft=false). 中文 — 判定:
|














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:
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_rewoundevent 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:"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
npm run build && npm run bundle, startqwen serve, open the Web Shell.Focused tests:
Evidence (Before & After)
Real stack: the
qwen servebundle 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 frommain.Raw results for every scenario and the harness (scripted model, Playwright driver): pr-13488.
["hello first", "HOLD oops typo S1\nfixed prompt S1"]; reload shows both prompts["hello first", "fixed prompt S1"]; reload shows only the corrected one["hello first", "final real prompt S13"]Also measured over the daemon HTTP API directly: a rewind sent the instant
POST /session/:id/cancelreturns 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.["hello first", "fixed prompt S16"], same after a reloadresults-round2-promptid-only-build.json)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'scontenteditableis polled to time the hold); raw results and the harness are in pr-13488/round3.POST …/rewindaborted in the browser)main— the daemon never saw a rewindroute.fetch()then abort)session_rewoundreached the transcript; model receives["hello first", "fixed prompt S17b"]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
getRewindSnapshotswaits 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 seesbranch, rewind, rewind_snapshotsin 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 from16bd737bd3(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 asresults-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-shellunit suite: 412 files, 10822 tests, all passing.packages/acp-bridgesuite: 54 files, 2597 tests, all passing.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.@smokePlaywright 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
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 servewith an isolatedQWEN_HOME, Chromium via Playwright 1.61.Risk & Scope
rewindFiles: false); no tool ran in such a turn anyway.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.getRewindSnapshotsnow waits for rewinds admitted before it, so a listing requested while a rewind is queued behind a branch is answered after both have run. ThegetRewindSnapshotsandrewindSessionsession actions gain an optionalsilentflag, following the existingsilentoption ongetContextUsageandgetTasks.SendPromptOptions.onAdmittednow receives{ promptId }(callers that ignore the argument are unaffected).DaemonPromptSettledEventgainsoriginatorClientId; the host-facingonAssistantTurnSettledprojection is unchanged.Linked Issues
None.
中文说明
这个 PR 做了什么
在 Web Shell 里,如果一条 prompt 在本轮还没有任何产出时就被取消(连按两次 Esc 或点停止按钮,典型场景是刚发出去就发现写错了),这条 prompt 现在会回到输入框(文本、粘贴的图片、文件和标签),并且这一轮会从会话中移除。此前它会留在 transcript 和模型历史里。
只有在撤回不会丢任何东西时才生效:
取消返回时什么都不决定。撤回会等待 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上有两个后果:"HOLD oops typo S1\nfixed prompt S1"。TUI 已经会把取消的 prompt 拿回来(
AppContainer.tsx里的 restore-on-cancel),并且同样把思考视为"没有产出"。Web Shell 没有对应行为,两端不一致。Reviewer 测试计划
如何验证
npm run build && npm run bundle,启动qwen serve,打开 Web Shell。聚焦测试:
证据(前后对比)
真实环境:由本分支构建的
qwen servebundle、Chromium 中的真实 Web Shell(Playwright),以及一个脚本化的 OpenAI 兼容模型(可以保持沉默、只流式输出推理、流式输出文本、或调用工具)。"main" 指同一个 daemon 改为提供由main构建的客户端 bundle。每个场景的原始结果和测试脚本(脚本化模型、Playwright 驱动):pr-13488。
["hello first", "HOLD oops typo S1\nfixed prompt S1"];刷新后两条都在["hello first", "fixed prompt S1"];刷新后只有修正的那条["hello first", "final real prompt S13"]另外直接通过 daemon HTTP API 测量:在
POST /session/:id/cancel返回的瞬间发出回退请求,27 次中成功 27 次(模型沉默和只有推理的轮次,在被接纳后 20–320 ms 取消),因此撤回不需要等待或重试。评审第二轮(以该轮终点事件为判定点;回退期间暂扣 prompt)。上表全部场景在同一真实环境用
d65f8b171f构建的客户端复跑,并针对评审提出的两个竞态新增两个场景;原始结果(含一个中间构建和第一遍全量)在 pr-13488/round2。["hello first", "fixed prompt S16"],刷新后一致results-round2-promptid-only-build.json)第二轮第一遍全量里有一次停止按钮场景只停止、未撤回(daemon 日志显示终点事件之后没有读取快照)。第二遍全量、20 次单独运行、4 次与前一场景连跑均未复现;原始结果保留为
results-this-pr-round2-first-pass.json。评审第三轮(结果未知的回退不得由计时器解除暂扣)。上述 16 个场景在同一真实环境用
ccdac0bdf5构建的客户端复跑,并新增三个浏览器丢失回退请求或其响应的场景(在真实 daemon 前用 Playwright 路由拦截;通过轮询输入框的contenteditable计时暂扣);原始结果与 harness 在 pr-13488/round3。POST …/rewind)main一致——daemon 从未见到回退route.fetch()后中止)session_rewound到达 transcript 时解除;模型收到["hello first", "fixed prompt S17b"]评审第四轮(列表必须排在 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 或其他客户端的结算;到达过晚的结算。@smokePlaywright 套件(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"用例通过。测试平台
macOS 和 Windows 没有在本地运行;改动只在浏览器端,这两个平台交给 CI。
环境(可选)
Linux x86_64,Node 22,
npm run build && npm run bundle,使用隔离QWEN_HOME的qwen serve,通过 Playwright 1.61 驱动 Chromium。风险与范围
rewindFiles: false);这样的轮次里本来也没有工具运行过。ChatPane)仍然只是普通停止。标签页里没有加载完较早历史的会话,以及 SSH 工作区(拒绝回退),prompt 会回到输入框,但该轮仍留在历史里。撤回新会话的第一条 prompt 会留下一个以该 prompt 命名的空会话。SSH 工作区没有用真实远端验证。getRewindSnapshots现在会等待此前接纳的回退,因此在回退排在 branch 之后时请求的列表,会在两者都执行完后才应答。getRewindSnapshots和rewindSession两个会话 action 新增可选的silent标志,沿用getContextUsage和getTasks上已有的silent选项。SendPromptOptions.onAdmitted现在会收到{ promptId }(忽略参数的调用方不受影响)。DaemonPromptSettledEvent新增originatorClientId;面向 host 的onAssistantTurnSettled投影不变。关联 Issue
无。