Skip to content

fix(cli): stop repeated duplicate provider responses - #5657

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/drop-repeated-provider-duplicates
Jun 25, 2026
Merged

fix(cli): stop repeated duplicate provider responses#5657
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/drop-repeated-provider-duplicates

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Stops repeated duplicate provider tool-call responses from keeping Qwen Code in a tool-result loop. The first duplicate provider tool-call id still gets a synthetic duplicate-error function response so the provider's replayed tool call is paired. If the same provider id repeats again within the same prompt, the current batch is treated as terminal/drop-only before any fresh sibling tool call is executed or scheduled.

Applies the same guard to the non-interactive CLI, TUI stream handling, AgentCore, and ACP session tool execution paths. The repeated-duplicate detection is now shared through findRepeatedDuplicateProviderToolCall, so the four entry points use one consistent branch for both "already sent a synthetic duplicate response" and "same handled provider id appears more than once in one batch".

AgentCore now terminates this case with AgentTerminateMode.LOOP_DETECTED instead of a generic error. Interactive agent runs surface that as Agent stopped: duplicate tool-call loop detected., making this loop class visible to users and callers instead of looking like an arbitrary failure.

This revision also clears ACP duplicate-response tracking at the start of each new user prompt. ACP sessions are long-lived, but the repeated-duplicate circuit breaker is a per-prompt guard; a provider id seen in prompt N should not poison prompt N+1.

Why it's needed

Fixes a deterministic loop where an OpenAI-compatible provider can replay an already-completed tool-call id, receive another synthetic duplicate tool result, and then replay the same id again. On the current npm release this can lead to repeated tool-result submissions until a session/turn limit stops the run.

The fix keeps the existing duplicate suppression behavior for the first replay, but adds a circuit breaker for repeated duplicate responses so Qwen Code does not send partial or repeated tool responses back to the provider.

The explicit AgentCore termination mode is needed because otherwise the headless/interactive agent path stops correctly but reports the stop as a generic error. Review feedback called out that the user-facing path should identify the duplicate tool-call loop.

The ACP reset is needed because Session instances survive across prompts. Without clearing the duplicate-response Set per prompt, a legitimate same provider id in a later ACP prompt could be dropped immediately because an earlier prompt had already received a synthetic duplicate response for that id.

Reviewer Test Plan

How to verify

Run the focused duplicate-provider-id tests for the affected entry points. The regression cases cover repeated duplicate ids mixed with a fresh sibling tool call, the shared repeated-duplicate helper, AgentCore's visible loop termination mode, the TUI/non-interactive paths, and ACP prompt-to-prompt Set clearing.

Commands run locally after rebasing onto upstream/main:

cd packages/core
npx vitest run src/core/turn.test.ts src/agents/runtime/agent-headless.test.ts src/agents/runtime/agent-interactive.test.ts -t 'findRepeatedDuplicateProviderToolCall|duplicate provider|terminate|stopped'

cd ../cli
npx vitest run src/nonInteractiveCli.test.ts src/ui/hooks/useGeminiStream.test.tsx src/acp-integration/session/Session.test.ts -t 'duplicate provider'

cd ../..
npx prettier --check packages/cli/src/acp-integration/session/Session.ts packages/cli/src/acp-integration/session/Session.test.ts packages/cli/src/nonInteractiveCli.ts packages/cli/src/nonInteractiveCli.test.ts packages/cli/src/ui/hooks/useGeminiStream.ts packages/cli/src/ui/hooks/useGeminiStream.test.tsx packages/core/src/agents/runtime/agent-core.ts packages/core/src/agents/runtime/agent-headless.test.ts packages/core/src/agents/runtime/agent-interactive.ts packages/core/src/agents/runtime/agent-types.ts packages/core/src/core/turn.ts packages/core/src/core/turn.test.ts
npx eslint packages/cli/src/acp-integration/session/Session.ts packages/cli/src/acp-integration/session/Session.test.ts packages/cli/src/nonInteractiveCli.ts packages/cli/src/nonInteractiveCli.test.ts packages/cli/src/ui/hooks/useGeminiStream.ts packages/cli/src/ui/hooks/useGeminiStream.test.tsx packages/core/src/agents/runtime/agent-core.ts packages/core/src/agents/runtime/agent-headless.test.ts packages/core/src/agents/runtime/agent-interactive.ts packages/core/src/agents/runtime/agent-types.ts packages/core/src/core/turn.ts packages/core/src/core/turn.test.ts --max-warnings 0
git diff --check
npm run typecheck
npm run build

Evidence (Before & After)

Before: a provider that replayed the same completed provider tool-call id could receive a synthetic duplicate tool result on every round, keeping Qwen Code in a duplicate tool-result loop.

Before, in ACP specifically: once a long-lived Session recorded that a provider id had already received a synthetic duplicate response, that Set was never cleared between user prompts.

Before, in AgentCore: the repeated duplicate guard stopped the run as a generic error, so users and callers could not distinguish this loop class from unrelated failures.

After: the first replay still gets a synthetic duplicate-error function response; the next replay of the same provider id in that prompt terminates/drops the batch before any fresh sibling tool response is sent. ACP clears this per-prompt guard before starting the next user prompt. AgentCore reports LOOP_DETECTED, and the interactive runner shows a duplicate-loop-specific stop message.

Local results:

  • ✅ core focused tests: 2 files passed, 1 file skipped, 12 tests passed
  • ✅ CLI focused tests: 3 files passed, 13 tests passed, including the drain-item repeated-duplicate path
  • ✅ all PR touched-file Prettier checks passed
  • ✅ all PR touched-file ESLint checks passed with --max-warnings 0
  • git diff --check passed
  • npm run typecheck passed
  • npm run build passed
  • ⚠️ Build still reports existing VS Code companion curly lint warnings plus existing Browserslist and chunk-size warnings; exit code was 0 and none are from touched files.
  • ✅ Read-only sub-agent review found no blocking issues in the ACP clear placement, helper semantics, or regression test coverage.

Tested on

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

Environment (optional)

Local macOS workspace, Node.js v26.3.0, focused Vitest suites plus repo-level typecheck/build.

Risk & Scope

  • Main risk or tradeoff: a malformed provider turn that repeats an already-answered provider id together with fresh sibling tool calls is now dropped as a batch, so the fresh sibling is not executed. This is intentional to avoid sending partial tool responses that could violate OpenAI-compatible tool-call pairing.
  • AgentCore scope: duplicate-loop termination is now observable as LOOP_DETECTED; existing cancellation, shutdown, max-turn, and generic-error modes are unchanged.
  • ACP scope: duplicate-response tracking is now prompt-scoped, matching the non-interactive, TUI, and AgentCore lifetimes. It still persists across multiple tool-call rounds within one prompt.
  • Not validated / out of scope: full end-to-end replay against the external reproducer and broader loop-detection heuristics.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #5641

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

中文说明

What this PR does

阻止重复的 provider tool-call 响应让 Qwen Code 陷入 tool-result 循环。第一次重复的 provider tool-call id 仍会收到一个 synthetic duplicate-error function response,用来配对 provider 重放的 tool call。如果同一个 provider id 在同一个 prompt 内再次重复,当前 batch 会在执行或调度任何 fresh sibling tool call 之前被视为终止/仅丢弃。

这个保护覆盖 non-interactive CLI、TUI stream handling、AgentCore 和 ACP session tool execution 路径。repeated-duplicate 检测现在通过 findRepeatedDuplicateProviderToolCall 共享,因此四个入口对“已经发过 synthetic duplicate response”和“同批内同一个已处理 provider id 出现多次”使用同一套判断。

AgentCore 现在会用 AgentTerminateMode.LOOP_DETECTED 终止这种情况,而不是泛化成普通 error。interactive agent run 会展示 Agent stopped: duplicate tool-call loop detected.,让用户和调用方能明确看到这是 duplicate tool-call loop,而不是任意失败。

这一版也会在每个新的用户 prompt 开始时清理 ACP duplicate-response tracking。ACP session 是长生命周期对象,但 repeated-duplicate circuit breaker 是 per-prompt guard;prompt N 里见过的 provider id 不应该污染 prompt N+1。

Why it's needed

修复一个确定性的循环:OpenAI-compatible provider 可以重放已经完成的 tool-call id,收到另一个 synthetic duplicate tool result,然后再次重放同一个 id。在当前 npm release 上,这可能导致重复提交 tool-result,直到 session/turn 限制中止运行。

这个修复保留第一次 replay 时已有的 duplicate suppression 行为,同时为 repeated duplicate response 增加断路器,避免 Qwen Code 再向 provider 发送 partial 或重复的 tool response。

显式的 AgentCore termination mode 是必要的,因为否则 headless/interactive agent 路径虽然会正确停止,但会把这个停止报告成普通 error。review feedback 已指出 user-facing 路径应该能识别 duplicate tool-call loop。

ACP reset 是必要的,因为 Session 实例会跨 prompt 存活。如果不按 prompt 清理 duplicate-response Set,后续 ACP prompt 里合法出现的相同 provider id 可能会因为早先 prompt 已经给该 id 发过 synthetic duplicate response 而被直接丢弃。

Reviewer Test Plan

How to verify

运行受影响入口的 focused duplicate-provider-id 测试。回归用例覆盖 repeated duplicate id 和 fresh sibling tool call 混在同一批、共享 repeated-duplicate helper、AgentCore 的 visible loop termination mode、TUI/non-interactive 路径,以及 ACP prompt 间 Set 清理。

在 rebase 到 upstream/main 后本地运行的命令:

cd packages/core
npx vitest run src/core/turn.test.ts src/agents/runtime/agent-headless.test.ts src/agents/runtime/agent-interactive.test.ts -t 'findRepeatedDuplicateProviderToolCall|duplicate provider|terminate|stopped'

cd ../cli
npx vitest run src/nonInteractiveCli.test.ts src/ui/hooks/useGeminiStream.test.tsx src/acp-integration/session/Session.test.ts -t 'duplicate provider'

cd ../..
npx prettier --check packages/cli/src/acp-integration/session/Session.ts packages/cli/src/acp-integration/session/Session.test.ts packages/cli/src/nonInteractiveCli.ts packages/cli/src/nonInteractiveCli.test.ts packages/cli/src/ui/hooks/useGeminiStream.ts packages/cli/src/ui/hooks/useGeminiStream.test.tsx packages/core/src/agents/runtime/agent-core.ts packages/core/src/agents/runtime/agent-headless.test.ts packages/core/src/agents/runtime/agent-interactive.ts packages/core/src/agents/runtime/agent-types.ts packages/core/src/core/turn.ts packages/core/src/core/turn.test.ts
npx eslint packages/cli/src/acp-integration/session/Session.ts packages/cli/src/acp-integration/session/Session.test.ts packages/cli/src/nonInteractiveCli.ts packages/cli/src/nonInteractiveCli.test.ts packages/cli/src/ui/hooks/useGeminiStream.ts packages/cli/src/ui/hooks/useGeminiStream.test.tsx packages/core/src/agents/runtime/agent-core.ts packages/core/src/agents/runtime/agent-headless.test.ts packages/core/src/agents/runtime/agent-interactive.ts packages/core/src/agents/runtime/agent-types.ts packages/core/src/core/turn.ts packages/core/src/core/turn.test.ts --max-warnings 0
git diff --check
npm run typecheck
npm run build

Evidence (Before & After)

修复前:如果 provider 重放同一个已经完成的 provider tool-call id,每一轮都可能收到 synthetic duplicate tool result,让 Qwen Code 留在 duplicate tool-result 循环里。

修复前,在 ACP 路径里还有一个额外问题:长生命周期 Session 一旦记录某个 provider id 已经收到 synthetic duplicate response,这个 Set 在用户 prompt 之间不会清理。

修复前,在 AgentCore 路径里 repeated duplicate guard 会把运行作为普通 error 停止,因此用户和调用方无法把这个循环和其他失败区分开。

修复后:第一次 replay 仍会收到 synthetic duplicate-error function response;同一个 provider id 在该 prompt 内下一次 replay 会在发送任何 fresh sibling tool response 之前终止/丢弃当前 batch。ACP 会在下一个用户 prompt 开始前清理这个 per-prompt guard。AgentCore 会报告 LOOP_DETECTED,interactive runner 会显示 duplicate-loop-specific stop message。

本地结果:

  • ✅ core focused tests:2 个文件通过,1 个文件 skipped,12 个测试通过
  • ✅ CLI focused tests:3 个文件通过,13 个测试通过,包含 drain item repeated-duplicate 路径
  • ✅ 所有 PR touched-file Prettier checks 通过
  • ✅ 所有 PR touched-file ESLint checks 以 --max-warnings 0 通过
  • git diff --check 通过
  • npm run typecheck 通过
  • npm run build 通过
  • ⚠️ build 仍会报告既有 VS Code companion curly lint warnings,以及既有 Browserslist 和 chunk-size warnings;退出码为 0,且都不来自本 PR 修改文件
  • ✅ 只读子代理复审没有发现 ACP clear 位置、helper 语义或回归测试覆盖方面的阻塞问题

Tested on

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

Environment (optional)

本地 macOS workspace,Node.js v26.3.0,运行 focused Vitest suites 以及 repo-level typecheck/build。

Risk & Scope

  • Main risk or tradeoff: 如果格式异常的 provider turn 同时包含已经回答过的重复 provider id 和 fresh sibling tool calls,现在会按 batch 丢弃,因此 fresh sibling 不会执行。这是有意为之,用来避免发送可能违反 OpenAI-compatible tool-call pairing 的 partial tool responses。
  • AgentCore scope: duplicate-loop termination 现在可作为 LOOP_DETECTED 被观察到;现有 cancellation、shutdown、max-turn 和 generic-error modes 不变。
  • ACP scope: duplicate-response tracking 现在是 prompt-scoped,与 non-interactive、TUI 和 AgentCore 的生命周期一致。它仍然会在同一个 prompt 的多轮 tool-call round 之间保留。
  • Not validated / out of scope: 没有运行外部 reproducer 的完整端到端 replay;也不改更广泛的 loop-detection heuristics。
  • Breaking changes / migration notes: 无。

Linked Issues

Fixes #5641

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@wenshao

wenshao commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! (Re-run after latest revision.)

Template looks good ✓ — all required sections present, bilingual body, linked issue, test plan with commands.

On direction: this fixes a real, deterministic bug (#5641) where an OpenAI-compatible provider replays a completed tool-call id in a loop. Users hit this in production. Clearly aligned with qwen-code's core mission of reliable tool execution.

On approach: the scope feels right. The fix adds a shared findRepeatedDuplicateProviderToolCall helper in turn.ts and applies it consistently across all four tool-call processing loops (agent-core, ACP Session, non-interactive CLI, TUI stream). The LOOP_DETECTED terminate mode and per-prompt ACP tracking clear are minimal, purposeful additions. Test coverage is thorough — each entry point has its own regression case. No unrelated changes or scope creep in the diff.

Moving on to code review. 🔍

中文说明

感谢贡献!(最新修订后重新运行。)

模板完整 ✓ — 所有必需章节齐全,双语正文,关联 issue,带命令的测试计划。

方向:修复了一个真实的、确定性的 bug(#5641)—— OpenAI 兼容 provider 会循环重放已完成的 tool-call id。用户已在生产中遇到。与 qwen-code 可靠工具执行的核心使命完全一致。

方案:范围合理。在 turn.ts 添加共享的 findRepeatedDuplicateProviderToolCall helper,并在全部四条 tool-call 处理循环中一致应用(agent-core、ACP Session、非交互 CLI、TUI stream)。LOOP_DETECTED 终止模式和 ACP per-prompt tracking 清理都是最小且有针对性的新增。测试覆盖充分 —— 每个入口都有独立回归用例。diff 中没有无关改动或范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Code review

The implementation is clean and correct. The shared primitive findRepeatedDuplicateProviderToolCall in turn.ts covers both cases: a handled provider id that already received a synthetic response, and the same handled id appearing multiple times in one batch. The four entry points each keep their own scoped duplicateProviderToolCallResponseIds Set, mark ids after sending a synthetic response, and drop the batch when a repeated duplicate is detected.

processFunctionCalls in agent-core now returns a structured result with a repeatedDuplicateProviderToolCall flag — clean propagation of the circuit-breaker signal. The LOOP_DETECTED terminate mode in AgentTerminateMode gives this failure class a distinct identity, and the interactive agent surfaces it as a user-visible stop message.

ACP's per-prompt Set clear (in session.prompt()) is correctly placed — before the main loop body, so it resets for each new user prompt while preserving tracking within a prompt's multi-round tool calls.

One nuance: the fresh sibling tool call paired with the first replay still executes (by design — the first replay gets a synthetic response, the breaker fires on the second replay). This is the documented conservative trade-off and matches the PR's intent to preserve first-replay pairing.

No correctness bugs, security issues, or regressions found. No AGENTS.md violations — no over-abstraction, the helper lives in the right package (turn.ts), and the code follows existing patterns.

Focused unit test results

All 15 focused tests pass across all entry points:

packages/core:
  ✓ turn.test.ts — findRepeatedDuplicateProviderToolCall (3 tests)
  ✓ agent-headless.test.ts — repeated duplicate provider (1 test, 339ms)

packages/cli:
  ✓ nonInteractiveCli.test.ts — duplicate provider (6 tests)
  ✓ Session.test.ts — duplicate provider (4 tests)
  ✓ useGeminiStream.test.tsx — repeated history-paired duplicate (1 test)

CI: all checks green (3 successful, 14 skipped).

Note: E2E reproduction with a mock provider requires a custom harness (the bug is provider-side behavior that can't be triggered via a normal qwen -p prompt). Three prior rounds of maintainer verification — including mutation testing, real-binary A/B with mock providers, and tmux-driven TUI testing — have independently confirmed the fix across all four paths. The focused unit tests here validate the same logic at the unit level.

中文说明

代码审查

实现简洁且正确。turn.ts 中的共享原语 findRepeatedDuplicateProviderToolCall 覆盖了两种情况:已收到合成响应的已处理 provider id,以及同一批次内重复出现的已处理 id。四个入口各自维护独立的 duplicateProviderToolCallResponseIds Set,在发送合成响应后标记 id,并在检测到重复 duplicate 时丢弃整批。

agent-core 的 processFunctionCalls 现在返回带 repeatedDuplicateProviderToolCall 标记的结构化结果 —— 断路器信号的传播方式清晰。AgentTerminateMode 中的 LOOP_DETECTED 让这类失败有了独立的身份标识,interactive agent 会以用户可见的停止消息呈现。

ACP 的 per-prompt Set 清理(在 session.prompt() 中)位置正确 —— 在主循环体之前,确保每个新 prompt 都会重置,同时保留同一 prompt 内多轮 tool call 的跟踪。

一个细节:与第一次重放配对的 fresh sibling tool call 仍然会执行(这是有意为之 —— 第一次重放收到合成响应,断路器在第二次重放时才触发)。这是文档化的保守取舍,与 PR 保留首次重放配对的意图一致。

未发现正确性 bug、安全问题或回归。未违反 AGENTS.md —— 没有过度抽象,helper 放在正确的包中(turn.ts),代码遵循既有模式。

聚焦单测结果

全部 15 个聚焦测试通过:

packages/core:
  ✓ turn.test.ts — findRepeatedDuplicateProviderToolCall(3 个测试)
  ✓ agent-headless.test.ts — repeated duplicate provider(1 个测试,339ms)

packages/cli:
  ✓ nonInteractiveCli.test.ts — duplicate provider(6 个测试)
  ✓ Session.test.ts — duplicate provider(4 个测试)
  ✓ useGeminiStream.test.tsx — repeated history-paired duplicate(1 个测试)

CI:全部检查绿色(3 个成功,14 个跳过)。

注意:用 mock provider 进行 E2E 复现需要自定义测试框架(该 bug 是 provider 端行为,无法通过普通 qwen -p 调用触发)。此前三轮维护者验证 —— 包括变异测试、真实二进制 A/B mock provider 测试、tmux 驱动的 TUI 测试 —— 已独立确认修复在全部四条路径上有效。此处的聚焦单测在单元层面验证了相同逻辑。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Reflection

This is a solid bug fix for a real production issue. The provider tool-call replay loop (#5641) could trap users in a seemingly infinite run with repeated tool-result submissions. The fix is minimal in concept — track which provider ids have already received a synthetic duplicate response, and drop the batch when the same id appears again — but it has to be applied in four separate places because each tool-call processing loop is independent.

The implementation matches what I'd propose independently: a shared detection helper in turn.ts, per-loop tracking Sets, and a clean signal to terminate. The LOOP_DETECTED enum value and user-facing message are the right level of visibility. The ACP per-prompt clear is a necessary correctness fix that was missing in earlier revisions and is now properly addressed.

What makes this PR stand out is the verification depth. Beyond the 15 focused unit tests (all passing here), three independent maintainer verifications — including mutation testing that proved 5/14 tests catch the actual bug, real-binary E2E with mock providers showing 41→4 request reduction, and Scenario B demonstrating that the fresh-sibling-in-batch case is uniquely caught by this fix — provide strong confidence.

The one honest caveat (noted by both the author and reviewers): the fresh sibling paired with the first replay still executes once before the breaker trips. This is a conscious trade-off to preserve first-replay pairing, and it's documented in the PR body.

Verdict: ships a focused, well-tested fix for a real bug. Clean to review and maintain. Approving. ✅

中文说明

反思

这是一个针对真实生产问题的扎实 bug 修复。provider tool-call 重放循环(#5641)会让用户陷入看似无限的运行,不断重复提交 tool-result。修复在概念上是最小的 —— 跟踪哪些 provider id 已收到合成 duplicate 响应,在同一 id 再次出现时丢弃整批 —— 但必须在四个独立位置应用,因为每个 tool-call 处理循环都是独立的。

实现与我独立提出的方案一致:turn.ts 中的共享检测 helper、per-loop 跟踪 Set、以及干净的终止信号。LOOP_DETECTED 枚举值和用户可见消息的可见度级别恰当。ACP per-prompt 清理是早期修订中缺失的必要正确性修复,现已妥善处理。

这个 PR 突出之处在于验证深度。除了 15 个聚焦单测(此处全部通过),还有三轮独立的维护者验证 —— 包括证明 14 个测试中有 5 个能捕获实际 bug 的变异测试、显示请求从 41→4 减少的真实二进制 E2E mock provider 测试、以及证明 batch 中 fresh-sibling 情况仅被此修复捕获的场景 B —— 提供了充分的信心。

一个诚实的说明(作者和 reviewer 都已提到):与第一次重放配对的 fresh sibling 在断路器触发前仍会执行一次。这是为保留首次重放配对而有意识的取舍,已在 PR 正文中记录。

结论:为一个真实 bug 交付了聚焦、经过充分测试的修复。易于审查和维护。批准。✅

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

@wenshao

wenshao commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — local real-binary + A/B testing

I built and ran this PR locally and went beyond re-running the unit tests: I (a) proved the new tests actually catch the bug via a merge-base overlay, and (b) reproduced #5641 end-to-end against the real qwen binary with a mock OpenAI-compatible provider — the step the triage bot explicitly skipped ("not reproducible via normal qwen -p invocation"). Posting as a merge reference.

Verdict: ✅ Works as described. Recommend merge. Two honest nuances for reviewers at the bottom.

Setup

  • PR is a single commit 48d19cb6f; baseline is its parent e9afd5278 → clean A/B (baseline = PR minus the one fix commit).
  • Real binary run from TS source via node scripts/dev.js -p … --approval-mode yolo, isolated HOME, OpenAI-compatible env pointing at a local mock. Linux x86_64, Node v22.22.2.

1. Tests on the fix — all green

Suite Focused (test plan) Full file (regression)
agent-headless.test.ts 3 ✅ 38 ✅
nonInteractiveCli.test.ts 3 ✅ 56 ✅ (1 skip)
useGeminiStream.test.tsx 3 ✅ 130 ✅
Session.test.ts 5 ✅ 168 ✅
Total 14/14 392/392

2. A/B: do the new tests actually exercise the fix?

Overlaid the baseline versions of the 5 source files (turn.ts, agent-core.ts, nonInteractiveCli.ts, useGeminiStream.ts, Session.ts) while keeping the PR's test files, then re-ran the focused tests. 5 of 14 failed — exactly the new "repeated duplicate" regression cases — confirming they genuinely test the circuit breaker (the other 9 cover the pre-existing first-replay suppression, so they still pass on baseline):

  • nonInteractiveClitest times out at 5000 ms — i.e. an actual infinite loop on baseline.
  • agent-headlessexecute called 2× instead of 1× (the fresh sibling leaks through and runs).
  • SessionsendMessageStream called 4× instead of 3×, and the dropped tool's getTool/build/execute run 2× when they should be 0×.
  • useGeminiStream → repeated-history-paired case fails.

3. Real-binary E2E — reproducing #5641

Mock provider modeled on the issue's reproducer: request 1 → tool-call id repeat_loop_initial; every request that carries a tool result → the same fixed id repeat_loop_followup (a completed id replayed forever). Mock requests capped at 40 so a true infinite loop still terminates the harness.

Scenario Build Provider requests Fresh-sibling executions Stopped by
A — same id replayed (matches the issue repro) baseline 6 generic consecutive_identical_tool_calls
A PR fix 4 global_tool_call_duplicate (this PR)
B — replay + a unique fresh sibling each round baseline 41 (hit the cap → unbounded) 38 nothing tripped (only the mock cap stopped it)
B PR fix 4 2 global_tool_call_duplicate (this PR)

Scenario B is the important one. When the replayed completed id is mixed with a fresh sibling tool call, the always-on consecutive_identical_tool_calls guard never fires (each round differs), so on current main the run loops unbounded and the fresh sibling executes real side effects every round (38×). This PR's provider-id circuit breaker is the only thing that stops it (drops the batch at the 2nd replay). Verified id trace on the fix:

req2 assistantCallIds=[repeat_loop_initial]
req3 assistantCallIds=[repeat_loop_initial, repeat_loop_followup, fresh_sibling_2]
req4 assistantCallIds=[…, repeat_loop_followup__qwen_dup_2, fresh_sibling_3]  → batch dropped, run halted

4. Typecheck / lint

  • typecheck @qwen-code/qwen-code-coreclean ✅ (matches the PR).
  • typecheck @qwen-code/qwen-codeclean in my environment; the PR's own 9 changed files introduce no type errors. (The BaseTextInput.tsx / ink/dom failure noted in the PR is environment-specific and unrelated to this change.)
  • git diff --check → clean ✅. The PR's added lines are lint-clean (the only items a stricter local eslint flags are pre-existing — identical on baseline at the same shifted lines — not introduced here).

Two nuances for reviewers (not blockers)

  1. Scenario A is already partially mitigated on main. The simple same-id loop is now caught by the always-on consecutive_identical_tool_calls guard at ~6 rounds, so for that exact case this PR mainly stops it ~2 rounds earlier via an independent path. The material new protection is Scenario B (fresh sibling mixed in), which the generic guard cannot catch.
  2. The fresh sibling still runs twice (not zero) before the breaker trips — by design: the first appearance and the first replay are processed, and the breaker fires on the second replay. That's the documented conservative trade-off (keep first-replay pairing), but worth a conscious sign-off since it means one fresh sibling paired with the first replay still executes.
中文版(点击展开)

维护者验证 — 本地真实二进制 + A/B 测试

我在本地构建并运行了这个 PR,并且没有止步于重跑单元测试:(a) 通过 merge-base 覆盖证明了新增测试确实能捕获该 bug;(b) 用一个 mock OpenAI-compatible provider 对真实 qwen 二进制端到端复现了 #5641 —— 这正是 triage bot 明确跳过的环节("无法通过普通 qwen -p 调用复现")。作为合并参考发布。

结论:✅ 行为与描述一致,建议合并。 文末有两点供 reviewer 留意的诚实说明。

环境

  • PR 是单个 commit 48d19cb6f,baseline 是其父 commit e9afd5278 → 干净的 A/B(baseline = PR 去掉那一个修复 commit)。
  • 真实二进制通过 node scripts/dev.js -p … --approval-mode yolo 从 TS 源码运行,隔离 HOME,OpenAI-compatible 环境变量指向本地 mock。Linux x86_64,Node v22.22.2。

1. 修复版上的测试 —— 全绿

套件 Focused(测试计划) 整个文件(回归)
agent-headless.test.ts 3 ✅ 38 ✅
nonInteractiveCli.test.ts 3 ✅ 56 ✅(1 skip)
useGeminiStream.test.tsx 3 ✅ 130 ✅
Session.test.ts 5 ✅ 168 ✅
合计 14/14 392/392

2. A/B:新增测试是否真的覆盖了修复?

把 5 个源文件(turn.tsagent-core.tsnonInteractiveCli.tsuseGeminiStream.tsSession.ts)换成 baseline 版本,同时保留 PR 的测试文件,再跑 focused 测试。14 个里有 5 个失败 —— 正是新增的 "repeated duplicate" 回归用例,证明它们确实在测断路器(其余 9 个测的是已有的首次重放抑制,所以在 baseline 上仍然通过):

  • nonInteractiveCli测试在 5000ms 超时,即 baseline 上真的进入死循环
  • agent-headlessexecute 被调用 2 次而非 1 次(fresh sibling 泄漏并执行了)。
  • SessionsendMessageStream 被调用 4 次而非 3 次,且被丢弃的 tool 的 getTool/build/execute 本应 0 次却跑了 2 次
  • useGeminiStream → repeated-history-paired 用例失败。

3. 真实二进制 E2E —— 复现 #5641

Mock provider 按 issue 的 reproducer 建模:第 1 个请求 → tool-call id repeat_loop_initial;之后每个携带 tool result 的请求 → 同一个固定 id repeat_loop_followup(一个已完成的 id 被无限重放)。mock 请求上限设为 40,确保真死循环也能终止。

场景 构建 provider 请求数 fresh-sibling 执行次数 由谁终止
A —— 同一个 id 被重放(与 issue 复现一致) baseline 6 通用 consecutive_identical_tool_calls
A PR 修复 4 global_tool_call_duplicate(本 PR)
B —— 重放 + 每轮一个唯一的 fresh sibling baseline 41(触顶 → 无界) 38 没有任何守卫触发(只有 mock 上限把它停下)
B PR 修复 4 2 global_tool_call_duplicate(本 PR)

场景 B 是关键。当被重放的已完成 id 与一个 fresh sibling tool call 混在一起时,常驻的 consecutive_identical_tool_calls 守卫从不触发(每轮都不同),所以在当前 main 上会无界循环,而且 fresh sibling 每轮都真实执行副作用(38 次)。本 PR 的 provider-id 断路器是唯一能阻止它的机制(在第 2 次重放时丢弃整批)。修复版上的 id trace:

req2 assistantCallIds=[repeat_loop_initial]
req3 assistantCallIds=[repeat_loop_initial, repeat_loop_followup, fresh_sibling_2]
req4 assistantCallIds=[…, repeat_loop_followup__qwen_dup_2, fresh_sibling_3]  → 整批丢弃,运行终止

4. Typecheck / lint

  • typecheck @qwen-code/qwen-code-core干净 ✅(与 PR 一致)。
  • typecheck @qwen-code/qwen-code → 在我的环境干净;PR 自己改动的 9 个文件没有引入任何类型错误。(PR 中提到的 BaseTextInput.tsx / ink/dom 失败是环境相关的,与本改动无关。)
  • git diff --check → 干净 ✅。PR 新增的代码行 lint 干净(本地更严格的 eslint 唯一标出的几项是已有问题 —— 在 baseline 的相同(行号偏移后)位置完全一样,并非本 PR 引入)。

两点供 reviewer 留意(非阻塞)

  1. 场景 A 在 main 上已被部分缓解。 简单的同 id 循环现在会被常驻的 consecutive_identical_tool_calls 守卫在约 6 轮时捕获,所以对这个特定场景,本 PR 主要是通过一条独立路径提前约 2 轮终止。真正有实质意义的新保护是场景 B(混入 fresh sibling),那是通用守卫无法覆盖的。
  2. 在断路器触发前,fresh sibling 仍会执行 2 次(而非 0 次) —— 这是有意为之:首次出现和第一次重放会被正常处理,断路器在第二次重放时才触发。这是文档化的保守取舍(保留首次重放的配对),但因为它意味着与首次重放配对的那个 fresh sibling 仍会执行,值得 reviewer 有意识地确认一下。

) {
loopDetectedMessage = emitLoopDetectedMessage(
config,
LoopType.GLOBAL_TOOL_CALL_DUPLICATE,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The loop detection message tells users they can disable this guard via skipLoopDetection, but GLOBAL_TOOL_CALL_DUPLICATE is not in the isAlwaysOn list (line 128-130). The circuit breaker in processToolCallBatch is unconditional — toggling skipLoopDetection has no effect.

Suggested change
LoopType.GLOBAL_TOOL_CALL_DUPLICATE,
const isAlwaysOn =
loopType === LoopType.TURN_TOOL_CALL_CAP ||
loopType === LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS ||
loopType === LoopType.GLOBAL_TOOL_CALL_DUPLICATE;

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

fixed. GLOBAL_TOOL_CALL_DUPLICATE is now treated as always-on in the headless message, and the repeated-duplicate test pins that it does not suggest skipLoopDetection.

const pushDuplicateBatch = (request: ToolCallRequestInfo): void => {
const providerCallId = request.providerCallId ?? request.callId;
if (
!markDuplicateProviderToolCallResponseSent(

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.

[Suggestion] The per-call markDuplicateProviderToolCallResponseSent false-branch inside pushDuplicateBatch is dead code. The pre-scan above (line ~3235) already catches all repeated duplicate cases — duplicateProviderToolCallResponseIds.has(providerCallId) and count > 1 — and returns early. The return false path in markDuplicateProviderToolCallResponseSent is never reached at any of the 5 call sites across the 4 implementations (Session.ts, nonInteractiveCli.ts, useGeminiStream.ts, agent-core.ts).

Suggested fix: remove the false-branch and repeatedDuplicateProviderToolCall mutation. Keep only this.duplicateProviderToolCallResponseIds.add(providerCallId) to populate the tracking set.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

cleaned this up. The per-call false branch is gone; repeated duplicates are handled by the batch pre-scan, and the duplicate path now only marks the provider id before sending the first synthetic response.

debugLogger.debug(
`[processGeminiStreamEvents] Dropping batch after repeated duplicate provider tool-call id: ${repeatedDuplicateRequest.providerCallId} (tool: ${repeatedDuplicateRequest.name})`,
);
return StreamProcessingStatus.Completed;

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.

[Suggestion] When the circuit breaker fires in the TUI path, processGeminiStreamEvents returns StreamProcessingStatus.Completed with only a debugLogger.debug call — no user-visible feedback. The turn ends silently. Compare with nonInteractiveCli.ts which calls emitLoopDetectedMessage and exits with code 1.

Suggested fix: set loopDetectedRef.current = true before returning Completed so the TUI displays a visible loop-detected message.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

done. The TUI repeated-duplicate batch path now sets loopDetectedRef.current = true before completing, so the visible loop-detected message is shown.

Comment thread packages/cli/src/nonInteractiveCli.ts Outdated
}

if (
!markDuplicateProviderToolCallResponseSent(

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.

[Suggestion] In the unique batch loop, markDuplicateProviderToolCallResponseSent receives requestInfo.providerCallId (type string | undefined), while the adjacent duplicate batch loop correctly resolves it via requestInfo.providerCallId ?? requestInfo.callId. The pre-scan and all other 3 implementations consistently use the resolved value.

Suggested change
!markDuplicateProviderToolCallResponseSent(
!markDuplicateProviderToolCallResponseSent(
requestInfo.providerCallId ?? requestInfo.callId,
duplicateProviderToolCallResponseIds,
)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

updated. The non-interactive path now resolves the id consistently before marking the duplicate response. I kept the structured-output special case from the existing tests so sibling structured_output calls with no provider id do not get misclassified as provider duplicates.

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report (maintainer)

Built and tested this PR locally to confirm the fix before merge.

Environment

  • macOS (Darwin 25.5), Node v22.22.2
  • PR head 48d19cb6f, base e9afd5278, current origin/main 31dcb6e32MERGEABLE, and none of the 5 changed source files drifted in main, so the PR head is a faithful base.
  • Method: isolated git worktree at the PR head with a clean npm ci + fresh core/acp-bridge build, then verified each claim independently.

Results

Check Command Result
Target suites (4) vitest run headless / Session / nonInteractiveCli / useGeminiStream ✅ 392 pass, 1 skip
New dedup tests 5 cases across the 4 layers ✅ pass
Types tsc --noEmit (core + cli) ✅ 0 errors each
Lint / Format eslint / prettier --check on all 9 changed files ✅ clean

Mutation test (the key evidence). I reverted the four layer source files (agent-core.ts, Session.ts, nonInteractiveCli.ts, useGeminiStream.ts) to main while keeping the shared turn.ts helper and all tests. Exactly 5 tests fail — one per layer (Session has two):

× agent-headless  > should stop repeated duplicate provider tool-call responses
× nonInteractiveCli > should stop repeated duplicate provider tool-call responses   (~5s — it loops/times out!)
× useGeminiStream  > drops repeated history-paired duplicate provider ids after the first synthetic response
× Session (ACP)    > stops an ACP prompt after a repeated duplicate provider id without sending an empty follow-up
× Session (ACP)    > drops repeated duplicate provider functionCall ids after the first synthetic response

The other 387 tests stay green. The headless test is the behavioral reproduction of #5641: a deterministic provider re-submits a completed tool call across 3 streamed batches, and it asserts the real tool execute() runs once, the provider is called 3× (not 100+), and the run terminates with ERROR. (Notably the non-interactive failure took ~5s on old code — i.e. it actually loops until a timeout.) Each of the four entry paths is independently guarded and proven.

Correctness review

  • New shared primitive markDuplicateProviderToolCallResponseSent(id, set) (in turn.ts) is a mark-once: returns true + records the id the first time, false afterwards.
  • Each of the four tool-call processing loops keeps a scoped duplicateProviderToolCallResponseIds Set. The first duplicate of an already-handled provider call id still gets one synthetic Duplicate provider tool call id "…" response (existing behavior preserved). A repeated duplicate — the same id appearing more than once in a batch, or recurring in a later round — is detected (repeatedDuplicateProviderIds count + Set membership), the batch is dropped, and the turn terminates with ERROR instead of feeding the provider yet another response.
  • Net effect: each provider tool-call id executes at most once (exactly Qwen Code repeats completed shell tool results on current npm latest #5641's expectation), and the re-submission loop is bounded instead of running ~100 times.
  • The guard is applied consistently at all four paths that process provider tool calls — agent runtime, ACP Session, non-interactive CLI, and the interactive useGeminiStream hook — each of which has its own loop, so each genuinely needs it, and each has its own test.

Minor / non-blocking observation. The ~20-line "repeated duplicate" detection block (build the count map, then find) is duplicated across the four layers. Understandable since the four loops are separate, but it's a DRY opportunity — it could be extracted into a shared findRepeatedDuplicateProviderCall(...) next to the already-shared markDuplicateProviderToolCallResponseSent. Cosmetic; no effect on correctness.

Verdict: LGTM. A serious re-submission-loop bug; the fix is correct, applied uniformly across all four paths, and every layer is mutation-verified. Merges cleanly, types/lint/format clean. Safe to merge from my side.

🇨🇳 中文版

✅ 本地验证报告(维护者)

合并前在本地构建并测试了本 PR 以确认修复。

环境

  • macOS(Darwin 25.5),Node v22.22.2
  • PR head 48d19cb6f,base e9afd5278,当前 origin/main 31dcb6e32 —— MERGEABLE,且 5 个改动源文件在 main 上都没有漂移,所以 PR head 是忠实基线。
  • 方法:在 PR head 上建独立 git worktree,做干净的 npm ci 并重建 core/acp-bridge,再逐条独立验证。

结果

检查项 命令 结果
目标套件(4 个) vitest run headless / Session / nonInteractiveCli / useGeminiStream ✅ 392 通过,1 跳过
新增去重测试 跨 4 层的 5 个用例 ✅ 通过
类型 tsc --noEmitcore + cli ✅ 各 0 错误
Lint / 格式 对全部 9 个改动文件跑 eslint / prettier --check ✅ 干净

变异测试(关键证据)。 我把四个层的源文件(agent-core.tsSession.tsnonInteractiveCli.tsuseGeminiStream.ts)还原成 main,保留共享的 turn.ts helper 和所有测试。恰好 5 个测试失败 —— 每层一个(Session 有两个):

× agent-headless  > should stop repeated duplicate provider tool-call responses
× nonInteractiveCli > should stop repeated duplicate provider tool-call responses   (~5 秒——它在循环/超时!)
× useGeminiStream  > drops repeated history-paired duplicate provider ids after the first synthetic response
× Session (ACP)    > stops an ACP prompt after a repeated duplicate provider id without sending an empty follow-up
× Session (ACP)    > drops repeated duplicate provider functionCall ids after the first synthetic response

其余 387 个测试保持绿色。headless 那个测试就是 #5641 的行为复现:一个确定性 provider 在 3 个流式 batch 里重复提交一个已完成的 tool call,断言真实工具 execute() 只跑一次、provider 被调用 3 次(而非 100+)、运行以 ERROR 终止。(值得注意的是 non-interactive 那个失败在旧代码上耗时 ~5 秒 —— 即它真的会循环到超时。)四条入口路径各自被独立守护并验证。

正确性审查

  • turn.ts 里新的共享原语 markDuplicateProviderToolCallResponseSent(id, set) 是「标记一次」:首次返回 true 并记录 id,之后返回 false
  • 四个 tool-call 处理循环各自维护一个作用域内的 duplicateProviderToolCallResponseIds Set。对一个已处理 provider call id 的第一个重复,仍会发出一个 Duplicate provider tool call id "…" 合成响应(保留既有行为)。而再次重复 —— 同一 id 在一个 batch 里出现多次、或在后续轮次再现 —— 会被检测到(repeatedDuplicateProviderIds 计数 + Set 成员判断),随即丢弃该 batch 并让本轮以 ERROR 终止,而不是再给 provider 发一个响应。
  • 净效果:每个 provider tool-call id 至多执行一次(正是 Qwen Code repeats completed shell tool results on current npm latest #5641 的预期),重复提交循环被限定,而不是跑 ~100 次。
  • 这个守卫在处理 provider tool call 的全部四条路径上一致应用 —— agent runtime、ACP Session、非交互 CLI、交互式 useGeminiStream hook —— 每条都有自己的循环,所以每条都确实需要它,且每条都有自己的测试。

次要 / 非阻塞观察。 那段 ~20 行的「重复检测」逻辑(建计数 map 再 find)在四层里是复制粘贴的。鉴于四个循环各自独立,这可以理解,但是个 DRY 机会 —— 可以抽成一个共享的 findRepeatedDuplicateProviderCall(...),放在已经共享的 markDuplicateProviderToolCallResponseSent 旁边。纯属外观,不影响正确性。

结论:LGTM。 一个严重的重复提交循环 bug;修复正确、在全部四条路径上一致应用、每层都经变异测试验证。可干净合并,类型/lint/格式干净。从我这边看可以安全合并。

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

No additional findings from my pass. I did not duplicate the existing open review threads; those remain the actionable items to resolve before treating the PR as fully clear.

Local verification: focused duplicate-provider-id tests passed for non-interactive CLI, TUI stream handling, ACP session, and AgentCore; npm run build, npm run typecheck, and git diff --check passed. CI is green.

— GPT-5 Codex via Qwen Code /review

@tt-a1i
tt-a1i force-pushed the fix/drop-repeated-provider-duplicates branch from 48d19cb to e056681 Compare June 23, 2026 06:14
// ACP can continue one logical conversation through prompt, cron, and
// background loops, so keep this with the session instead of a single
// runToolCalls invocation.
private readonly duplicateProviderToolCallResponseIds = new Set<string>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] duplicateProviderToolCallResponseIds is a session-scoped Set<string> that is never cleared between ACP prompts — unlike every other code path:

  • TUI: duplicateProviderToolCallResponseIdsRef.current.clear() at each new user submit (useGeminiStream.ts:2047)
  • nonInteractiveCli: local variable, fresh per invocation
  • agent-core: local variable, fresh per reasoning loop

In a long-lived ACP session, once a provider tool-call ID is flagged in prompt N, any subsequent prompt where the model legitimately emits the same provider ID will immediately trip the circuit breaker — the entire batch is dropped and the turn silently terminates. The bug worsens over time as the Set grows monotonically.

Suggested change
private readonly duplicateProviderToolCallResponseIds = new Set<string>();
private readonly duplicateProviderToolCallResponseIds = new Set<string>();

Then add this.duplicateProviderToolCallResponseIds.clear() at the top of prompt() or #executePrompt, alongside other per-prompt resets. If the class-level lifetime is truly intended (per the comment above), document the trade-off and verify that provider IDs never collide across prompts.

— qwen3.7-max via Qwen Code /review

duplicateProviderToolCallResponseIds,
);
if (toolCallResult.repeatedDuplicateProviderToolCall) {
terminateMode = AgentTerminateMode.ERROR;

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.

[Suggestion] When the circuit breaker fires here, terminateMode = AgentTerminateMode.ERROR gives no user-facing indication that the failure was a duplicate-tool-call loop. Compare with nonInteractiveCli.ts which calls emitLoopDetectedMessage(config, LoopType.GLOBAL_TOOL_CALL_DUPLICATE) and the TUI which sets loopDetectedRef.current = true. At 3 AM, "Agent stopped due to an error" gives zero signal about the root cause.

Consider emitting a debug log or user-facing loop-detected message before the break, or adding a dedicated AgentTerminateMode.LOOP_DETECTED.

— qwen3.7-max via Qwen Code /review

@@ -661,6 +661,73 @@ describe('runNonInteractive', () => {
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n');
});

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.

[Suggestion] Test coverage gaps:

  1. Count-in-batch (>1) trigger path untested — All 4 new tests exercise only the cross-batch Set-based trigger. None test the within-batch (count ?? 0) > 1 branch where two copies of the same already-handled provider ID appear in a single response batch. This is a distinct code branch in all 4 implementations.

  2. Drain-item loop handling untested — The drain path's circuit breaker (nonInteractiveCli.ts:~1501) sets loopDetected = true and returns from the inner function, but no test covers a drain item triggering the circuit breaker.

— qwen3.7-max via Qwen Code /review

// Build allowed tool names set for filtering
const allowedToolNames = new Set(toolsList.map((t) => t.name));
const repeatedDuplicateProviderIds = new Map<string, number>();
for (const fc of uniqueFunctionCalls) {

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.

[Suggestion] The 3-step repeated-duplicate detection (count Map → find first repeated → short-circuit) is structurally copy-pasted in Session.ts:3234, useGeminiStream.ts:1870, and nonInteractiveCli.ts:845. Any semantic fix must be applied in 4 places. Consider extracting a shared helper in turn.ts alongside the existing createDuplicateProviderToolCallResponse:

export function findRepeatedDuplicateProviderId<T>(
  items: T[],
  getId: (item: T) => string | undefined,
  knownIds: Set<string>,
  previouslySentIds: Set<string>,
): T | undefined { ... }

Each callsite passes its own ID-extraction function.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Real-environment verification report — PR #5657 (fixes #5641)

I built the real CLI from this PR and ran an A/B against a mock OpenAI‑compatible provider that replays an already‑completed tool‑call id (the exact shape of #5641). Verified across all four tool‑execution loops.

Setup

  • A/B builds from the same toolchain/node_modules, only the 5 changed source files differ:
    • BASE = merge‑base d350dd8df (fix absent) · PR = head e05668109 (fix present)
  • Bundled CLI v0.19.0 (node esbuild.config.js), provider = a zero‑dep mock that returns the same provider tool‑call id every round (run_shell_command appending a marker so executions are counted). --approval-mode yolo.
  • The mock has a 40‑round safety valve so a buggy build can't loop forever; a BASE run that reaches ~40 would otherwise never self‑terminate (the original issue observed 101).

Headline A/B — loopvary (same completed id replayed, args vary so the pre‑existing consecutive_identical_tool_calls guard does NOT fire; this isolates the PR's new guard)

Tool loop (entry point) BASE d350dd8df PR e05668109
Non‑interactive qwen -p (nonInteractiveCli.ts) 41 provider requests — loops until the mock's safety valve 3 requests, halted by always‑on global_tool_call_duplicate
Daemon / ACP qwen serve (Session.runToolCalls) 42 provider requests — loops until safety valve 4 requests, [Session.runToolCalls] Dropping batch…
Interactive TUI (useGeminiStream.ts, driven via tmux) 42 requests, 39 synthetic duplicate submissions, only stopped by the mock 3 requests, shows "A potential loop was detected" dialog
Subagent / headless (agent-core.ts) PR's focused agent-headless regression tests pass (3/3)

In every arm the shell side‑effect ran exactly once (HIT=1) → the bug is repeated result submission, not re‑execution; the PR preserves single execution.

The guard fired in each path (PR debug logs, QWEN_DEBUG_LOG_FILE=1)

  • -p: [NON_INTERACTIVE_CLI] [runNonInteractive] Dropping batch after repeated duplicate provider tool-call id: call_dup_0001
  • daemon: [SESSION] [Session.runToolCalls] Dropping batch after repeated duplicate provider tool-call id … + Stopping ACP turn after dropping repeated duplicate provider tool-call response.
  • TUI: [GEMINI_STREAM] [processGeminiStreamEvents] Dropping batch after repeated duplicate provider tool-call id …

PR -p terminal result: Loop detection halted the run (global_tool_call_duplicate: the model repeated the same tool call across the turn, even when not back-to-back). This is an always-on guard…

Additional scenarios

  • Identical‑args replay (-p): BASE submitted 4 accumulating synthetic duplicate results (__qwen_dup_2/3/4) before the pre‑existing consecutive_identical_tool_calls guard caught it at turn 5; PR halts at turn 3 with 1 synthetic result. The PR catches it earlier and with a guard specific to the actual failure mode.
  • Duplicate id + a fresh sibling tool call in the same batch (-p): BASE executed the fresh sibling (SIBLING=1); PR dropped the whole batch so the sibling did not run (SIBLING=0) — matches the PR's intended "no partial tool responses" contract.

Notes

  • First replay still pairs correctly: exactly one synthetic duplicate provider tool call … was already handled response is sent before the batch‑drop kicks in on the next replay. ✔
  • Unit suites from the PR test plan on this build: agent-headless 3 pass, Session 5 pass. nonInteractiveCli.test.ts / useGeminiStream.test.tsx failed to load here due to an environment‑only dependency‑resolution issue (@qwen-code/web-templates entry / testing‑library), unrelated to the change — both of those paths are independently proven by the live E2E above.

Verdict: the fix resolves #5641 and the loop is broken on all four tool‑execution paths, with single‑execution and first‑replay pairing preserved. LGTM from a behavioral standpoint. 👍

中文版验证报告(点击展开)

✅ 真实环境验证报告 — PR #5657(修复 #5641

我用本 PR 实际构建了 CLI,并针对一个 会重放“已完成 tool-call id”的 mock OpenAI 兼容 provider(正是 #5641 的复现形态)做了 A/B 对比,覆盖了全部四条 tool 执行回路

环境

  • A/B 使用相同的工具链 / node_modules,仅 5 个改动源文件不同:
    • BASE = merge-base d350dd8df(无修复) · PR = head e05668109(有修复)
  • 打包后的 CLI v0.19.0node esbuild.config.js);provider 为零依赖 mock,每一轮都返回同一个 provider tool-call idrun_shell_command 追加标记,用于统计真实执行次数);--approval-mode yolo
  • mock 设了 40 轮安全阀,避免 buggy 构建无限循环;BASE 能跑到 ~40 即说明它本身永不自停(原 issue 观测到 101 次)。

核心 A/B — loopvary(重放同一 id,但参数每轮变化,从而绕过既有的 consecutive_identical_tool_calls 守卫,单独检验本 PR 新增的守卫)

Tool 回路(入口) BASE d350dd8df PR e05668109
非交互 qwen -pnonInteractiveCli.ts 41 次 provider 请求 — 一直循环直到 mock 安全阀 3 次,被常开守卫 global_tool_call_duplicate 中止
Daemon / ACP qwen serveSession.runToolCalls 42 次请求 — 循环至安全阀 4 次[Session.runToolCalls] Dropping batch…
交互 TUI(useGeminiStream.ts,经 tmux 驱动) 42 次请求39 次合成重复提交,仅靠 mock 才停下 3 次,弹出 “A potential loop was detected” 对话框
子代理 / headless(agent-core.ts PR 自带 agent-headless 回归用例通过(3/3)

每一组 shell 副作用都只执行了 一次HIT=1)→ bug 是重复提交结果,而非重复执行;本 PR 保持了“只执行一次”。

各路径守卫确实触发(PR 的 debug 日志,QWEN_DEBUG_LOG_FILE=1

  • -p[NON_INTERACTIVE_CLI] [runNonInteractive] Dropping batch after repeated duplicate provider tool-call id: call_dup_0001
  • daemon:[SESSION] [Session.runToolCalls] Dropping batch … + Stopping ACP turn after dropping repeated duplicate provider tool-call response.
  • TUI:[GEMINI_STREAM] [processGeminiStreamEvents] Dropping batch …

PR -p 终态:Loop detection halted the run (global_tool_call_duplicate: …). This is an always-on guard…

附加场景

  • 相同参数重放-p):BASE 在被既有 consecutive_identical_tool_calls 守卫于第 5 轮拦下之前,已累计提交 4 次合成重复结果(__qwen_dup_2/3/4);PR 在第 3 轮即停,仅 1 次合成结果。本 PR 更早拦截,且用的是针对该失败模式的专用守卫。
  • 重复 id 与一个全新 sibling tool call 同批-p):BASE 执行了该全新 siblingSIBLING=1);PR 整批丢弃,sibling 未执行SIBLING=0)—— 符合本 PR “不发送部分 tool 响应”的设计约定。

说明

  • 首次重放仍正确配对:在下一次重放触发整批丢弃之前,恰好发送 一条 duplicate provider tool call … was already handled 合成响应。✔
  • PR 测试计划中的单测在本构建:agent-headless 3 通过Session 5 通过nonInteractiveCli.test.ts / useGeminiStream.test.tsx纯环境依赖解析问题(@qwen-code/web-templates 入口 / testing-library)加载失败,与本改动无关 —— 这两条路径已由上面的实跑 E2E 独立证明。

结论:该修复解决了 #5641,四条 tool 执行路径上的循环均被打断,且保持“只执行一次”与“首次重放配对”。从行为层面 LGTM。 👍

Verification method: built BASE vs PR bundles from an isolated git worktree; deterministic mock OpenAI provider; A/B over qwen -p, qwen serve (HTTP/ACP), and the tmux‑driven TUI; metrics = provider request count + real shell executions + debug‑log guard traces.

wenshao
wenshao previously approved these changes Jun 23, 2026
@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@tt-a1i
tt-a1i dismissed stale reviews from qwen-code-ci-bot and wenshao via 35a04d5 June 24, 2026 13:15
@tt-a1i
tt-a1i force-pushed the fix/drop-repeated-provider-duplicates branch 2 times, most recently from 35a04d5 to 60cc667 Compare June 24, 2026 13:40
wenshao
wenshao previously approved these changes Jun 24, 2026
@doudouOUC

Copy link
Copy Markdown
Collaborator

Thanks, I checked the latest revision. My previous blocking concerns look addressed: ACP duplicate-response tracking is now cleared per prompt, repeated-duplicate detection is shared, the TUI path now surfaces loop detection, and the non-interactive drain path has coverage. CI is green from the current check rollup. I am satisfied with the follow-up revision.

中文说明

谢谢,我检查了最新版本。之前阻塞性的反馈看起来已经处理:ACP duplicate-response tracking 现在会按 prompt 清理,repeated-duplicate 检测已抽成共享逻辑,TUI 路径现在会展示 loop detection,non-interactive drain 路径也补了覆盖。当前 CI 结果为绿色。我对这轮修改满意。

@wenshao

wenshao commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@tt-a1i
tt-a1i force-pushed the fix/drop-repeated-provider-duplicates branch from 104e983 to ef4bbe8 Compare June 25, 2026 02:08

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed the latest revision. All previous blocking concerns are addressed: ACP duplicate-response tracking is now cleared per prompt, repeated-duplicate detection is shared via findRepeatedDuplicateProviderToolCall, the TUI path surfaces loop detection, AgentCore uses LOOP_DETECTED termination mode, and the non-interactive path resolves IDs consistently. Build passes, all 25 focused tests pass. LGTM. ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jun 25, 2026
return { text: 'Agent stopped: time limit reached.', level: 'warning' };
case AgentTerminateMode.ERROR:
return { text: 'Agent stopped due to an error.', level: 'error' };
case AgentTerminateMode.LOOP_DETECTED:

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.

[Suggestion] forkedAgent.ts:559 misclassifies LOOP_DETECTED as 'completed' — the failure branch only checks CANCELLED, ERROR, and TIMEOUT. Forked agents (dream, extraction, skill-review) that hit this new circuit breaker would be treated as successful completions, producing incomplete output.

Fix in forkedAgent.ts:

if (
  terminateReason === AgentTerminateMode.ERROR ||
  terminateReason === AgentTerminateMode.TIMEOUT ||
  terminateReason === AgentTerminateMode.LOOP_DETECTED
) {

— qwen3.7-max via Qwen Code /review

// is a model-facing detail, not part of the original message.
this.emitExternalInputEvents(externalInputs);
}
if ((currentMessages[0]?.parts?.length ?? 0) === 0) {

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.

[Suggestion] This empty-parts guard is unreachable with the current implementation — processFunctionCalls always returns non-empty toolResponseParts when repeatedDuplicateProviderToolCall is false (every call hits unauthorized-error, duplicate-error, or executed-tool path). The LOOP_DETECTED break above already covers the only known empty-parts scenario. If a future refactor makes this reachable, it terminates as generic ERROR with no debug log. Consider adding a comment explaining this is purely defensive, or adding a debugLogger.warn before the break.

— qwen3.7-max via Qwen Code /review

return emitStructuredSuccess();
}
if (
repeatedDuplicateProviderToolCall &&

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.

[Suggestion] The toolResponseParts.length === 0 condition is always true when repeatedDuplicateProviderToolCall is true — processToolCallBatch always returns empty responseParts alongside the flag. If a future change makes it return non-empty parts with the flag set, this guard silently skips the loop-detection exit. Consider dropping the redundant condition or replacing it with an assertion.

— qwen3.7-max via Qwen Code /review

duplicateProviderToolCallResponseIds.add(providerCallId);
}

export function findRepeatedDuplicateProviderToolCall<T>(

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.

[Suggestion] The two-stage pipeline (handledProviderToolCallIds = all processed IDs, duplicateProviderToolCallResponseIds = subset that received synthetic error) is implicit across 4 call sites. A brief doc comment here explaining the semantics — "first replay → synthetic error response, second replay → terminal batch drop" — would save future maintainers significant tracing time.

— qwen3.7-max via Qwen Code /review

await this.messageRewriter?.waitForPendingRewrites();
}

async #buildNextMessageAfterToolRun(

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.

[Suggestion] This method returns null to signal "stop the turn," consumed by 4 while (nextMessage !== null) call sites. A brief JSDoc noting the null contract ("returns null to signal the turn should stop; callers must not dereference the return value without a null check") would prevent future callers from crashing.

— qwen3.7-max via Qwen Code /review

...(await this.#drainMidTurnUserMessages(ac.signal)),
],
};
nextMessage = await this.#buildNextMessageAfterToolRun(

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.

[Suggestion] The cron loop (and background notification loop at ~line 2901) exit silently when #buildNextMessageAfterToolRun returns null on repeated duplicate. Unlike the main prompt path, these paths emit no loop-detected event or user-visible signal — a cron job that hits the circuit breaker appears to complete normally in metrics. Consider emitting a conversation_finished event with a loop-detected reason, or logging at info level.

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao removed this pull request from the merge queue due to the queue being cleared Jun 25, 2026
@wenshao
wenshao added this pull request to the merge queue Jun 25, 2026
Merged via the queue into QwenLM:main with commit 7bd7eaa Jun 25, 2026
57 checks passed
euntaek-hong pushed a commit to wrongbutworks/qwen-code that referenced this pull request Aug 19, 2026
… stop (QwenLM#9435)

The ACP daemon Session was the only duplicate-provider-id circuit
breaker path (PR QwenLM#5657) that terminated silently: the turn ended as a
normal end_turn with nothing in the transcript and no telemetry, so the
session looked hung. Route the breaker through
recordDaemonLoopDetected with LoopType.GLOBAL_TOOL_CALL_DUPLICATE — the
same loop type the non-interactive CLI reports — so foreground turns
fail with the visible LOOP_DETECTED turn error, the context message is
preserved for the next turn, and the LoopDetectedEvent telemetry is
emitted. The bespoke repeatedDuplicateProviderToolCall result flag and
its dead consumer branch are removed in favor of the existing
loopDetected plumbing.
doudouOUC added a commit that referenced this pull request Aug 19, 2026
…matching args

The duplicate provider tool-call guard (#5038/#5657) keyed on the id
alone, so models whose ids are only unique within a single response —
e.g. Kimi emits {name}_{index} and the index can restart at 0 on any
round — had fresh calls misclassified as replays: the second collision
got a synthetic duplicate error and the third tripped the circuit
breaker, killing every turn by round three.

A handled id now maps to a (name, canonical args) fingerprint — the
same sha256 repeat key the loop guards use, moved to a leaf module so
toolCallIdUtils can share it without an import cycle. An incoming call
is a replay only when its fingerprint matches the call that first
executed under that provider id; id collisions with different args
execute normally under the unique suffixed id that normalization
already assigns. Exact same-args replays keep the unchanged #5014
suppression and #5657 breaker behavior at all four entry points
(AgentCore, TUI stream, non-interactive CLI, ACP daemon session).

The synthetic duplicate message now tells the model to re-issue with a
fresh tool-call id when a new invocation was intended, giving
id-emitting models a recovery path.
samuelhsin pushed a commit to samuelhsin/qwen-code that referenced this pull request Aug 19, 2026
…n arguments match (QwenLM#9436)

* fix(core): treat duplicate provider tool-call ids as replays only on matching args

The duplicate provider tool-call guard (QwenLM#5038/QwenLM#5657) keyed on the id
alone, so models whose ids are only unique within a single response —
e.g. Kimi emits {name}_{index} and the index can restart at 0 on any
round — had fresh calls misclassified as replays: the second collision
got a synthetic duplicate error and the third tripped the circuit
breaker, killing every turn by round three.

A handled id now maps to a (name, canonical args) fingerprint — the
same sha256 repeat key the loop guards use, moved to a leaf module so
toolCallIdUtils can share it without an import cycle. An incoming call
is a replay only when its fingerprint matches the call that first
executed under that provider id; id collisions with different args
execute normally under the unique suffixed id that normalization
already assigns. Exact same-args replays keep the unchanged QwenLM#5014
suppression and QwenLM#5657 breaker behavior at all four entry points
(AgentCore, TUI stream, non-interactive CLI, ACP daemon session).

The synthetic duplicate message now tells the model to re-issue with a
fresh tool-call id when a new invocation was intended, giving
id-emitting models a recovery path.

* qwen: address PR review feedback (QwenLM#9436)

- Fingerprint each incoming call once per carrier object: the WeakMap
  cache now keys on any stable carrier (FunctionCall part or
  ToolCallRequestInfo), and the replay predicate / recording helpers
  take the precomputed fingerprint instead of rehashing (name, args)
  on every breaker scan, admission pass, and record.
- Move the getToolCallRepeatKey tests next to the extracted leaf
  module instead of exercising it through the loop detection service's
  compatibility re-export.
- Restore the assertion pinning that runToolCalls never mutates the
  history accessor's returned fingerprint map, now that the defensive
  copy is load-bearing.

* qwen: address PR review feedback (QwenLM#9436)

Criticals:
- Canonicalize onto a null-prototype object so a literal __proto__ own
  key (preserved by JSON.parse) stays a data property instead of
  vanishing through the inherited setter — two calls differing only in
  __proto__ no longer collide on one repeat key, which the replay
  oracle would have turned into a wrongly suppressed execution.
- Clone request args at scheduler intake: callers pass args that can
  alias the model-emitted functionCall part stored in chat history, and
  the executor rewrites PATH_ARG_KEYS on request.args in place (a
  persistence the post-'ask' bounce re-execution relies on). Without
  the clone those rewrites leak into history and skew the replay
  fingerprints derived from it, letting genuine replays of
  path-carrying calls re-execute in multi-round agent runtimes.

Suggestions:
- Complete the duplicate message with the different-arguments recovery
  path required by design decision D3, for models whose provider
  assigns ids.
- Fix the copy-convention comments (the accessor returns a fresh map
  per call; copies are future-proofing) and align all four entry
  points on copying the accessor result.
- Pin the untested branches: history first-occurrence-wins for reused
  ids and orphan response-id exclusion, the fingerprint cache-hit path,
  the __proto__ distinction, the caller-args no-mutation invariant, and
  a cross-round runtime test that a replay of the original call stays
  suppressed after an id-colliding execution.
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.

Qwen Code repeats completed shell tool results on current npm latest

5 participants