Skip to content

fix(core): route id-less continuation chunks to a colliding tool-call opener's slot - #6981

Merged
wenshao merged 5 commits into
QwenLM:mainfrom
he-yufeng:fix/streaming-toolcall-collision-continuation
Jul 26, 2026
Merged

fix(core): route id-less continuation chunks to a colliding tool-call opener's slot#6981
wenshao merged 5 commits into
QwenLM:mainfrom
he-yufeng:fix/streaming-toolcall-collision-continuation

Conversation

@he-yufeng

@he-yufeng he-yufeng commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Fixes a silent tool-call argument-loss bug in StreamingToolCallParser. When a provider reuses the same streaming index for a second tool call whose id and name arrive together on an empty opener delta (the standard OpenAI streaming shape function: { name, arguments: "" }), the index -> slot remap was never recorded — it was guarded by !meta.id, and the id was already set. The subsequent id-less argument chunks then failed to find the remapped slot and were routed to a fresh orphan slot: the tool call was emitted with empty args {}, the real arguments (in a nameless slot) were dropped by getCompletedToolCalls, and the nameless slot made converter.ts raise InvalidStreamError('MALFORMED_TOOL_CALL'), forcing up to four wasted retries of an otherwise valid response.

The fix records the remap whenever the actual slot differs from the provider index, and guards pending-remap adoption so a brand-new id cannot hijack a slot that already has an id. A follow-up commit also removes the now-dead pendingIndexRemaps.delete in the adoption branch (the common-path re-registration immediately re-creates it), which a reviewer flagged, and documents why the remap is intentionally kept alive.

Why it's needed

Under the standard OpenAI streaming protocol, argument continuation chunks carry neither id nor name. When a provider recycles an index, these continuations were misrouted, so any tool call in that shape silently lost its arguments and burned the retry budget. This is a real provider-stream shape, not a synthetic edge case, and the existing collision tests only covered the id-arrives-late variant.

Reviewer Test Plan

How to verify

cd packages/core
npx vitest run src/core/openaiContentGenerator/streamingToolCallParser.test.ts

Three tests are added: routes id-less continuation chunks to a slot claimed by a colliding opener delta, routes id-less continuations after a content-bearing colliding opener, and does not let a brand-new tool-call id adopt a remap slot that already has an id (the block direction of the adoption guard).

Evidence (Before & After)

Non-user-visible core parser change, so the evidence is the test suite rather than a screenshot.

  • Before: reverting the fix fails routes id-less continuation chunks...continuation.actualIndex is an orphan slot and the call is emitted with args: {}. Reverting the added guard fails does not let a brand-new tool-call id adopt...third.actualIndex is 1, hijacking the prior call's slot.
  • After: Test Files 1 passed, Tests 88 passed. The full openaiContentGenerator suite stays green.

Tested on

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

macOS verified locally (parser suite 88/88, prettier/eslint clean); Windows and Linux are exercised by CI.

Environment (optional)

N/A — unit tests only.

Risk & Scope

  • Main risk or tradeoff: confined to StreamingToolCallParser's index-remap bookkeeping; the id-arrives-late path and public API are untouched.
  • Not validated / out of scope: real-provider live streams (the failing shape is reproduced deterministically in the unit tests instead).
  • Breaking changes / migration notes: none.

Linked Issues

None — a self-contained streaming-parser correctness fix found while reading the collision/remap code introduced in #6819.

… opener's slot

StreamingToolCallParser remaps a provider index to a fresh slot when a second
tool call reuses an already-completed index. When that second call's id and
name arrive together on an empty opener delta (the standard OpenAI streaming
shape, function: { name, arguments: "" }), the index -> slot remap was never
recorded, because it was guarded by !meta.id and the id was already set. The
following id-less argument chunks then failed to find the remapped slot and
were routed to a brand-new orphan slot: the tool call was emitted with empty
args {}, the real arguments (in a nameless slot) were dropped by
getCompletedToolCalls, and the nameless slot made converter.ts raise
InvalidStreamError('MALFORMED_TOOL_CALL'), forcing up to four wasted retries of
an otherwise valid response.

Record the remap whenever the actual slot differs from the provider index, not
only before an id is known, so id-less continuations follow it. To keep a
genuinely new tool call from hijacking that slot, only adopt a pending remap
for a new id when the remapped slot has not itself been claimed by an id yet
(the existing id-arrives-late path); otherwise fall through to normal collision
handling.

Adds a regression test for the id+name-on-opener collision shape, which the
existing "late stable ID" collision tests did not cover.

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

Hi @he-yufeng, thanks for the PR — the bug analysis and fix look solid, but the description doesn't follow the PR template. The template requires these headings:

  • ## What this PR does
  • ## Why it's needed
  • ## Reviewer Test Plan (with ### How to verify, ### Evidence (Before & After), ### Tested on)
  • ## Risk & Scope
  • ## Linked Issues

Your PR uses ## What, ## Fix, and ## Test instead. Could you restructure the body to match the template? The content is already great — it just needs to be placed under the correct headings so reviewers can find it quickly.

中文说明

你好 @he-yufeng,感谢提交!Bug 分析和修复看起来很好,但 PR 描述没有遵循 PR 模板。模板要求以下标题:

  • ## What this PR does
  • ## Why it's needed
  • ## Reviewer Test Plan(包含 ### How to verify### Evidence (Before & After)### Tested on
  • ## Risk & Scope
  • ## Linked Issues

你的 PR 使用了 ## What## Fix## Test。请将正文重新组织到模板的标题下——内容本身已经很好,只需要放在正确的标题下方便 reviewer 查阅。

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.

Reviewed. Suggestions are inline. Not reviewed: chunk 1 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

Comment on lines +1015 to +1016
it('routes id-less continuation chunks to a slot claimed by a colliding opener delta', () => {
parser.addChunk(0, '{"a":1}', 'call_1', 'function1');

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 new test covers the removed !meta.id guard (remap recording) but does not exercise the added guard !this.toolCallMeta.get(...)?.id on pending-remap adoption. In this test, step 1 (call_1 at index 0) never creates a remap because actualIndex === index, so the guarded else if branch is never reached. A third id-bearing call at the same provider index would be needed to hit that branch.

Failure scenario: If the !meta.id guard were accidentally removed, all existing tests still pass — a third tool call at the same index would silently hijack the second call's remapped slot, dropping its continuation arguments.

it('does not let a third colliding opener hijack a remapped slot', () => {
  parser.addChunk(0, '{"a":1}', 'call_1', 'function1');
  parser.addChunk(0, '', 'call_2', 'function2'); // creates remap 0→1
  const third = parser.addChunk(0, '', 'call_3', 'function3');
  expect(third.actualIndex).toBe(2); // must NOT adopt slot 1
  // call_2's continuation still routes correctly
  expect(parser.addChunk(0, '{"b":2}').actualIndex).toBe(1);
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The added guard's block direction is covered by the third test, does not let a brand-new tool-call id adopt a remap slot that already has an id: after call_2 claims the 0->1 remap with its own id, a third call reusing index 0 with a fresh id asserts third.actualIndex !== 1, which fails if the !toolCallMeta.get(remap)?.id guard is removed. This first test intentionally covers the record path; the third covers the adopt-guard path.

@doudouOUC

Copy link
Copy Markdown
Collaborator

Qwen Code Review (qwen3.7-max)

结论:ISSUES_FOUND — 验证通过,发现 3 个建议级问题(2 高置信 + 1 低置信)和 1 个 Nice to have。

审查摘要

PR 修复了 StreamingToolCallParser 在索引冲突时 id-less 后续 chunk 路由错误的问题。代码变更方向正确,构建与测试(639/639)均通过。主要缺口集中在新增逻辑的测试覆盖与断言完整性上。

Findings

# 级别 文件 说明
1 Suggestion (high) streamingToolCallParser.test.ts ~L1015 新的三向碰撞 guard 只测了“通过”方向,未覆盖“阻断”方向(第三个新 id 到达时 remapped slot 已有 id)。
2 Suggestion (low) streamingToolCallParser.ts ~L246 content-bearing opener 路径的 !meta.id 移除没有被直接测试,新增测试只覆盖了 empty-opener 早返回路径。
3 Suggestion (high) streamingToolCallParser.test.ts ~L1036 新测试仅对 call_2 使用 toContainEqual,未断言 call_1args 未被破坏。
4 Nice to have (low) streamingToolCallParser.ts ~L52 pendingIndexRemaps 的 JSDoc 已 slightly 过时,未反映 PR 后同时用于 post-ID 路由的双重语义。

Rejected / 非问题

  • Debug logging 缺失(风格偏好)
  • 提取复合条件(轻微重构建议)
  • 多碰撞 remap 覆盖(既有设计限制,场景不现实)

Adds a case where a brand-new tool-call id reuses an index that already has a
0->N remap to an id-claimed slot: it must not hijack that slot and instead gets
its own. This exercises the `!toolCallMeta.get(remap)?.id` guard directly (the
prior test only covered id-less continuation routing). Fails without the guard.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed both points:

  • Rewrote the description to follow the PR template (What / Why / Reviewer Test Plan / Risk & Scope / Linked Issues).
  • Added does not let a brand-new tool-call id adopt a remap slot that already has an id (62bce69), which exercises the added !toolCallMeta.get(remap)?.id guard directly — a third tool call reusing the index with a fresh id must fall through to collision handling instead of hijacking the prior call's slot. It fails without the guard (third.actualIndex becomes 1) and passes with it. Parser suite is now 87/87 and the full openaiContentGenerator suite stays at 639/639.

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

Reviewed. Not reviewed: chunk 1 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

…p doc

Per review: add a case for the content-bearing colliding opener (arguments
fragment arriving with the id/name, exercising the line-239 remap-record path
that the empty-opener test skipped), assert call_1's arguments survive the
collision in the id-less-continuation test, and update the pendingIndexRemaps
doc to describe its dual role (post-id adoption and id-less continuation
routing).
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Addressed the remaining suggestions (001a710):

Parser suite is now 88/88, prettier/eslint clean.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to the diff; see the terminal output. Not reviewed: chunk 1 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

// once it has one, the remap only exists to route later id-less
// continuation chunks, so a brand-new id must not hijack that slot.
actualIndex = this.pendingIndexRemaps.get(index)!;
this.pendingIndexRemaps.delete(index);

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] Dead delete — pendingIndexRemaps.delete(index) here is unconditionally re-created at line 220 (empty-chunk early return) or line 250 (normal path), since actualIndex !== index always holds on the adoption path. The delete has no runtime effect.

Concrete cost: An oncall engineer reading this delete would conclude the remap is gone after adoption and waste time tracing a phantom state change. A future refactor adding an early return between this delete and the re-set would silently break id-less continuation routing.

Fix: Remove the this.pendingIndexRemaps.delete(index); line entirely. The downstream pendingIndexRemaps.set(index, actualIndex) at lines 220/250 already overwrites the entry with the same value.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — removed in ef62d64. You're right that the common-path re-registration re-creates the mapping on the adoption path (actualIndex !== index always holds and isKnownId is false there, so the "ignoring replay chunk" early return isn't reached), so the delete had no runtime effect. I left a comment where it was so the remap's intended lifetime — kept alive for later id-less continuations — reads straight through and nobody re-adds it.

Once an id adopts a remapped slot, the common-path re-registration keeps
the index->slot mapping alive for later id-less continuation chunks, so
the delete in the adoption branch is immediately re-created and has no
runtime effect. Remove it and note why the remap is intentionally kept,
so the mapping's lifetime reads straight through.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage — the description now follows the PR template (all required headings plus the Tested-on table), and I've addressed both inline suggestions: removed the dead remap delete (ef62d64) and pointed to the existing coverage for the adopt-guard block direction. Ready for another look.

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

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines 249 to 254
meta.name = validName;
}
}
if (!meta.id && actualIndex !== index) {
if (actualIndex !== index) {
this.pendingIndexRemaps.set(index, actualIndex);
}

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 common-path remap recording unconditionally overwrites an existing pendingIndexRemaps entry when a third tool call collides at the same provider index. While this 3+ collision scenario is pre-existing (the old code also fails via slot hijacking), guarding the overwrite would prevent the failure without breaking the 2-call fix.

Failure scenario: three tool calls at provider index 0 with content-bearing openers -- call_2 at slot 1 (incomplete args), call_3 at slot 2 (incomplete args). Remap 0->1 is overwritten to 0->2. An id-less continuation for call_2 routes to slot 2 (call_3's incomplete buffer) and is appended there, corrupting call_3's args and leaving call_2 incomplete.

Suggested change
meta.name = validName;
}
}
if (!meta.id && actualIndex !== index) {
if (actualIndex !== index) {
this.pendingIndexRemaps.set(index, actualIndex);
}
if (actualIndex !== index && !this.pendingIndexRemaps.has(index)) {
this.pendingIndexRemaps.set(index, actualIndex);
}

-- qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — I looked at this closely and verified it empirically, and I'm going to keep the unconditional overwrite. The suggested guard (!pendingIndexRemaps.has(index)) keeps the first remap, but an id-less continuation is supposed to belong to the most recent opener at that provider index (the sequential-completion assumption the whole collision path relies on). Keeping the first mapping pins the remap at call_2's slot, so a continuation after call_3's opener is misrouted to call_2.

I added a regression test for exactly the well-formed three-call case (6b62711): call_1@0, call_2 opener→slot 1 with its args, call_3 opener→slot 2, then an id-less {"c":3} that must land on slot 2. It passes with the overwrite and fails with the suggested guard (the continuation lands on slot 1 — the findMostRecentIncompleteIndex fallback doesn't recover it). So the guard would trade the pre-existing 3-call interleaved-corruption case (which it only half-fixes — it moves the corruption from call_2 to call_3) for a regression in the well-formed 3-call case. The interleaved-malformed 3-call case is genuinely pre-existing and out of this PR's 2-call scope.

…peners

The unconditional remap overwrite is deliberate: an id-less continuation after
the newest colliding opener must route to that opener's slot. A regression test
covers the three-call case so the overwrite is not later 'guarded' back into
misrouting the third call's continuation to an earlier slot.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage — description follows the template and the one /review suggestion is addressed (kept the unconditional remap overwrite; the suggested guard regresses the well-formed three-call case, verified by a new test in 6b62711). Ready for another look.

@wenshao

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required headings present.

Problem: this is an observed bug, not theoretical hardening. The PR describes a concrete misrouting path in StreamingToolCallParser — when a provider reuses a streaming index and the opener delta carries both id and name (standard OpenAI shape), the !meta.id guard prevented the remap from being recorded, so subsequent id-less argument chunks landed on an orphan slot and the tool call was emitted with empty args {}. The four new tests reproduce the failure deterministically (reverting the fix fails them), which is solid before/after evidence.

Direction: aligned — this is a correctness fix in the core streaming parser, squarely within qwen-code's mission. No CHANGELOG reference needed for a parser bugfix.

Size: 25 production lines (19+/6−) in streamingToolCallParser.ts, 93 test lines in the collocated test file. Well under any threshold.

Approach: the scope feels right. Three small, related changes — drop the overly restrictive !meta.id guard on remap recording, add a guard so a new id can't hijack an already-claimed remap slot, and remove the now-dead pendingIndexRemaps.delete that the common-path re-registration immediately re-creates. No unrelated edits, no scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需标题齐全。

问题:这是一个已观测到的 bug,而非理论性加固。PR 描述了 StreamingToolCallParser 中一个具体的路由错误路径——当 provider 复用流式 index 且 opener delta 同时携带 idname(标准 OpenAI 流式格式)时,!meta.id 守卫阻止了 remap 的记录,导致后续无 id 的参数 chunk 落入孤立 slot,工具调用以空 args {} 发出。四个新测试确定性地复现了该故障(回退修复后测试失败),是可靠的 before/after 证据。

方向:对齐——这是核心流式解析器的正确性修复,完全在 qwen-code 的使命范围内。

规模:streamingToolCallParser.ts 中 25 行生产代码(19+/6−),测试文件 93 行。远低于任何阈值。

方案:范围合理。三个小而相关的改动——移除过于严格的 !meta.id remap 记录守卫、添加守卫防止新 id 劫持已认领的 remap slot、移除已被公共路径重新注册覆盖的死代码 pendingIndexRemaps.delete。无无关改动,无范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal: given the bug description (id-less continuation chunks misrouted when a provider reuses an index and the opener carries id+name together), I would (1) drop the !meta.id guard on remap recording so the remap is always stored when actualIndex !== index, (2) guard pending-remap adoption so a new id cannot hijack a slot that already has one, and (3) rely on the common-path re-registration to keep the remap alive rather than deleting it in the adoption branch.

Comparison with the diff: the PR does exactly this. The three production changes map 1:1 to the proposal above. No simpler path missed, no correctness issues found.

Specifics I verified by reading the full streamingToolCallParser.ts:

  • The adoption guard (!this.toolCallMeta.get(this.pendingIndexRemaps.get(index)!)?.id) correctly blocks a brand-new id from claiming a slot that already has an id, while still allowing the original id-arrives-late path (slot has no id yet) to adopt the remap.
  • Removing pendingIndexRemaps.delete(index) in the adoption branch is safe: the unconditional pendingIndexRemaps.set(index, actualIndex) at the end of the same code path immediately re-creates the entry. The old delete was dead code.
  • The two !meta.id && → unconditional changes (lines ~219 and ~249) are the core fix: they ensure the remap is recorded even when the opener delta already carries an id, which is exactly the case that was broken.
  • The remap overwrite on a third collision (pendingIndexRemaps.set(0, 2) replacing 0 → 1) is correct and tested — id-less continuations after the third opener route to the newest slot.

No critical blockers. No AGENTS.md violations. The JSDoc update on pendingIndexRemaps accurately describes the dual-role semantics.

Four new tests cover: empty-opener collision + id-less continuation, content-bearing opener collision, adoption-guard block direction, and three-way collision with remap overwrite. Each test asserts both the routed actualIndex and the final getCompletedToolCalls() output, and the first test also verifies call_1's args survive the collision intact.

Testing

Non-user-visible core parser change — no TUI surface affected. Real-scenario tmux testing: N/A.

CI signal on 6b62711a35f53e1e09ec2a348e2624e51cb359a5 (fetched via API, not re-run):

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Classify PR ✅ success

macOS/Windows/integration tests are skipped — typical for fork PRs where platform-specific runners or secrets are unavailable. The ubuntu unit suite (which includes the parser tests) passed. No failures.

中文说明

独立方案: 根据 bug 描述(provider 复用 index 且 opener 同时携带 id+name 时,无 id 的后续 chunk 路由错误),我的方案是:(1) 移除 remap 记录上的 !meta.id 守卫,使 remap 在 actualIndex !== index 时始终记录;(2) 对 pending-remap 认领添加守卫,防止新 id 劫持已有 id 的 slot;(3) 依赖公共路径的重新注册保持 remap 存活,而非在认领分支中删除。

与 diff 对比: PR 的实现与上述方案完全一致。三个生产代码改动与方案一一对应。未发现更简路径,未发现正确性问题。

已验证的关键点:认领守卫正确阻止新 id 劫持已有 id 的 slot,同时保留 id 延迟到达路径的认领能力;移除 pendingIndexRemaps.delete 是安全的(公共路径立即重建);两处 !meta.id && 移除是核心修复;三次碰撞时 remap 覆盖行为正确且有测试覆盖。

无关键阻塞项。无 AGENTS.md 违规。四个新测试覆盖:空 opener 碰撞 + 无 id 后续、带内容 opener 碰撞、认领守卫阻断方向、三次碰撞 remap 覆盖。

测试:非用户可见的核心解析器改动,无 TUI 界面影响。真实场景 tmux 测试:N/A。CI 信号:ubuntu 单元测试通过,macOS/Windows/集成测试跳过(fork PR 典型情况),无失败。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; would merge without hesitation.

This is a tight, well-evidenced correctness fix. The bug is real and the reproduction is deterministic — reverting any of the three production changes fails the corresponding test. The fix matches what I'd have written independently: drop the guard that was too restrictive, add the guard that was missing, and remove the dead delete. Twenty-five production lines, four focused tests, no scope creep.

The author iterated through three rounds of review feedback (qwen-review suggestions plus inline review comments) and addressed each one — added the block-direction test for the adoption guard, covered the content-bearing opener path, asserted call_1 integrity, refreshed the JSDoc, and removed the dead remap delete. The result is a small diff that's easy to reason about and easy to revert if needed.

CI is green on ubuntu (the only platform that runs for fork PRs). No user-visible surface, so no tmux evidence needed.

LGTM, approving. ✅

中文说明

置信度:5/5 —— 各阶段均无问题,毫不犹豫地合并。

这是一个紧凑、证据充分的正确性修复。bug 真实存在,复现是确定性的——回退三个生产代码改动中的任何一个都会导致对应测试失败。修复与我独立提出的方案一致:移除过于严格的守卫、添加缺失的守卫、删除死代码。25 行生产代码,四个聚焦的测试,无范围蔓延。

作者经过三轮审查反馈迭代,逐一解决了每个建议。最终 diff 小巧、易于推理、易于回退。

CI 在 ubuntu 上通过(fork PR 唯一运行的平台)。无用户可见界面,无需 tmux 证据。

LGTM,批准。✅

Qwen Code · qwen3.8-max-preview

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

@he-yufeng Thanks for the updates, and for flagging that the review suggestion was addressed (with the regression test in 6b62711 — appreciated).

One small note: the @qwen-code /triage command is permission-gated — it only runs for users with write/maintain/admin access on this repo, and your account currently has read access, so those two /triage comments didn't actually trigger a run. No action needed on your side; a maintainer can trigger the re-triage. I've just kicked it off, so the bot will post fresh stage results in this thread shortly.


@he-yufeng 感谢更新,也谢谢你说明已处理那条 review 建议(还补了 6b62711 的回归测试,赞)。

小提示:@qwen-code /triage 命令有权限门槛——只有在本仓库拥有 write/maintain/admin 权限的用户才能触发,而你的账号目前是 read 权限,所以那两条 /triage 评论其实没有真正触发运行。你这边无需做任何操作,由 maintainer 触发即可。我刚刚已经触发了,bot 稍后会在本线程更新各阶段结果。

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — merge reference

I built and ran this PR locally as an independent check before merging. Everything below is real output from a trial merge of the PR head onto current main; nothing is quoted from the PR description or from CI.

Verdict: safe to merge. The bug is real, more common than the description suggests, and all three production changes are correct. One non-blocking gap: one of the three changes has no test coverage — details in §5.

Setup

PR head 6b62711a35f53e1e09ec2a348e2624e51cb359a5
Base main @ e3cf1c3b4 (PR is 494 commits behind)
Trial merge 261cec28fclean, 2 files, +112 / −6 (exactly the PR diff)
Environment macOS 24.6.0, Node v22.23.1, vitest 3.2.7

Both touched files are untouched on main since the merge-base, so the 494-commit drift produces no conflict.

1. Test suites

Suite Result
streamingToolCallParser.test.ts 89 passed (85 on base → the PR adds 4)
src/core/openaiContentGenerator/ (17 files) 692 passed
Full packages/core — PR side 17232 passed, 9 failed (14 files)
Full packages/core — base side, same tree without the PR 17228 passed, 9 failed (same 14 files)

The 14 failing files are byte-identical on both sides and are local-environment failures in my symlinked node_modules (ajv-formats / MCP SDK resolution) in chatRecordingService.*, providers/__tests__/*, client-mcp-registrar, etc. — none touch the streaming parser. The pass-count delta is exactly +4, i.e. the PR's four new tests and nothing else.

prettier --check clean, eslint exit 0 on both changed files. tsc --noEmit produces an identical error-file set on the PR and on the base → no PR-introduced type errors.

2. Mutation matrix — do the new tests actually pin the fix?

I reverted one production change at a time and re-ran the 89-test suite.

mutation matrix

Mutant Change reverted Result
M1 !meta.id && restored on the empty-opener remap record (L222) KILLED — 3 tests fail
M2 !meta.id && restored on the content-bearing remap record (L252) SURVIVED ⚠️
M3 new pending-remap adoption guard dropped (L118–121) KILLED — 2 tests fail
M4 removed pendingIndexRemaps.delete(index) re-added (ef62d64f6) SURVIVED — expected; confirms it really was dead code
M5 full revert of the production file, PR tests kept KILLED — 3 discriminating tests
M6 keep-first remap instead of last-write-wins (the /review suggestion) KILLED — 1 test fails

M6 settles the disagreement in the author's favour. The suggested guard really does regress the well-formed three-call case, and 6b62711a3 genuinely pins it — the test is not decorative.

M4 confirms the dead-code claim. Re-adding the deleted line changes nothing, so ef62d64f6 was a true no-op cleanup.

3. Real-world impact — end-to-end through the real converter

The PR's evidence is unit-level. I drove the same wire bytes through the actual OpenAIContentConverter.convertOpenAIChunkToGemini, swapping only the parser.

e2e and fuzz

  • pre-PR parser → throws InvalidStreamError('MALFORMED_TOOL_CALL'), 0 tool calls reach the caller, the otherwise-valid response is discarded.
  • PR parser → both calls emitted with their real arguments.

The retry cost in the description checks out: MALFORMED_TOOL_CALL is not PROTOCOL_TAG_LEAK, so it takes the INVALID_STREAM_RETRY_CONFIG.transientMaxRetries = 4 branch in geminiChat.ts:455 — up to four wasted round-trips per occurrence.

4. Differential fuzz — 20,000 randomized provider streams per variant

Identical generated streams into each parser, comparing slot routing and emitted tool calls:

Variant vs PR Divergence
BASE (pre-PR) 8020 / 20000 (40.1%) — first at seed 41: call_2 emitted with args {} and nameless: trueMALFORMED_TOOL_CALL
M6 keep-first remap 3266 / 20000 (16.3%)
M4 re-add deleted line 0 / 20000
M2 revert L252 0 / 20000

40% divergence is the main thing I'd want a reviewer to see: this is not an exotic corner case. Any provider that recycles a streaming index with the standard function: { name, arguments: "" } opener hits it routinely.

5. One gap (non-blocking): the L252 change is untested

untested line

routes id-less continuations after a content-bearing colliding opener was added specifically to cover the second !meta.id && removal (reply to review finding #2). It does not: the test passes identically with that line reverted (M2 survived). In that two-call scenario the findMostRecentIncompleteIndex() fallback happens to return the same slot the remap would have pointed at, so both paths coincide.

The line is not dead, though. Add a second still-open tool call at a higher slot and the fallback stops coinciding:

parser.addChunk(3, '{"z":',   'call_0', 'fn0'); // slot 3, left open (depth 1)
parser.addChunk(0, '{"a":1}', 'call_1', 'fn1'); // slot 0, complete
parser.addChunk(0, '{"b":',   'call_2', 'fn2'); // collision -> slot 1, content-bearing opener
parser.addChunk(0, '2}');                       // id-less continuation
actualIndex sequence outcome
PR [3, 0, 1, 1] call_2.args = {"b":2}
L252 reverted [3, 0, 1, 3] continuation stolen by call_0; call_2 never completes ❌
base [3, 0, 1, 3] same defect ❌

So the change fixes a second, distinct misroute that the PR never claims or demonstrates. Suggest adding the four-chunk stream above as a fifth test — it would make the mutation matrix 3/3 on production changes. Not a merge blocker; the code is correct as written.

Nit

The description says the parser suite is 88 passed; at 6b62711a3 it is 89 (the fourth test landed after that line was written).


中文说明

本地验证报告 —— 合并参考

在合并前我在本地独立构建并运行了这个 PR。以下全部是把 PR head 试合并到当前 main 之后的真实输出,没有引用 PR 描述或 CI 的任何结论。

结论:可以安全合并。 Bug 真实存在,且比描述中显得更常见,三处生产代码改动都正确。一个不阻塞的缺口:三处改动中有一处没有任何测试覆盖,详见第 5 节。

环境

PR head 6b62711a35f53e1e09ec2a348e2624e51cb359a5
Base main @ e3cf1c3b4(PR 落后 494 个提交)
试合并 261cec28f —— 干净,2 个文件,+112 / −6(与 PR diff 完全一致)
环境 macOS 24.6.0、Node v22.23.1、vitest 3.2.7

两个被修改的文件自 merge-base 起在 main 上未被改动,所以 494 个提交的漂移不会产生冲突。

1. 测试套件

套件 结果
streamingToolCallParser.test.ts 89 通过(base 上是 85 → PR 新增 4 个)
src/core/openaiContentGenerator/(17 个文件) 692 通过
完整 packages/core —— PR 侧 17232 通过,9 失败(14 个文件)
完整 packages/core —— 同一棵树但不带 PR 17228 通过,9 失败(同样的 14 个文件

这 14 个失败文件在两侧完全一致,属于我本地 symlink node_modules 的环境问题(ajv-formats / MCP SDK 解析),涉及 chatRecordingService.*providers/__tests__/*client-mcp-registrar 等,均与流式解析器无关。通过数的差值正好是 +4,即 PR 新增的四个测试,没有其他变化。

prettier --check 干净,两个改动文件 eslint 退出码 0。tsc --noEmit 在 PR 侧与 base 侧产生完全相同的报错文件集合 → PR 未引入任何类型错误

2. 变异测试矩阵 —— 新测试是否真的钉住了这个修复?

每次只回退一处生产代码改动,然后重跑 89 个测试。

变异 回退的改动 结果
M1 恢复 empty-opener 路径上的 !meta.id &&(L222) 被杀死 —— 3 个测试失败
M2 恢复 content-bearing 路径上的 !meta.id &&(L252) 存活 ⚠️
M3 去掉新增的 pending-remap 采纳守卫(L118–121) 被杀死 —— 2 个测试失败
M4 把删掉的 pendingIndexRemaps.delete(index) 加回(ef62d64f6 存活 —— 符合预期,证实它确实是死代码
M5 完整回退生产文件,保留 PR 的测试 被杀死 —— 3 个判别性测试
M6 用 keep-first 取代 last-write-wins(即 /review 的建议) 被杀死 —— 1 个测试失败

M6 判定了那处分歧,作者是对的。 被建议的守卫确实会让格式良好的三次调用场景回归,而 6b62711a3 中的测试真正钉住了它,不是摆设。

M4 证实了死代码的说法。 把删掉的那行加回来行为完全不变,所以 ef62d64f6 是真正的空操作清理。

3. 真实影响 —— 经由真实 converter 的端到端验证

PR 的证据停留在单元层面。我把同样的 wire 字节喂进真实的 OpenAIContentConverter.convertOpenAIChunkToGemini,只替换解析器:

  • 修复前的解析器 → 抛出 InvalidStreamError('MALFORMED_TOOL_CALL')0 个工具调用到达调用方,本来有效的响应被整个丢弃。
  • PR 的解析器 → 两个调用都带着正确参数被正常发出。

描述里说的重试代价也核对无误:MALFORMED_TOOL_CALL 不是 PROTOCOL_TAG_LEAK,因此走 geminiChat.ts:455INVALID_STREAM_RETRY_CONFIG.transientMaxRetries = 4 分支 —— 每次触发最多浪费四轮往返。

4. 差分模糊测试 —— 每个变体 20,000 条随机 provider 流

把完全相同的生成流喂给每个解析器,比对槽位路由和最终发出的工具调用:

变体 vs PR 分歧率
BASE(修复前) 8020 / 20000(40.1%) —— 首次出现在 seed 41:call_2 带着 args {}nameless: true 被发出 ⇒ MALFORMED_TOOL_CALL
M6 keep-first remap 3266 / 20000(16.3%)
M4 加回被删的那行 0 / 20000
M2 回退 L252 0 / 20000

40% 的分歧率是我最希望 reviewer 看到的一点:这根本不是罕见边角场景。任何会复用流式 index、且 opener 采用标准 function: { name, arguments: "" } 形状的 provider 都会经常性地踩中。

5. 一个缺口(不阻塞):L252 的改动没有测试覆盖

routes id-less continuations after a content-bearing colliding opener 是专门为覆盖第二处 !meta.id && 移除而加的(对 review 建议 #2 的回复)。但它没有做到:把那行回退后该测试依然通过(M2 存活)。原因是在那个两次调用的场景里,findMostRecentIncompleteIndex() 兜底恰好返回了 remap 本该指向的同一个槽位,两条路径重合了。

不过这行并非死代码。只要在更的槽位上再加一个仍然打开的工具调用,兜底就不再重合:

parser.addChunk(3, '{"z":',   'call_0', 'fn0'); // 槽位 3,保持打开(depth 1)
parser.addChunk(0, '{"a":1}', 'call_1', 'fn1'); // 槽位 0,已完成
parser.addChunk(0, '{"b":',   'call_2', 'fn2'); // 冲突 -> 槽位 1,content-bearing opener
parser.addChunk(0, '2}');                       // 无 id 的续传块
actualIndex 序列 结果
PR [3, 0, 1, 1] call_2.args = {"b":2}
回退 L252 [3, 0, 1, 3] 续传块被 call_0 抢走;call_2 永远无法完成 ❌
base [3, 0, 1, 3] 同样的缺陷 ❌

也就是说这处改动修掉了 PR 从未声称、也未演示的第二个、独立的路由错误。建议把上面这段四块流补成第五个测试 —— 那样变异矩阵在生产代码改动上就是 3/3。这不是合并阻塞项,代码本身是正确的。

小提示

描述里写解析器套件是 88 passed;在 6b62711a3 上实际是 89(第四个测试是在那句话写完之后才落地的)。

@wenshao
wenshao added this pull request to the merge queue Jul 26, 2026
Merged via the queue into QwenLM:main with commit ebf8f75 Jul 26, 2026
86 of 87 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants