Skip to content

fix(cli): Fail dangling replayed tool calls - #5624

Merged
doudouOUC merged 4 commits into
QwenLM:mainfrom
doudouOUC:codex/fix-history-replay-dangling-tool-calls
Jun 22, 2026
Merged

fix(cli): Fail dangling replayed tool calls#5624
doudouOUC merged 4 commits into
QwenLM:mainfrom
doudouOUC:codex/fix-history-replay-dangling-tool-calls

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR makes history replay close tool calls that were saved with a start event but no matching result. During replay, emitted assistant tool starts are tracked by call ID, real tool results remove their matching pending entry, and any remaining pending calls are replayed as failed tool updates with a clear missing-result message. Tool result matching now also falls back from the saved result call ID to the first function response ID before using the record UUID, preserving older history behavior while covering the saved-history shape that triggered the stuck UI.

The replay change is intentionally limited to historical transcript reconstruction. It does not change REST, SDK, ACP protocol shapes, or live tool execution semantics. TodoWrite starts that are skipped by the existing emitter are not tracked as dangling calls.

Why it's needed

Some saved sessions can contain assistant function calls without corresponding tool result records when the previous run ended before the tool completed or the history was otherwise incomplete. Replaying those sessions currently recreates an in-progress tool block but never emits a terminal update, so clients can continue showing the session as processing even though there is no backend work left.

Failing these dangling replay-only calls makes restored/exported transcripts terminal and prevents old incomplete history from keeping the UI in a responding state.

Reviewer Test Plan

How to verify

Run cd packages/cli && npx vitest run src/acp-integration/session/HistoryReplayer.test.ts; expect all 25 tests to pass, including dangling, matched, mixed, call ID fallback, and TodoWrite skip cases.

Run cd packages/webui && npx vitest run src/daemon/session/selectors.test.ts; expect all 7 tests to pass, including failed/completed tool blocks remaining idle.

Run cd packages/cli && npx eslint src/acp-integration/session/HistoryReplayer.ts src/acp-integration/session/HistoryReplayer.test.ts and npx prettier --check packages/cli/src/acp-integration/session/HistoryReplayer.ts packages/cli/src/acp-integration/session/HistoryReplayer.test.ts packages/webui/src/daemon/session/selectors.test.ts; both should pass.

I also ran npm run build and npm run typecheck. Both are currently blocked by existing packages/cli/src/ui/components/BaseTextInput.tsx errors resolving ink/dom and ink/components/CursorContext, plus related cursorCtx unknown-type errors; those files are outside this PR.

Evidence (Before & After)

Before: replaying saved history with a function call but no saved tool result recreated an in-progress tool block with no terminal update, leaving the session visually stuck as processing.

After: the same replay emits the original tool_call followed by a tool_call_update with status: failed and the message Tool result missing from saved history; the previous run likely ended before this tool completed. Matched tool calls still complete normally and skipped TodoWrite starts do not produce synthetic failures.

Tested on

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

Environment (optional)

Local macOS checkout, Node.js v26.0.0, gh 2.92.0. Focused Vitest, ESLint, Prettier, build, and typecheck commands were run locally; build/typecheck are blocked by the unrelated BaseTextInput Ink type errors noted above.

Risk & Scope

  • Main risk or tradeoff: A saved history that has a dangling tool start for an unusual reason will now show that replayed tool as failed instead of in progress, but this is limited to historical replay and uses the existing tool update path.
  • Not validated / out of scope: Full build/typecheck cannot be validated until the unrelated BaseTextInput Ink type resolution errors are fixed; live tool execution and protocol schemas are intentionally unchanged.
  • Breaking changes / migration notes: None.

Linked Issues

N/A

中文说明

What this PR does

这个 PR 让历史回放能够终结那些已保存了工具开始事件、但没有匹配工具结果的调用。回放过程中,已发出的 assistant 工具开始事件会按 call ID 进入 pending;真实工具结果会移除匹配项;回放结束后剩余的 pending 调用会被重放为 failed 工具更新,并带上明确的缺失结果说明。工具结果匹配现在也会在保存的结果 call ID 缺失时,先回退到第一个 function response ID,再回退到 record UUID;这样既保留老历史行为,也覆盖导致卡住 UI 的保存历史形态。

这个回放改动刻意限制在历史 transcript 重建层,不修改 REST、SDK、ACP 协议形态,也不改变 live 工具执行语义。现有 emitter 会跳过的 TodoWrite start 不会被记录为 dangling call。

Why it's needed

部分已保存会话会在上一次运行于工具完成前结束、或历史不完整时,只包含 assistant function call,而没有对应的 tool result record。当前回放这些会话时会重新创建一个 in-progress 工具块,但永远不会发出终态更新,因此客户端可能持续显示会话仍在处理中,即使后端已经没有工作在运行。

把这些仅存在于 replay 阶段的悬空调用标记为 failed,可以让恢复和导出的 transcript 进入终态,并避免旧的不完整历史让 UI 一直保持 responding 状态。

Reviewer Test Plan

How to verify

运行 cd packages/cli && npx vitest run src/acp-integration/session/HistoryReplayer.test.ts;预期全部 25 个测试通过,包括 dangling、matched、mixed、call ID fallback 和 TodoWrite skip 场景。

运行 cd packages/webui && npx vitest run src/daemon/session/selectors.test.ts;预期全部 7 个测试通过,包括 failed/completed 工具块保持 idle 的回归断言。

运行 cd packages/cli && npx eslint src/acp-integration/session/HistoryReplayer.ts src/acp-integration/session/HistoryReplayer.test.tsnpx prettier --check packages/cli/src/acp-integration/session/HistoryReplayer.ts packages/cli/src/acp-integration/session/HistoryReplayer.test.ts packages/webui/src/daemon/session/selectors.test.ts;两者都应通过。

我也运行了 npm run buildnpm run typecheck。两者目前都被既有的 packages/cli/src/ui/components/BaseTextInput.tsx 错误阻塞:无法解析 ink/domink/components/CursorContext,并出现相关的 cursorCtx unknown 类型错误;这些文件不在本 PR 范围内。

Evidence (Before & After)

Before:当已保存历史里有 function call 但没有保存的 tool result 时,回放会重新创建一个 in-progress 工具块,但没有终态更新,导致会话视觉上一直停留在处理中。

After:同样的回放会先发出原始 tool_call,随后发出 tool_call_update,其中 status: failed,并带有 Tool result missing from saved history; the previous run likely ended before this tool completed.。已匹配的工具调用仍会正常 completed,被跳过的 TodoWrite start 不会产生合成失败。

Tested on

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

Environment (optional)

本地 macOS checkout,Node.js v26.0.0,gh 2.92.0。已在本地运行 focused Vitest、ESLint、Prettier、build 和 typecheck 命令;build/typecheck 被上面提到的无关 BaseTextInput Ink 类型错误阻塞。

Risk & Scope

  • Main risk or tradeoff: 如果某段保存历史因为少见原因存在悬空工具 start,现在回放时会显示为 failed,而不是继续保持 in progress;但影响范围仅限历史回放,并且复用了现有工具 update 路径。
  • Not validated / out of scope: 在修复无关的 BaseTextInput Ink 类型解析错误前,无法完成 full build/typecheck 验证;live 工具执行和协议 schema 均刻意不变。
  • Breaking changes / migration notes: 无。

Linked Issues

N/A

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC marked this pull request as ready for review June 22, 2026 09:17
Copilot AI review requested due to automatic review settings June 22, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.ts Outdated
Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

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

Downgraded from Approve to Comment: CI still running. R2 addresses both R1 suggestions (replay-loop try/catch and per-dangling-call error continuation). Four new suggestions below.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.ts
Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.ts Outdated
Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@wenshao

wenshao commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report (real build + tests on Linux)

I verified this PR locally with real builds and test runs to support the merge decision. Net result: the fix works end-to-end and is safe to merge. This also fills the 🐧 Linux row the PR table left as "not tested".

Setup

Host OS Linux, Node v22.22.2, npm 10.9.7
Verified on PR head bf88c3d69 merged onto latest origin/main e2db547a9 (merge 561ccc6b0) — clean merge, no conflicts
Changed files HistoryReplayer.ts (+168/−56), HistoryReplayer.test.ts, webui/.../selectors.test.ts (+488/−59 total)

1. Author's test plan reproduced ✅

packages/cli   HistoryReplayer.test.ts → Tests 24 passed (24)
packages/webui selectors.test.ts       → Tests  7 passed (7)

⚠️ Minor doc drift: the PR body says "expect all 22 tests to pass", but the final head runs 24 (base origin/main = 17 → +7 new tests). The review-feedback commit pushed it past the documented number. Cosmetic only — worth fixing the PR text before merge.

2. The new tests genuinely guard the fix (revert-proof) ✅

Ran the new test file against the base HistoryReplayer.ts (production fix reverted). Exactly the 5 behavior-changing tests fail, proving they aren't vacuous:

× should fail dangling function calls after replay completes
× should fail dangling calls before rethrowing replay errors
× should continue failing dangling calls after a synthetic update error
× should only fail dangling calls when matched and dangling calls are mixed
× should use functionResponse id as callId when toolCallResult.callId is missing
  → AssertionError: expected [ 'tool_call' ] to deeply equal [ 'tool_call', 'tool_call_update' ]

The other 2 new tests (matched-result completes / TodoWrite-skip) pass on base too — correct, they guard that the fix does not break existing good behavior.

Note: the production webui/.../selectors.ts is unchanged; the selector already maps failed/completed → idle. The webui diff is a test-only regression guard, which is the right call.

3. End-to-end runtime A/B — the actual "stuck UI" symptom 🎯

I drove the real HistoryReplayer on a realistic saved session (user msg → assistant text → run_shell_command start, no tool result = previous run died mid-tool), then fed its emitted block through the real webui daemon selector selectDaemonTranscriptStreamingState. Same session, fix on vs off:

emitted updates terminal update reduced block status selector state
BEFORE (base) tool_call only null in_progress responding → UI stuck ❌
AFTER (PR) tool_call + tool_call_update status: failed failed idle → UI released ✅

The actual terminal frame the fix emits:

{
  "sessionUpdate": "tool_call_update",
  "toolCallId": "call-shell-1",
  "status": "failed",
  "content": [{ "type": "content", "content": { "type": "text",
    "text": "Tool result missing from saved history; the previous run likely ended before this tool completed." }}],
  "_meta": { "toolName": "run_shell_command", "provenance": "builtin", "timestamp": 1782122401000 }
}

This is the whole point of the PR confirmed across both changed packages working together — not just the unit tests in isolation.

4. Lint / format / typecheck ✅

  • prettier --check on all 3 files → clean.
  • eslint on the changed cli files → clean (exit 0).
  • The PR's files type-check cleanly: an isolated tsc --noEmit over HistoryReplayer.ts + emitters reports 0 errors; a deliberately injected stringnumber error was caught (TS2322), proving the probe actually exercises the file.
  • The PR's note that npm run build/typecheck is blocked by BaseTextInput.tsx (ink/dom, ink/components/CursorContext, cursorCtx) is honest: those imports exist on origin/main already and the PR does not touch that file. Pre-existing and unrelated.

Observations for merge

  1. Fix the "22" → "24" test count in the PR description (only doc nit found).
  2. Matching is strictly improved, not riskier: result-side call-id resolution went from callId ?? uuid to callId → functionResponse.id → uuid. The extra fallback covers more old-history shapes; matched calls are cleared via their real id (verified by the matched/mixed tests), so there's no new false-positive-failure risk.
  3. Scope is genuinely replay-only: the pending map is per-replay() and cleared in finally; live tool execution and protocol shapes are untouched. Error paths (failDanglingToolCalls runs even when replay throws, original error rethrown first) are covered by tests.

Verdict: LGTM from a verification standpoint — approve to merge after the test-count text fix.

🇨🇳 中文版(点击展开)

✅ 本地验证报告(Linux 真实构建 + 测试)

为辅助合并决策,我在本地用真实构建与测试对本 PR 做了验证。结论:修复端到端有效,可以安全合并。 同时补齐了 PR 表格里标为 "not tested" 的 🐧 Linux 一行。

环境

主机 Linux,Node v22.22.2,npm 10.9.7
验证对象 PR head bf88c3d69 合并到最新 origin/main e2db547a9(合并提交 561ccc6b0)——干净合并,无冲突
改动文件 HistoryReplayer.tsHistoryReplayer.test.tswebui/.../selectors.test.ts(共 +488/−59)

1. 复现作者测试计划 ✅

packages/cli   HistoryReplayer.test.ts → 24 passed (24)
packages/webui selectors.test.ts       →  7 passed (7)

⚠️ 文档轻微过期: PR 描述写的是 "预期 22 个测试通过",但最终 head 实际跑 24 个(base origin/main = 17,新增 +7)。review-feedback 提交把数量带过了文档里的数字。仅文案问题,建议合并前顺手改一下。

2. 新测试确实在守护本次修复(反向验证)✅

新测试文件跑在 baseHistoryReplayer.ts(撤掉生产修复)上,恰好是这 5 个行为相关测试失败,证明它们不是空测试:

× should fail dangling function calls after replay completes
× should fail dangling calls before rethrowing replay errors
× should continue failing dangling calls after a synthetic update error
× should only fail dangling calls when matched and dangling calls are mixed
× should use functionResponse id as callId when toolCallResult.callId is missing
  → 断言:expected [ 'tool_call' ] to deeply equal [ 'tool_call', 'tool_call_update' ]

另外 2 个新测试(匹配成功→completed / TodoWrite 跳过)在 base 上也通过——这是对的,它们守护的是"修复不破坏既有正确行为"。

注意:生产代码 webui/.../selectors.ts 未改动;selector 本就把 failed/completed 归为 idle。webui 的 diff 是纯测试回归守护,做法正确。

3. 端到端运行时 A/B —— 真实复现"UI 卡住"症状 🎯

我用真实的 HistoryReplayer 跑了一个贴近真实的已保存会话(用户消息 → 助手文本 → run_shell_command 开始、没有工具结果 = 上次运行在工具完成前结束),再把它产出的块喂给真实的 webui daemon selector selectDaemonTranscriptStreamingState。同一会话,开/关修复对比:

发出的 update 终态 update 归约后块状态 selector 状态
修复前(base) tool_call null in_progress responding → UI 卡住 ❌
修复后(PR) tool_call + tool_call_update status: failed failed idle → UI 释放 ✅

修复实际发出的终态帧:

{
  "sessionUpdate": "tool_call_update",
  "toolCallId": "call-shell-1",
  "status": "failed",
  "content": [{ "type": "content", "content": { "type": "text",
    "text": "Tool result missing from saved history; the previous run likely ended before this tool completed." }}],
  "_meta": { "toolName": "run_shell_command", "provenance": "builtin", "timestamp": 1782122401000 }
}

这正是本 PR 的核心目标,且是在两个改动包协同工作下验证的——不仅仅是各自孤立的单测。

4. Lint / 格式 / 类型检查 ✅

  • 对 3 个文件 prettier --check通过
  • 对改动的 cli 文件 eslint通过(exit 0)。
  • PR 文件类型检查干净:对 HistoryReplayer.ts + emitters 做隔离的 tsc --noEmit0 error;故意注入一个 stringnumber 错误被抓到(TS2322),证明探针确实在检查该文件。
  • PR 提到 npm run build/typecheckBaseTextInput.tsxink/domink/components/CursorContextcursorCtx)阻塞——这一说法属实:这些 import 在 origin/main 上已存在,且本 PR 未触碰该文件。属既有、无关问题。

合并相关观察

  1. 把描述里的 "22" 改成 "24"(唯一发现的文案问题)。
  2. 匹配逻辑只增强、不增险: 结果侧 call-id 解析从 callId ?? uuid 变为 callId → functionResponse.id → uuid。新增的回退覆盖了更多旧历史形态;匹配成功的调用通过真实 id 被清除(matched/mixed 测试已验证),因此不存在新的"误判失败"风险。
  3. 作用域确实仅限 replay: pending map 是每次 replay() 独立、并在 finally 清空;不影响 live 工具执行与协议形态。错误路径(replay 抛错时 failDanglingToolCalls 仍执行、且优先重抛原始错误)有测试覆盖。

结论:从验证角度 LGTM —— 改完测试数量文案后可合并。

Verified locally on Linux via tmux with real build/test runs (focused vitest, revert-proof A/B, end-to-end HistoryReplayer→selector A/B, eslint/prettier/tsc).

@doudouOUC
doudouOUC requested a review from wenshao June 22, 2026 10:59

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

⚠️ Downgraded from Request Changes to Comment: self-PR; CI failing: Post Coverage Comment, ack-review-request, review-pr, delay-automatic-review, authorize, review-config.

Two Critical findings below need addressing before merge.

Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.ts Outdated
Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.ts
Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@pomelo-nwu pomelo-nwu 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

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

Reviewed the replay change independently. The approach is right: track emitted assistant tool starts by source call id, drop them on a matching result, fail whatever's left after the loop — it's contained to transcript reconstruction and reuses the existing emitResult path. The per-call isolation in failDanglingToolCalls, the replay/cleanup AggregateError split, and gating pending on sourceCallId all hold up, and the new tests genuinely guard them (verified non-vacuous).

One residual, non-blocking: if a result record has a real upstream functionCall.id but carries neither toolCallResult.callId nor a functionResponse.id, getToolResultCallId falls back to record.uuid, so the pending entry isn't deleted and the call ends up with both a completed update (under the uuid) and a synthetic failed one. That's strictly better than before (the block was already orphaned and stuck), just not perfectly clean for that rare old-history shape — fine to leave.

LGTM.

Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.ts
Comment thread packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required sections present, bilingual translation included.

On direction: this fixes a real, user-facing bug — replaying incomplete sessions leaves the UI stuck in "responding" forever. Clearly within scope as a session-management bug fix. CHANGELOG has prior replay-related fixes (#5108, #3747) in the same area, confirming this is a recognized concern.

On approach: scope feels right. The fix is confined to the replay path — no changes to live tool execution, protocol shapes, or REST/SDK/ACP contracts. The call-ID fallback chain (callId → functionResponse.id → uuid) is a sensible improvement that covers more old-history shapes without risking false-positive failures. Error aggregation in replay() and the try/finally hardening of setActiveRecordId are necessary companions, not scope creep.

One minor note: the PR description says "26 tests" but I verified 26 tests passing locally (matching the actual count), so the description is accurate on the latest head.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ — 所有必填章节齐全,包含双语翻译。

方向:修复了一个真实的、用户可感知的 bug — 回放不完整会话时 UI 永远卡在 "responding" 状态。明确属于 session 管理 bug 修复范围。CHANGELOG 中已有先前的回放相关修复(#5108#3747),说明这是已知的关注领域。

方案:范围恰当。修复仅限于回放路径 — 不改动 live 工具执行、协议形态或 REST/SDK/ACP 合约。call-ID 回退链(callId → functionResponse.id → uuid)是一个合理的增强,覆盖了更多旧历史格式,且不会引入误判失败风险。replay() 中的错误聚合和 setActiveRecordIdtry/finally 加固是必要的配套改动,不算范围蔓延。

一个小注:PR 描述说 "26 个测试",我在本地验证了实际 26 个测试通过(与描述匹配)。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): I'd track emitted tool_call start events by call ID in a pending map during replay, remove entries when matching tool_result records arrive, then after replay completes emit a synthetic tool_call_update with status: failed for any remaining entries. The replay() method needs try/finally to guarantee cleanup, and call ID matching on the result side should be more robust than just callId ?? uuid.

Comparison with the diff: the PR's approach matches this exactly. The pendingReplayToolCalls Map tracks by call ID, replayToolResult removes matched entries, failDanglingToolCalls() emits synthetic failures in a finally block, and the call-ID fallback chain (callId → functionResponse.id → uuid) is actually more thorough than my initial proposal. The emitStart return value is correctly used to skip non-emitted starts (e.g. TodoWrite) from tracking.

No critical blockers found. No AGENTS.md violations — the change is minimal, well-placed, and follows project conventions.

Reuse check: the pending-call tracking and synthetic failure emission are specific to the replay path. No existing utility or shared module covers this. The ToolCallEmitter.emitResult is correctly reused for synthetic failures.

Test Results

packages/cli  HistoryReplayer.test.ts → 26 passed (26)  ✓
packages/webui selectors.test.ts     →  7 passed (7)   ✓
ESLint (changed files)               → clean           ✓
Prettier (changed files)             → clean           ✓

Key test scenarios verified:

  • Dangling function calls → emitted as status: failed after replay completes
  • Matched calls → still complete normally (status: completed)
  • Mixed matched + dangling → only dangling are failed
  • Error during replay → dangling cleanup still runs, errors aggregated via AggregateError
  • TodoWrite skipped starts → not tracked as dangling
  • functionResponse.id fallback → used when toolCallResult.callId is missing
  • Webui selectors → failed and completed tool blocks correctly reduce to idle

The prior verification comment on this PR already includes an end-to-end A/B test driving the real HistoryReplayer + webui selector, confirming the "stuck UI" symptom is resolved (selector: respondingidle).

No concerns from code review or testing. Moving to final assessment.

中文说明

代码审查

独立方案(读 diff 前):在回放过程中用 pending map 按 call ID 跟踪已发出的 tool_call start 事件,匹配的 tool_result 到达时移除,回放结束后对剩余项发出 status: failed 的合成 tool_call_updatereplay() 方法需要 try/finally 保证清理,结果侧的 call ID 匹配需要比 callId ?? uuid 更健壮。

与 diff 对比:PR 的方案与此完全一致。pendingReplayToolCalls Map 按 call ID 跟踪,replayToolResult 移除匹配项,failDanglingToolCalls()finally 块中发出合成失败,call-ID 回退链(callId → functionResponse.id → uuid)实际上比我最初的方案更完善。正确使用 emitStart 的返回值来跳过未发出的 start(如 TodoWrite)。

未发现关键阻塞问题。未违反 AGENTS.md — 改动最小化、放置位置正确、遵循项目约定。

复用检查:pending-call 跟踪和合成失败发出是回放路径特有的逻辑,没有现有工具或共享模块覆盖。ToolCallEmitter.emitResult 被正确复用于合成失败。

测试结果

packages/cli  HistoryReplayer.test.ts → 26 passed (26)  ✓
packages/webui selectors.test.ts     →  7 passed (7)   ✓
ESLint(改动文件)                     → 通过             ✓
Prettier(改动文件)                   → 通过             ✓

已验证的关键测试场景:

  • 悬空函数调用 → 回放完成后发出 status: failed
  • 已匹配调用 → 仍正常 completed
  • 混合匹配 + 悬空 → 仅悬空被 fail
  • 回放期间出错 → 悬空清理仍执行,通过 AggregateError 聚合错误
  • TodoWrite 跳过的 start → 不被跟踪为悬空调用
  • functionResponse.id 回退 → 在 toolCallResult.callId 缺失时使用
  • Webui selectors → failedcompleted 工具块正确归约为 idle

PR 上先前的验证评论已包含端到端 A/B 测试(驱动真实 HistoryReplayer + webui selector),确认 "UI 卡住" 症状已解决(selector: respondingidle)。

代码审查和测试均无顾虑。进入最终评估。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, focused bug fix that does exactly what it says: dangling tool calls from incomplete sessions now get a terminal failed status during replay instead of leaving the UI stuck in "responding" forever.

The implementation matches my independent proposal — pending Map, match-and-remove, fail-the-rest in finally. The call-ID fallback chain is a nice touch that handles more old-history shapes than the original callId ?? uuid. Error aggregation via AggregateError when both replay and dangling cleanup fail is the right call.

26 tests in the CLI package and 7 in the webui package all pass, covering the important edge cases: mixed matched/dangling, error-during-replay, TodoWrite skip, and the functionResponse.id fallback. The prior verification comment already demonstrated the end-to-end A/B with real HistoryReplayer + webui selector — respondingidle — which is the actual symptom users see.

No over-engineering, no drive-by refactors, no scope creep. Every line in the diff serves the stated goal. If I had to maintain this in six months, I'd thank the author.

Approving. ✅

中文说明

这是一个干净、专注的 bug 修复,完全如其所述:不完整会话中的悬空工具调用现在在回放时会获得终态 failed 状态,而不是让 UI 永远卡在 "responding"。

实现方案与我的独立方案一致 — pending Map、匹配移除、在 finally 中 fail 其余项。call-ID 回退链是一个很好的补充,比原来的 callId ?? uuid 能处理更多旧历史格式。当回放和悬空调用清理同时失败时,通过 AggregateError 聚合错误是正确做法。

CLI 包 26 个测试和 webui 包 7 个测试全部通过,覆盖了重要边界场景:混合匹配/悬空、回放中出错、TodoWrite 跳过、functionResponse.id 回退。PR 上先前的验证评论已经展示了真实 HistoryReplayer + webui selector 的端到端 A/B — respondingidle — 这正是用户看到的实际症状。

没有过度工程、没有顺手重构、没有范围蔓延。diff 中每一行都服务于既定目标。如果六个月后需要维护这段代码,我会感谢作者。

批准 ✅

Qwen Code · qwen3.7-max

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@doudouOUC
doudouOUC merged commit bf70079 into QwenLM:main Jun 22, 2026
123 checks passed
@doudouOUC
doudouOUC deleted the codex/fix-history-replay-dangling-tool-calls branch June 22, 2026 12:04

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

⚠️ Downgraded from Request Changes to Comment: self-PR. The Critical finding is posted as an inline comment below.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

const callId = result?.callId ?? record.uuid;
const callId = this.getToolResultCallId(record);
this.pendingReplayToolCalls.delete(callId);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Critical] pendingReplayToolCalls.delete(callId) runs before emitResult(). If emitResult throws, the entry is already removed from the Map and failDanglingToolCalls() can't synthesize a terminal update — the tool stays in_progress forever, the exact bug this PR aims to fix.

Suggested change
await this.toolCallEmitter.emitResult({
toolName,
callId,
success: !result?.error,
message: record.message.parts,
resultDisplay: result?.resultDisplay,
args: undefined,
timestamp: record.timestamp,
});
this.pendingReplayToolCalls.delete(callId);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

danglingError = error;
}

if (replayError && danglingError) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] The AggregateError message 'Replay and dangling-cleanup both failed' discards the underlying error details. Include the actual error messages for incident response debuggability.

Suggested change
if (replayError && danglingError) {
`Replay and dangling-cleanup both failed: ${(replayError as Error).message} | ${(danglingError as Error).message}`

— DeepSeek/deepseek-v4-pro via Qwen Code /review

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.

6 participants