Skip to content

feat(cli): add audio bridge for attachments - #8332

Open
DragonnZhang wants to merge 81 commits into
QwenLM:mainfrom
DragonnZhang:dragon/audio-bridge
Open

feat(cli): add audio bridge for attachments#8332
DragonnZhang wants to merge 81 commits into
QwenLM:mainfrom
DragonnZhang:dragon/audio-bridge

Conversation

@DragonnZhang

Copy link
Copy Markdown
Collaborator

What this PR does

Adds an audio bridge for user-supplied attachments when the primary model does not support audio. Interactive and headless @ attachments and ACP audio prompts are transcribed through the configured batch voice model, then replaced with an explicitly untrusted machine-transcription block before the primary-model request.

The bridge leaves audio unchanged for audio-capable primary models, reports when audio is sent to the transcription provider, and fails closed with a safe unavailable-content note when configuration, size limits, cancellation, or transcription prevents conversion. It also handles IPv4-translated IPv6 DNS answers used by local proxy resolvers without weakening private-network rejection.

Why it's needed

Qwen Code already has a vision bridge for images that a text-only primary model cannot consume, but equivalent user-supplied audio was either dropped as unsupported or only handled on a narrower ACP path. This makes configured voice transcription consistently available across the main prompt entry points while preventing raw unsupported audio or untrusted transcript instructions from silently reaching the primary model.

Reviewer Test Plan

How to verify

  1. Configure a batch transcription model with /model --voice, select a text-only primary model, and attach an MP3 or WAV file with @ in both interactive and headless modes. Confirm that an audio-egress notice appears and that the primary model answers from the transcript without receiving raw audio.
  2. Send an ACP prompt containing a direct audio block or an @ audio path under the same configuration. Confirm that it follows the same conversion and disclosure behavior.
  3. Repeat with an audio-capable primary model. Confirm that the attachment bypasses the bridge and remains native audio.
  4. Remove the voice-model setting or simulate an oversized, cancelled, empty, or failed transcription. Confirm that a text-only primary model receives only a safe unavailable-content note and never the raw audio.
  5. On a machine whose proxy resolver returns an IPv4-translated IPv6 address for the voice endpoint, confirm that non-private mapped addresses work while mapped private addresses remain blocked.

Evidence (Before & After)

Before: a text-only primary model could not use an interactive or headless @ audio attachment and reported the audio as unavailable.

After: a local headless MP3 run emitted Converted 1 audio file(s) to text via qwen3-asr-flash. Your audio was sent to that model. and the primary model returned the expected transcript, including the spoken verification code 4279.

Tested on

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

Environment (optional)

macOS, Node.js 24, source CLI through npm run dev, local fake OpenAI-compatible endpoints for deterministic end-to-end coverage, and a live DashScope qwen3-asr-flash MP3 transcription smoke test.

Risk & Scope

  • Main risk or tradeoff: audio is sent to the configured voice provider when bridging is required; the CLI discloses that egress, caps input at 10 MB, labels transcripts as untrusted, and preserves the existing network-safety checks.
  • Not validated / out of scope: Windows and Linux manual runs; audio nested in tool results; channel-specific attachment plumbing; video; realtime dictation.
  • Breaking changes / migration notes: none. The existing voice-model setting and /model --voice command are reused.

Linked Issues

N/A

中文说明

本 PR 做了什么

为主模型不支持音频时的用户附件新增 audio bridge。交互式和 headless 的 @ 附件以及 ACP 音频提示会通过已配置的批量语音模型转录,并在发给主模型前替换成明确标注为不可信的机器转录文本块。

当主模型本身支持音频时,bridge 会保持音频不变;当音频被发送给转录服务商时会向用户提示;如果配置、大小限制、取消或转录失败导致无法转换,则会安全失败,只提供内容不可用的说明。本 PR 还兼容本地代理 DNS 返回的 IPv4-translated IPv6 地址,同时不会放宽对私网地址的拒绝。

为什么需要

Qwen Code 已经可以通过 vision bridge 处理纯文本主模型无法消费的图片,但用户提供的音频此前要么被作为不支持的内容丢弃,要么只在较窄的 ACP 路径中处理。本 PR 让已配置的语音转录在主要提示入口中保持一致,同时避免原始的不支持音频或转录内容中的不可信指令静默进入主模型。

Reviewer Test Plan

如何验证

  1. 使用 /model --voice 配置批量转录模型,选择纯文本主模型,并在交互式与 headless 模式中通过 @ 附加 MP3 或 WAV 文件。确认界面出现音频出站提示,且主模型基于转录作答而没有收到原始音频。
  2. 在相同配置下发送包含直接音频块或 @ 音频路径的 ACP 提示。确认其转换与披露行为一致。
  3. 改用支持音频的主模型重复测试。确认附件绕过 bridge,并保持为原生音频。
  4. 移除语音模型设置,或模拟超大、被取消、空结果或失败的转录。确认纯文本主模型只收到安全的内容不可用说明,绝不会收到原始音频。
  5. 在代理 DNS 为语音端点返回 IPv4-translated IPv6 地址的机器上,确认非私网映射地址可以工作,而映射后的私网地址仍被阻止。

证据(修改前后)

修改前:纯文本主模型无法使用交互式或 headless 的 @ 音频附件,并会报告音频内容不可用。

修改后:本地 headless MP3 测试输出了 Converted 1 audio file(s) to text via qwen3-asr-flash. Your audio was sent to that model.,主模型随后返回了预期转录,其中包含语音验证码 4279

测试平台

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS、Node.js 24、通过 npm run dev 运行源码 CLI、使用本地模拟 OpenAI-compatible 端点进行确定性的端到端覆盖,并使用真实 DashScope qwen3-asr-flash 完成 MP3 转录 smoke test。

风险与范围

  • 主要风险或取舍:需要 bridge 时,音频会发送给已配置的语音服务商;CLI 会披露该出站行为,将输入限制为 10 MB,把转录标记为不可信,并保留现有网络安全校验。
  • 未验证 / 不在范围内:Windows 和 Linux 手动运行;工具结果中嵌套的音频;各 channel 特有的附件链路;视频;实时听写。
  • 破坏性变更 / 迁移说明:无。复用现有语音模型设置和 /model --voice 命令。

关联 Issue

N/A

@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

E2E test report

Baseline

With the installed CLI and a text-only primary model, a headless @ MP3 prompt could not produce a transcript and the model reported that the audio was unavailable.

Feature verification

  • Deterministic local endpoint E2E passed: the WAV payload was sent only to the configured transcription model, the resulting transcript—not raw audio—was sent to the text-only primary model, and the egress disclosure was emitted.
  • Live DashScope MP3 E2E passed through the source CLI. The bridge emitted Converted 1 audio file(s) to text via qwen3-asr-flash. Your audio was sent to that model. and the primary model returned the expected transcript with verification code 4279.
  • Proxy-DNS regression verification passed: an IPv4-translated IPv6 answer mapping to a non-private address was accepted, while one mapping to 192.168.1.1 remained blocked.

Automated verification

  • Core targeted tests: 219 passed.
  • CLI bridge and prompt-path tests: 849 passed, 1 skipped.
  • Voice network/transcription tests: 42 passed.
  • npm run build && npm run lint && npm run typecheck: passed.

Scope notes

Manual E2E was run on macOS. Windows and Linux manual runs, tool-result audio, channel-specific attachment plumbing, video, and realtime dictation were not tested because they are outside this change's scope.

@DragonnZhang
DragonnZhang marked this pull request as ready for review August 1, 2026 17:08
@DragonnZhang
DragonnZhang enabled auto-merge August 1, 2026 17:08
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 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

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Gate re-run at 63166896 — two commits since the last gate at e374028, both of them merge: sync upstream main. Zero code delta to this PR's own files: the effective diff against the latest main parent matches the PR's recorded per-file stats exactly, and the only files that moved along the way were two docs files where main's own doc edits shifted underneath this PR's intact hunks. Everything below re-checked at the new head.

Template ✓ — all sections present, bilingual.

Problem — observed, not theoretical: on the merge base a text-only primary model returns [Unsupported audio file…] for a WAV @-attachment and treats FLAC/AIFF as opaque binary (Cannot display content of binary file). The maintainer's base-branch comparison in this thread reproduces both.

Direction — aligned: this is the audio twin of the shipped vision bridge. It reuses the existing voice-model setting (/model --voice) and the batch transcription transport instead of adding new product surface, and the area is established territory — ACP voice bridging shipped earlier (#6576). CHANGELOG: no direct entry, but multimodal attachments for text-only primaries are an ongoing theme here.

Size — core paths touched (packages/core/src/utils/fileUtils.ts, inlineMediaLimit.ts, readManyFiles.ts, core/client.ts, plus packages/cli/src/services/** and cross-package plumbing): 894 production lines, 4,535 test lines, 2 schema lines, 44 docs lines. feat type → not size-blocked, but 500+ production lines from a fork through core keeps the Stage 0 maintainer-awareness escalation in force — it caps this run at defer (see Stage 3).

Approach — no scope creep in the final delta. The two commits since the last gate are pure main syncs; no conflict-resolution edits touched PR code, so the approach assessment stands unchanged: one shared fail-closed bridge service consumed by all three entry points, mirroring the vision bridge's shape.

Risk — Stage 1e still matches packages/cli/src/acp-integration/session/Session.ts (high-risk path per the revert-history signal), so Stage 2b CI evidence is mandatory. For the record: @wenshao's approval already stands at exactly this head.

Moving on to code review. 🔍

中文说明

63166896 重跑门禁——距上次 e374028 门禁两个提交,均为 merge: sync upstream main。PR 自身文件零代码增量:对最新 main 父提交的有效 diff 与 PR 记录的逐文件统计完全一致;途中唯一变动的是两个文档文件——main 自身的文档改动在本 PR 完好保留的 hunk 之下移动。以下全部在新 head 上复核。

模板 ✓ —— 各节齐全,双语。

问题 —— 已观测、非理论:merge base 上纯文本主模型对 WAV @ 附件返回 [Unsupported audio file…],FLAC/AIFF 被当成不可读二进制(Cannot display content of binary file)。本帖中维护者的基线对比复现了这两点。

方向 —— 对齐:这是已上线 vision bridge 的音频孪生体,复用现有语音模型设置(/model --voice)与批量转录传输,不新增产品面;该领域已有先例——ACP 语音桥更早随 #6576 上线。CHANGELOG 无直接条目,但纯文本主模型的多模态附件是这里持续的方向。

规模 —— 触及核心路径(packages/core/src/utils/fileUtils.tsinlineMediaLimit.tsreadManyFiles.tscore/client.ts,加 packages/cli/src/services/** 与跨包链路):894 行生产代码、4,535 行测试、2 行 schema、44 行文档。feat 类型 → 不因规模拦截,但 fork 上 500+ 行生产代码穿过 core,Stage 0 维护者知悉升级仍然有效——本次运行因此以 defer 为上限(见 Stage 3)。

方案 —— 最终增量无夹带。上次门禁后的两个提交纯属 main 同步,冲突解决未触及 PR 代码,方案评估不变:一个共享 fail-closed bridge 服务供三个入口复用,与 vision bridge 形态一致。

风险 —— Stage 1e 仍命中 packages/cli/src/acp-integration/session/Session.ts(revert 历史信号的高风险路径),因此 Stage 2b CI 证据为必需项。备案:@wenshao 的批准已落在当前 head 上。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at 6316689653b92f16ea62895d097b47be2b38c889 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Code review

Reviewed at 63166896. The two commits since the last full pass at e374028 are clean merge: sync upstream main — the effective diff against the latest main parent is file-for-file identical to the PR's recorded stats, so this pass re-verified the final diff end to end rather than trusting the carry-over. My independent baseline is unchanged: with the vision bridge shipped, the natural shape is a preserveUnsupportedAudio flag through core's file-reading path, one shared fail-closed bridge service, and replacement markers at all three entry points — which is what this PR does. What I checked this pass:

  • The bridge service fails closed on every branch. No voice model, non-batch transport, unresolvable config, abort before/during/after transcription, unsupported format, oversized payload, empty transcript, thrown error — every one of them replaces the audio with an explicit unavailable marker, never passes raw audio through, and the transcript block is labelled untrusted with a do-not-follow-instructions warning and a surrogate-safe 10,000-char clamp. Egress is counted and disclosed in the notice on all three entry points.
  • The core size-gate matrix is ordered correctly. Bridge-bound audio is rejected pre-read above 10 MiB (readFile is asserted never called), exempted from the two 9.9 MB gates only while the bridge owns it, and the gates re-apply for audio-capable targets where the bridge skips the part — all four combinations pinned by tests, including the 9.95 MiB boundary. The clamp is restored on the native-passthrough path so the operator-tunable inline-media budget owns model-bound audio again.
  • Route overrides resolve fail-closed at every entry point. resolveForModel(..., { failClosed: true }) with the NUL route suffix stripped; an unresolvable or non-audio-capable override replaces the audio with the unavailable marker (interactive/headless also surface a user-facing notice) — no egress, no raw audio to a model that cannot hear it. The getModelOverride callback is threaded through every ACP mid-turn drain and tool-loop site, and the full-turn vision paths now stand down when audio parts are present.
  • Small hardening details hold up. The format-alias map is guarded with Object.hasOwn (the subtype is attacker-influenceable via ACP-supplied mimeType — the tests pin audio/constructor and audio/__proto__), audio/mp4 fails closed as undocumented for the ASR endpoint, and the mime/lite missing-type fallbacks (flac/wma/aiff) are shared by the type detector and the sync MIME gates.
  • Precision note on the NAT64 claim. The PR body mentions IPv4-translated IPv6 handling; the production logic for that entered main via feat(voice): support trusted private ASR base URLs #8350 (feat(voice): support trusted private ASR base URLs), which superseded this PR's own NAT64 commit during a main sync. What remains here is a set of new tests pinning that behavior (mapped-private rejected, unwrappable records fail closed, public NAT64 allowed). The behavior is covered at head; just attributing it correctly.

Still open from earlier rounds, none blocking: R20-1 (the ACP resolution-failure branch replaces the audio correctly but emits no user-facing notice — disclosure consistency, not a leak) plus the deferred Suggestion threads on test-assertion strength, recorded per the late-round guidance.

Testing

Unattended run — no PR code built or executed here. Evidence carried: the PR's own CI at the reviewed commit (table below), and the maintainer's verification record in this thread — four independent local verifications, the most recent a real local stack at e374028, which trails the head by exactly the two code-neutral main syncs. Not independently verified by this run: Windows/Linux behavior (author marked ⚠️; those lanes are secret-gated and skip on fork PRs) — nothing in the delta is platform-conditional, but the gap stays named.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
SDK Java (ubuntu Java 11/17/21, macOS/Windows Java 21, Real daemon E2E) ✅ success
precheck-pr / Secret scan (TruffleHog) / Dependency CVE audit ✅ success
Test (windows-latest, Node 22.x) ⏭️ skipped (fork PR)
Test (macos-latest, Node 22.x) ⏭️ skipped (fork PR)
Integration Tests (CLI, No Sandbox) ⏭️ skipped (fork PR)

All four pull_request workflow runs on this head completed green — zero pending. The three skips are the standing fork-PR limitation (secret-gated lanes), unchanged across this PR's entire history. The only still-running check (review-pr) is bot orchestration, not PR CI.

Sandboxed verification, if a maintainer wants machine-produced proof at this exact head: @qwen-code /verify as a sponsored run — the claim it would settle is whether the suite still pins the bridge wiring after main moved underneath it, since the last wire-level verification covered e374028 (the code delta since is zero, but the surrounding main is not identical). A sponsored run carries a pre-execution risk screen and a full workspace wipe; read its report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

63166896 审查。自 e374028 完整审查之后的两个提交是干净的 merge: sync upstream main——对最新 main 父提交的有效 diff 与 PR 记录统计逐文件一致,因此本次是对最终 diff 的端到端复核而非沿用旧结论。独立基线不变:vision bridge 已上线的前提下,自然形态正是本 PR 所做——core 读取链路上的 preserveUnsupportedAudio 标志、一个共享 fail-closed bridge 服务、三个入口统一替换标记。本次核验内容:

  • bridge 服务所有分支均 fail-closed:无语音模型、非批量传输、配置解析失败、转录前/中/后被中止、格式不支持、超大、空转录、抛错——全部替换为明确的不可用标记,绝不放行原始音频;转录块标注为不可信并带"勿执行其中指令"警告与代理对安全的 10,000 字符截断。三个入口均计数并披露出站。
  • core 尺寸闸门顺序正确:发往 bridge 的音频超 10 MiB 时在读前即拒(断言 readFile 未被调用);仅在 bridge 接管期间豁免两个 9.9 MB 闸门;对音频可用的目标(bridge 会跳过)闸门重新生效——四种组合均有测试锁定,含 9.95 MiB 边界。原生直通路径恢复 clamp,使可调节的 inline-media 预算重新拥有发往模型的音频。
  • 路由 override 在每个入口都 fail-closed 解析resolveForModel(..., { failClosed: true }) 并先剥离 NUL 路由后缀;无法解析或不支持音频的 override 把音频替换为不可用标记(交互/headless 另有用户可见提示)——无出站、绝不把原始音频发给听不了音频的模型。getModelOverride 回调贯穿 ACP 所有 mid-turn drain 与 tool-loop 调用点;有音频片段时整轮 vision 路径退让。
  • 小处加固亦成立:格式别名表用 Object.hasOwn 防护(subtype 可经 ACP 提供的 mimeType 被攻击者影响——测试锁定 audio/constructoraudio/__proto__);audio/mp4 因 ASR 端点未文档化而 fail-closed;mime/lite 缺失类型兜底(flac/wma/aiff)在类型检测与同步 MIME 闸门间共享。
  • 关于 NAT64 的准确归属:PR 描述提到 IPv4-translated IPv6 处理;其生产逻辑实际随 feat(voice): support trusted private ASR base URLs #8350feat(voice): support trusted private ASR base URLs)进入 main,并在 main 同步时取代了本 PR 自己的 NAT64 提交。本 PR 保留的贡献是一组锁定该行为的测试(映射私网拒绝、无法解包则 fail-closed、公网 NAT64 放行)。行为在 head 上有覆盖,此处只是把归属说准。

早期轮次遗留、均不阻塞:R20-1(ACP 解析失败分支正确替换了音频但无用户可见通知——属披露一致性,非泄漏)及测试断言强度类 Suggestion 线程,按后期轮次指引留档。

测试

无人值守运行——未构建、未执行 PR 代码。本节证据:该提交的 PR 自身 CI(见英文部分标记区表格),以及本帖维护者的验证记录——四次独立本地验证,最近一次为 e374028 上的真实本地栈,恰好落后 head 两个零代码增量的 main 同步。本次运行未独立验证:Windows/Linux 行为(作者标 ⚠️;对应通道需 secrets,在 fork PR 上被跳过)——增量中无平台条件分支,但缺口明说。

该 head 的四个 pull_request 工作流全部绿完成——无 pending。三个跳过项是 fork PR 的既有局限(需 secrets 的通道),贯穿本 PR 全部历史。唯一仍在运行的检查(review-pr)是机器人编排,非 PR CI。

如维护者想要当前 head 上的机器化证据:以 sponsored run 方式触发 @qwen-code /verify——它能验证 main 在其下移动之后测试套件是否仍锁定 bridge 链路(上次 wire 级验证覆盖 e374028,此后代码增量为零,但周边 main 已不完全相同)。sponsored run 带有执行前风险筛查与完整工作区清理;请像对待 fork 自身 CI 日志一样保持怀疑地阅读其报告。

Qwen Code · qwen3.8-max

Reviewed at 6316689653b92f16ea62895d097b47be2b38c889 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review at 63166896: the two-commit delta since the last pass is pure main sync with zero code change to PR files, the full-diff re-verification found no blockers, CI is green on every lane that runs on fork PRs, and a maintainer approval already stands at this exact head — the 3/5 is the Stage 0 policy cap (fork PR, ~890 production lines through core), not residual doubt.

Stepping back, honestly: there is nothing left to review here, which is itself the finding. The delta since the last gate is two clean upstream syncs — I verified file-for-file that no conflict resolution touched PR code — and the surrounding review history is the unusually thorough kind: 21+ review rounds, the one Critical that surfaced late (R19-1, the ACP bridge blind to the active route override) fixed and test-pinned two commits before the last gate, and four independent maintainer verifications in this thread, the most recent on a real local stack at e374028. My independent baseline still matches the implementation, and the only correction this pass is one of attribution (the NAT64 hardening arrived via #8350; this PR keeps the tests pinning it), not of substance.

What is actually left, none of it code and none of it for the bot to decide:

  • The merge decision. @wenshao approved exactly this head (63166896) this morning; main wants a second approving review, and the Stage 0 cap means the bot's vote stays out of that count by design. If a maintainer wants machine-produced A/B proof at this head before the second sign-off, @qwen-code /verify as a sponsored run remains available (Stage 2).
  • Recorded follow-ups. R20-1 (ACP notice consistency), R20-2, and the remaining Suggestion threads belong in follow-up issues if they are worth doing.

⏸️ Deferring to @wenshao — the review is clean and the maintainer's approval stands at this head; the bot defers purely on the Stage 0 policy cap for core-touching fork PRs, and the remaining step is the human merge decision.

中文说明

置信度:3/5 —— 63166896 上的复审干净:距上次审查的两个提交纯属 main 同步、PR 文件零代码变更,全 diff 复核未发现阻塞项,CI 在 fork PR 可运行范围内全绿,且维护者的批准已落在当前 head——3/5 是 Stage 0 政策上限(fork PR、约 890 行生产代码穿过 core)所致,并非残留疑虑。

退一步看:这里已经没有可再审查的东西——这本身就是结论。上次门禁后的增量是两次干净的上游同步(已逐文件核验冲突解决未触及 PR 代码),而围绕它的评审历史异常扎实:21+ 轮评审,后期浮现的唯一 Critical(R19-1——ACP bridge 对活动路由 override 视而不见)已在上一门禁前两个提交修复并有测试锁定,本帖中还有四次维护者独立验证,最近一次是 e374028 上的真实本地栈。我的独立基线仍与实现一致;本次唯一的更正只是归属问题(NAT64 加固随 #8350 进入 main,本 PR 保留的是锁定该行为的测试),不涉及实质。

真正剩下的事,都不是代码,也不由机器人决定:

  • 合入决定。 @wenshao 今晨已批准当前 head(63166896);main 还需要第二个批准,而 Stage 0 上限意味着机器人的票按设计不计入。如维护者想在第二次签核前获得当前 head 上的机器化 A/B 证据,仍可按 Stage 2 以 sponsored run 触发 @qwen-code /verify
  • 留档的后续项。 R20-1(ACP 通知一致性)、R20-2 及其余 Suggestion 线程,如果值得做,应转为 follow-up issue。

⏸️ 转交 @wenshao —— 审查干净、维护者批准已在当前 head 上;机器人的 defer 纯粹出于 Stage 0 对触及核心的 fork PR 的政策上限,剩余步骤是人的合入决定。

Qwen Code · qwen3.8-max

Reviewed at 6316689653b92f16ea62895d097b47be2b38c889 · re-run with @qwen-code /triage

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 2, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Review: feat(cli): add audio bridge for attachments

Overview

Extracts the ACP-only voice conversion into a shared packages/cli/src/services/audio-bridge-service.ts and wires it into all three prompt entry points (ACP Session, interactive useGeminiStream, headless nonInteractiveCli). Adds preserveUnsupportedAudio(ForBridge) plumbing through readManyFiles / processSingleFileContent so @audio attachments survive file resolution long enough to be transcribed, plus an IPv4-translated IPv6 (::ffff:0:h:h) case in the voice endpoint's private-network check.

The overall shape is good: a single service with one result type, fail-closed replacement text on every failure path, explicit egress disclosure, and an untrusted-transcript envelope. The refactor genuinely removes duplication rather than adding a parallel code path.


🔴 Blocking: ACP @image paths regress for image-capable models

Session.ts, the text @-path collector:

const bridgeCanRead =
  (preserveUnsupportedImageForBridge && mimeType?.startsWith('image/')) ||
  (preserveUnsupportedAudioForBridge && mimeType?.startsWith('audio/'));
if (bridgeCanRead && /* workspace + ignore checks */) {
  textPathSpecsToRead.set(canonicalPath, pathSpec);
}

The previous condition was just getSpecificMimeType(canonicalPath)?.startsWith('image/') — unconditional. The new preserveUnsupportedImageForBridge && conjunct changes image behavior, not just audio:

shouldRunVisionBridge(config) is modalities.image !== true && getDefaultVisionBridgeModel() !== undefined, so it is false exactly when the primary model does support images. Result:

  • Primary model supports images + ACP prompt look at @/abs/path/shot.png → the path is never added to textPathSpecsToRead, readManyFiles is never called for it, and the image is silently dropped. Before this PR it was read and sent inline.
  • No vision-bridge model configured + text-only primary → previously the user got the explicit does not support image input note from processSingleFileContent; now the mention is silently ignored.

Suggested fix — keep images unconditional, gate only audio:

const bridgeCanRead =
  mimeType?.startsWith('image/') === true ||
  (preserveUnsupportedAudioForBridge && mimeType?.startsWith('audio/') === true);

The same conjunct was added to the re-validation loop below (textPathSpecsToRead.has(textPath) && !(...)); once the collector is fixed, that branch reduces to the original image/audio mime recheck.

Every existing ACP @-path test sets getEffectiveInputModalities to {} and a getDefaultVisionBridgeModel, so this case is uncovered — CI is green but the regression is real. Worth adding a test: image-capable primary + @image.pngreadManyFiles called and an inlineData part reaches the model.


🟠 Worth addressing

1. No per-turn cap, and transcription is fully sequential.
runAudioBridge loops over every audio part with await, with no equivalent of VISION_BRIDGE_MAX_IMAGES and no cap on transcript length (the vision bridge caps at BRIDGE_MAX_OUTPUT_TOKENS = 2048). A prompt with 20 @ audio files means 20 serialized uploads of up to 10 MB each, each with its own INFERENCE_TIMEOUT_MS, and 20 unbounded transcripts spliced into the prompt. Consider a MAX_AUDIO_PARTS_PER_TURN constant (with the surplus replaced by unavailableBlock('too many audio attachments')) and a transcript length clamp.

2. Transport gating is inconsistent between the two exported helpers.
shouldPreserveUnsupportedAudioForBridge requires resolveVoiceTransport(voiceModel) === 'qwen-asr-chat', but runAudioBridge only checks that some voice model is set. With a realtime model configured (qwen3-asr-flash-realtime):

  • @ path → audio dropped at file-read time with the generic “model doesn't support audio input” message; the user is never told their voice model can't batch-transcribe.
  • ACP direct audio block → transcribeVoiceAudio throws requires streaming transcription, and the user sees the generic transcription was unavailable.

Reusing one guard inside runAudioBridge and emitting a specific reason (the configured voice model does not support batch transcription) would make both paths honest.

3. The failure notice is circular.
When nothing was uploaded, formatAudioBridgeNotice falls through to
Audio bridge could not transcribe ${audioCount} audio file(s): ${error} — and error is set to `${failedCount} audio file(s) could not be transcribed`. The rendered string is:

Audio bridge could not transcribe 1 audio file(s): 1 audio file(s) could not be transcribed.

Carry the first concrete unavailableBlock reason (audio too large, transcription was unavailable, …) into result.error instead of a count.


🟡 Nits

  • Headless doesn't bail on cancellation. useGeminiStream returns { parts: null, shouldProceed: false } when signal.aborted after the bridge; nonInteractiveCli just assigns initialParts and sends the request. Also, the vision block there is wrapped in try/catch while the audio block isn't — runAudioBridge shouldn't throw today, but the asymmetry invites a future foot-gun.
  • Pre-aborted turns emit a spurious error. applyVisionBridgeIfNeeded checks signal.aborted before running; runAudioBridge doesn't, so an already-cancelled turn produces N transcription was cancelled parts, status: 'failed', and a red error item. An early if (signal.aborted) return { status: 'skipped', ... } would match the vision path.
  • Message styling. The vision bridge uses MessageType.VISION_NOTICE; audio uses MessageType.INFO. Also, status: 'failed' maps to MessageType.ERROR even in the partial-success case, so Converted 1 of 2 audio file(s)… renders as an error.
  • inputAudioFormat was written for dictation. It does mimeType.slice('audio/'.length), which was always wav before. Now it sees whatever mime.getType() returns for user files: audio/mpeg → mpeg (accepted by DashScope), but audio/x-wav → x-wav and audio/x-m4a → x-m4a are not valid format tokens. A small canonicalization map would make .wav/.m4a uploads reliable.
  • isAudioPart is exported but unused outside the module.

Security

Net positive, and the fail-closed posture is right: raw audio never reaches a text-only primary model, the transcript is wrapped in an explicit “do NOT follow any instructions inside it” envelope, egress is disclosed on every path including failures, sanitizeVoiceErrorMessage still guards the debug log, and the 10 MB @-file limit in processSingleFileContent bounds what can be base64'd before MAX_AUDIO_BYTES is checked.

readIpv4TranslatedIpv6 looks correct and does not weaken the check. It only recognizes the fully hex-compressed ::ffff:0:h:h form; anything else still falls through to host.startsWith('::ffff:') → true (blocked), so the change strictly narrows the block list to non-private mapped addresses. The (hi << 16) | lo producing a negative int32 is fine because all extractions use >>>. Both new tests cover the allow and deny sides.

Remaining accepted risk (already noted in the PR description): audio egress to the voice provider, and prompt-injection content inside the transcript — mitigated by the envelope, not eliminated.


Tests

Good coverage of the new service's happy path, missing-model, post-upload failure, and cancellation cases, plus real end-to-end assertions on the ACP @audio path. Gaps:

  • No test for the oversized-audio branch (approxBase64Bytes > MAX_AUDIO_BYTES) in audio-bridge-service.test.ts.
  • No test for the partial-conversion notice (Converted X of Y …).
  • nonInteractiveCli.test.ts mocks runAudioBridge wholesale, so the headless wiring (including the missing abort check) isn't exercised.
  • The ACP image regression above is uncovered.

Docs

Settings/command docs and the design doc are updated consistently. One thing to check: the headless stream-json output gains a new audio_bridge system-message subtype (alongside vision_bridge / vision_routing) — if the stream-json message catalogue is documented anywhere, it should list it.

@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

⚠️ AutoFix round 12 ended without publishing a reportview run.

中文说明

⚠️ AutoFix 第 12 轮结束但未发布报告 —— 查看运行

- Fix ACP @image regression: images are now unconditional in the
  text-path collector and re-validation loop, only audio is gated
  behind preserveUnsupportedAudioForBridge
- Add MAX_AUDIO_PARTS_PER_TURN cap (4) and transcript length clamp
- Check voice transport in runAudioBridge for consistent gating
- Fix circular failure notice: report first concrete reason
- Add early signal.aborted return matching the vision bridge
- Bail on cancellation in headless after audio bridge
- Use INFO (not ERROR) for partial-success bridge notices
- Canonicalize non-standard audio MIME subtypes (x-wav, x-m4a, etc.)
- Unexport isAudioPart (unused outside the module)
- Add tests: image-capable ACP, oversized audio, partial conversion,
  pre-aborted, transport gating, per-turn cap, concrete error reason
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Review feedback addressed

🔴 Blocking: ACP @image paths regress for image-capable models

Fixed. The text-path collector and re-validation loop in Session.ts now treat images unconditionally (mimeType?.startsWith('image/') === true), matching the pre-PR behavior. Only audio is gated behind preserveUnsupportedAudioForBridge. Added a regression test: image-capable primary model + @image.pngreadManyFiles called and inlineData reaches the model.

🟠-1: No per-turn cap, and transcription is fully sequential

Fixed. Added MAX_AUDIO_PARTS_PER_TURN = 4 (matching the vision bridge's VISION_BRIDGE_MAX_IMAGES). Surplus audio parts are replaced with unavailableBlock('too many audio attachments'). Added MAX_TRANSCRIPT_CHARS = 10_000 clamp on each transcript. Sequential transcription is kept as-is — parallelizing uploads would complicate abort/egress tracking for marginal gain at ≤4 parts.

🟠-2: Transport gating is inconsistent between the two exported helpers

Fixed. runAudioBridge now checks resolveVoiceTransport(voiceModel) !== 'qwen-asr-chat' and returns a specific error: "the configured voice model does not support batch transcription". Both the @-path and ACP-direct-audio paths now produce the same honest message.

🟠-3: The failure notice is circular

Fixed. result.error now carries the first concrete unavailableBlock reason (e.g. "audio too large", "transcription was unavailable") via a firstFailureReason tracker, instead of the count-based string that produced the circular "could not transcribe 1 audio file(s): 1 audio file(s) could not be transcribed" message.

🟡-1: Headless doesn't bail on cancellation

Partially fixed. Added if (abortController.signal.aborted) return 1; after the audio bridge in nonInteractiveCli.ts, matching the useGeminiStream pattern. Declined the try/catch asymmetry part: runAudioBridge catches all transcription errors internally and cannot throw, so wrapping it in try/catch would be defense for an impossible case.

🟡-2: Pre-aborted turns emit a spurious error

Fixed. runAudioBridge now returns { status: 'skipped' } with cancellation notes when signal.aborted is true at entry, matching the vision bridge's early-exit pattern. No transcription is attempted and no error item is emitted.

🟡-3: Message styling

Partially fixed. Partial-success notices (convertedCount > 0 with status: 'failed') now use MessageType.INFO instead of MessageType.ERROR in useGeminiStream.ts. Only total failure (convertedCount === 0) renders as an error. Declined adding a new AUDIO_NOTICE message type — INFO is appropriate and adding a type for one call site is out of scope.

🟡-4: inputAudioFormat was written for dictation

Fixed. Added AUDIO_FORMAT_ALIASES map in voice-transcriber.ts canonicalizing x-wavwav, x-m4am4a, x-aacaac, x-flacflac, x-oggogg, x-mpegmp3.

🟡-5: isAudioPart is exported but unused outside the module

Fixed. Removed the export keyword.

Test gaps

Addressed. Added 7 new tests to audio-bridge-service.test.ts (oversized audio, partial-conversion notice, pre-aborted signal, transport gating, per-turn cap, concrete error reason, batch model rejection) and 1 regression test to Session.test.ts (image-capable primary + @image).

Docs: stream-json audio_bridge subtype

Noted. The audio_bridge system-message subtype is emitted alongside vision_bridge / vision_routing. If the stream-json message catalogue is documented, it should list it. No doc file was found that enumerates these subtypes, so no change was made.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npx eslint (7 changed source files) — passed, no errors
  • vitest run src/services/audio-bridge-service.test.ts — 12 passed
  • vitest run src/acp-integration/session/Session.test.ts — 486 passed
  • vitest run src/nonInteractiveCli.test.ts — 102 passed, 1 skipped
  • vitest run src/ui/hooks/useGeminiStream.test.tsx — 184 passed
  • vitest run src/ui/hooks/atCommandProcessor.test.ts — 72 passed
  • vitest run src/ui/voice/voice-transcriber.test.ts — 42 passed
  • vitest run src/utils/fileUtils.test.ts src/utils/readManyFiles.test.ts (core) — 219 passed
中文说明

已处理的审查反馈

🔴 阻塞:ACP @image 路径在支持图像的模型上回归

已修复。 Session.ts 中的文本路径收集器和重新验证循环现在无条件处理图像(mimeType?.startsWith('image/') === true),与 PR 前的行为一致。只有音频受 preserveUnsupportedAudioForBridge 门控。新增了回归测试:支持图像的主模型 + @image.png → 调用 readManyFilesinlineData 到达模型。

🟠-1:没有每轮上限,转录完全串行

已修复。 新增 MAX_AUDIO_PARTS_PER_TURN = 4(与视觉桥接的 VISION_BRIDGE_MAX_IMAGES 一致)。超出的音频部分替换为 unavailableBlock('too many audio attachments')。新增 MAX_TRANSCRIPT_CHARS = 10_000 对每段转录文本进行截断。串行转录保持不变——并行上传会使中止/出口跟踪复杂化,且 ≤4 个部分的收益有限。

🟠-2:两个导出辅助函数之间的传输门控不一致

已修复。 runAudioBridge 现在检查 resolveVoiceTransport(voiceModel) !== 'qwen-asr-chat' 并返回具体错误:"the configured voice model does not support batch transcription"。@ 路径和 ACP 直接音频路径现在产生相同的诚实消息。

🟠-3:失败通知循环引用

已修复。 result.error 现在通过 firstFailureReason 跟踪器携带第一个具体的 unavailableBlock 原因(如 "audio too large"、"transcription was unavailable"),而不是产生循环的 "could not transcribe 1 audio file(s): 1 audio file(s) could not be transcribed" 消息的计数字符串。

🟡-1:无头模式不在取消时退出

部分修复。nonInteractiveCli.ts 的音频桥接后添加了 if (abortController.signal.aborted) return 1;,与 useGeminiStream 模式一致。拒绝了 try/catch 不对称部分:runAudioBridge 内部捕获所有转录错误且不会抛出异常,因此用 try/catch 包裹它是对不可能情况的防御。

🟡-2:预中止的轮次发出虚假错误

已修复。 runAudioBridge 现在在入口处 signal.aborted 为 true 时返回 { status: 'skipped' } 并附带取消通知,与视觉桥接的提前退出模式一致。不会尝试转录,也不会发出错误项。

🟡-3:消息样式

部分修复。 部分成功通知(convertedCount > 0status: 'failed')在 useGeminiStream.ts 中现在使用 MessageType.INFO 而非 MessageType.ERROR。只有完全失败(convertedCount === 0)才渲染为错误。拒绝添加新的 AUDIO_NOTICE 消息类型——INFO 是合适的,为一个调用点添加类型超出范围。

🟡-4:inputAudioFormat 是为听写编写的

已修复。voice-transcriber.ts 中添加了 AUDIO_FORMAT_ALIASES 映射,将 x-wavwavx-m4am4ax-aacaacx-flacflacx-oggoggx-mpegmp3 进行规范化。

🟡-5:isAudioPart 已导出但在模块外未使用

已修复。 移除了 export 关键字。

测试缺口

已补充。audio-bridge-service.test.ts 中新增 7 个测试(超大音频、部分转换通知、预中止信号、传输门控、每轮上限、具体错误原因、批量模型拒绝),在 Session.test.ts 中新增 1 个回归测试(支持图像的主模型 + @image)。

文档:stream-json audio_bridge 子类型

已记录。 audio_bridge 系统消息子类型与 vision_bridge / vision_routing 一起发出。如果 stream-json 消息目录有文档,应该列出它。未找到枚举这些子类型的文档文件,因此未做更改。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npx eslint(7 个修改的源文件)— 通过,无错误
  • vitest run src/services/audio-bridge-service.test.ts — 12 通过
  • vitest run src/acp-integration/session/Session.test.ts — 486 通过
  • vitest run src/nonInteractiveCli.test.ts — 102 通过,1 跳过
  • vitest run src/ui/hooks/useGeminiStream.test.tsx — 184 通过
  • vitest run src/ui/hooks/atCommandProcessor.test.ts — 72 通过
  • vitest run src/ui/voice/voice-transcriber.test.ts — 42 通过
  • vitest run src/utils/fileUtils.test.ts src/utils/readManyFiles.test.ts(core)— 219 通过

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@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: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment thread packages/cli/src/services/voice-transcriber.ts
Comment thread packages/cli/src/services/audio-bridge-service.test.ts
Comment thread packages/cli/src/nonInteractiveCli.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No action required

All four automated-reviewer suggestions were already addressed in commit 31489f60e ("fix(cli): harden audio bridge contracts"), which is the current HEAD of this branch. Each fix was verified against the code and confirmed by running the relevant test suites:

Finding Status Evidence
AUDIO_FORMAT_ALIASES missing mpegmp3 + no test coverage ✅ Fixed mpeg: 'mp3' present in the alias map; parameterized format tests cover audio/x-wav, audio/mpeg, audio/x-mpeg, audio/x-m4a, audio/x-aac, audio/x-flac, audio/x-ogg
audio-bridge-service.test.ts doesn't pin internal contracts ✅ Fixed Tests now assert base64→Uint8Array decode, MIME forwarding, AbortSignal propagation, post-await cancellation discard, empty transcripts, truncation, notice fallback, thrown cancellation, and first-failure ordering with distinct reasons (17 tests pass)
nonInteractiveCli.test.ts doesn't test headless bridge wiring ✅ Fixed Tests assert preserveUnsupportedAudioForBridge reaches handleAtCommand, bridge is skipped under an inline model override, and exit code 1 without sendMessageStream when aborted post-bridge (104 tests pass)
useGeminiStream.test.tsx doesn't test interactive bridge wiring ✅ Fixed Tests pin preserveUnsupportedAudioForBridge at both call sites, skip audio egress under inline model override, discard post-await output when aborted, and emit MessageType.ERROR on total failure (187 tests pass)
中文说明

无需操作

自动化审查器的四条建议均已在提交 31489f60e("fix(cli): harden audio bridge contracts")中解决,该提交是本分支的当前 HEAD。每项修复均已对照代码核实,并通过运行相关测试套件确认:

发现 状态 证据
AUDIO_FORMAT_ALIASES 缺少 mpegmp3 且无测试覆盖 ✅ 已修复 别名映射中已存在 mpeg: 'mp3';参数化格式测试覆盖了 audio/x-wavaudio/mpegaudio/x-mpegaudio/x-m4aaudio/x-aacaudio/x-flacaudio/x-ogg
audio-bridge-service.test.ts 未钉住内部契约 ✅ 已修复 测试现在断言 base64→Uint8Array 解码、MIME 转发、AbortSignal 传播、await 后取消丢弃、空转录、截断、通知兜底、抛出的取消、以及使用不同原因的首次失败排序(17 个测试通过)
nonInteractiveCli.test.ts 未测试 headless bridge 接线 ✅ 已修复 测试断言 preserveUnsupportedAudioForBridge 到达 handleAtCommand、在内联模型覆盖下跳过 bridge、以及 bridge 后中止时返回退出码 1 且不调用 sendMessageStream(104 个测试通过)
useGeminiStream.test.tsx 未测试交互式 bridge 接线 ✅ 已修复 测试在两个调用点钉住 preserveUnsupportedAudioForBridge、在内联模型覆盖下跳过音频出站、中止时丢弃 await 后的输出、以及在完全失败时发出 MessageType.ERROR(187 个测试通过)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

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

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated

@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: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/services/audio-bridge-service.ts Outdated
Comment thread packages/cli/src/services/audio-bridge-service.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No code changes required this round

All four inline findings from the automated reviewer were already addressed by @DragonnZhang in commit 2bf04b2d6 (current HEAD):

  1. Critical — abort bypasses routeAbort (rc:3698536367): Fixed. The post-audio abort now calls await routeAbort() instead of return 1. Regression tests assert exit 130 on SIGINT, exit 55 on budget abort, and no prompt sent in either case.
  2. Suggestion — mid-turn queued-audio untested (rc:3698536378): Fixed. A mid-turn test now verifies preserveUnsupportedAudioForBridge: true, bridge invocation, transcript delivery, and absence of inlineData.
  3. Suggestion — convertedCount === 0 guard untested (rc:3698536381): Fixed. A partial-success test (1-of-2 converted) now asserts MessageType.INFO.
  4. Suggestion — cancellation renders ERROR (rc:3698536384): Fixed. A failed bridge result produced after cancellation is now informational (MessageType.INFO) rather than an error, with egress still disclosed.

The remaining CHANGES_REQUESTED review flagged that "Integration Tests (CLI, No Sandbox)" were skipped in CI and not run locally. These have now been run locally after npm run bundle:

  • 179 passed, 18 skipped, 2 failed (36 test files)
  • Both failures are in test files and code areas not touched by this PR:
    • cli/qwen-config-dir.test.ts — "CLI functions normally when QWEN_HOME is not set" (timing-sensitive, retried 3×)
    • cli/qwen-serve-streaming.test.ts — "daemon Todo Stop Guard replay" (30 s timeout matcher expired)
  • git diff origin/main...HEAD --name-only confirms zero overlap with either failing test file or their underlying code paths.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest packages/cli (6 touched test files) — 918 passed, 1 skipped
  • vitest packages/core (2 touched test files) — 219 passed
  • npm run bundle — passed
  • npm run test:integration:cli:sandbox:none — 179 passed, 18 skipped, 2 failed (both pre-existing, unrelated to PR)
中文说明

本轮无需代码变更

自动化审查器提出的四个行内发现已全部由 @DragonnZhang 在提交 2bf04b2d6(当前 HEAD)中修复:

  1. Critical — 中止绕过 routeAbort(rc:3698536367):已修复。音频桥接后的中止现在调用 await routeAbort() 而非 return 1。回归测试断言 SIGINT 时退出码为 130、预算中止时退出码为 55,且两种情况下均不发送提示。
  2. Suggestion — 轮中排队音频未测试(rc:3698536378):已修复。新增轮中测试验证 preserveUnsupportedAudioForBridge: true、桥接调用、转录文本传递以及不含 inlineData
  3. Suggestion — convertedCount === 0 守卫未测试(rc:3698536381):已修复。新增部分成功测试(2 个中转换 1 个)断言 MessageType.INFO
  4. Suggestion — 取消时渲染 ERROR(rc:3698536384):已修复。取消后产生的失败桥接结果现在为信息性(MessageType.INFO)而非错误,同时仍披露出站请求。

剩余的 CHANGES_REQUESTED 审查指出 "Integration Tests (CLI, No Sandbox)" 在 CI 中被跳过且未在本地运行。现已在 npm run bundle 后于本地运行:

  • 179 通过,18 跳过,2 失败(36 个测试文件)
  • 两个失败均位于本 PR 未触及的测试文件和代码区域:
    • cli/qwen-config-dir.test.ts — "CLI functions normally when QWEN_HOME is not set"(时间敏感,重试 3 次)
    • cli/qwen-serve-streaming.test.ts — "daemon Todo Stop Guard replay"(30 秒超时匹配器过期)
  • git diff origin/main...HEAD --name-only 确认与任一失败测试文件或其底层代码路径零重叠。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest packages/cli(6 个触及的测试文件)— 918 通过,1 跳过
  • vitest packages/core(2 个触及的测试文件)— 219 通过
  • npm run bundle — 通过
  • npm run test:integration:cli:sandbox:none — 179 通过,18 跳过,2 失败(均为已有问题,与 PR 无关)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Review: feat(cli): add audio bridge for attachments

Reviewed at b3aeb24. I read the PR head ref statically (my checkout is on another branch with local modifications), so I did not execute the test suite — everything below comes from reading the code and its neighbours (vision-bridge-service.ts, voice-transcriber.ts, fileUtils.ts).

Overview

Extracts the ACP-only audio→text conversion into a shared packages/cli/src/services/audio-bridge-service.ts and wires it into three entry points (ACP Session, interactive useGeminiStream, headless nonInteractiveCli), plus a preserveUnsupportedAudio flag threaded down through readManyFilesprocessSingleFileContent so @audio attachments survive long enough to be transcribed. Also bundles an unrelated DNS-safety tweak in voice-transcriber.ts.

What's good

  • The extraction is clean. ACP behaviour is preserved and the renamed tests line up with the moved code; no logic was silently dropped in the move.
  • It fails closed on every branch I traced. No voice model, non-batch transport, oversized input, empty transcript, request failure, cancellation — all produce a text note. I could not find a path where raw inlineData audio reaches a text-only primary model.
  • The modality gate is consistent. getEffectiveInputModalities() is literally getContentGeneratorConfig()?.modalities ?? {} (config.ts:3922), which is the same source processSingleFileContent reads. The preserve-gate and the read-gate cannot disagree.
  • Untrusted-transcript framing mirrors the vision bridge convention, and sanitizeVoiceErrorMessage is applied before the debug log.
  • Service-level test coverage is genuinely thorough — per-turn cap, partial failure, first-failure-reason ordering, abort mid-loop and abort-at-entry are all covered.

Worth addressing before merge

1. voiceModel is being reused as a consent surface it wasn't granted for.

Today voiceModel means "the model my push-to-talk dictation goes to" — the user presses a key each time, which is per-use consent. After this PR, the same setting silently authorises automatic upload of any @-attached audio file on prompt submit, with the disclosure emitted after the bytes have left. Note the contrast with the vision bridge, which gates on a purpose-specific getDefaultVisionBridgeModel() rather than borrowing an existing setting.

The settings-doc update is good, but existing users who configured voiceModel for dictation get new egress behaviour with no upgrade note. Consider a distinct setting (audioBridgeModel, defaulting to voiceModel), or a first-use confirmation, or at minimum a release-note entry.

2. status: 'skipped' is overloaded, and the two meanings need different caller handling.

runAudioBridge returns skipped both when there is nothing to do (parts unchanged) and when the signal was already aborted at entry — in the second case parts are replaced with cancellation notes while egressCount stays 0. So result.status !== 'skipped' || result.egressCount > 0 is false, no notice fires, and the caller must separately check signal.aborted to avoid forwarding mutated parts. All three current callers do, but that's an invariant held by convention.

VisionBridgeResult solves exactly this with an explicit applied: boolean (and its doc comment calls out the same "or the turn was cancelled" ambiguity). Suggest either an applied flag or a distinct 'cancelled' status.

3. runAudioBridge isn't wrapped in try/catch at any call site — runVisionBridge is.

nonInteractiveCli.ts and Session.ts both wrap runVisionBridge and degrade to text-only parts on throw. The audio equivalent is unguarded at both. It's total today, but any future throw inside the service (or a malformed settings object reaching readVoiceModel) takes down the whole headless run / ACP turn instead of degrading. Cheap to make symmetric.


Medium

4. The per-turn cap is invisible to the user. With MAX_AUDIO_PARTS_PER_TURN = 4 and 6 attachments, formatAudioBridgeNotice hits the partial-egress branch and prints "Converted 4 of 6 audio file(s)… 4 audio file(s) were sent to that model."result.error === 'too many audio attachments' is dropped on the floor, so the user is never told two files were refused by a cap rather than by a failure. The constant is also undocumented.

5. The egress notice omits the endpoint host. formatVisionBridgeNotice renders ${modelName} (${result.modelEndpoint}); the audio notice names only the model id. For a disclosure whose entire job is "where did my audio go", the host matters — particularly with custom baseUrl providers, where the model id says nothing about the destination.

6. Failure reasons are genericised past usefulness. Every transcription error collapses to transcription was unavailable. sanitizeVoiceErrorMessage exists precisely to make provider errors safe to display, but it's only fed to debugLogger. Concretely: processSingleFileContent caps files at 9.9 MB, so a ~9 MB MP3 sails past the bridge's 10 MB gate and then fails server-side on qwen3-asr's duration limit — the user sees "transcription was unavailable" with no hint that a shorter clip would work.

7. Transcript truncation has no marker. transcriptBlock appends a bare at 10 000 chars inside the untrusted block. The primary model can't distinguish that from an ellipsis the speaker actually paused on. Prefer an explicit [transcript truncated at 10000 characters].


Low / nits

8. The DNS change is out of scope and asymmetric. readIpv4TranslatedIpv6 has nothing to do with the audio bridge and would be easier to review as its own PR. On the substance: it un-blocks ::ffff:0:X:Y (RFC 2765 IPv4-translated, ::ffff:0:0:0/96) but the far more common compressed IPv4-mapped form ::ffff:X:Y (e.g. ::ffff:c612:40, ::ffff:0:0/96) still falls through to the blanket host.startsWith('::ffff:') → true. That's fail-closed, so not a hole, but if proxy resolvers are returning translated answers it's worth saying why the mapped form doesn't need the same treatment. Please also add a regression test pinning that mapped literals stay blocked — the new tests only cover the translated form, so nothing guards the blanket rule.

9. ACP still can't read @audio for an audio-capable model. bridgeCanRead admits audio only when preserveUnsupportedAudioForBridge is true, which is false when the primary model already supports audio — so @recording.wav over ACP is silently ignored on exactly the models that could consume it natively. Pre-existing, but this PR rewrites that condition and the new sibling test (reads image @ paths for an image-capable primary model) covers the image analogue while the audio one is left uncovered. The gate probably wants image || (audio && (modelSupportsAudio || bridgeAvailable)).

10. preserveUnsupportedAudioForBridge ignores an active inline model override. It's passed to handleAtCommand unconditionally, while applyBridgeConversionsIfNeeded / the headless path skip bridging when an override is active. Net: /model <text-only-model> … @clip.wav now forwards raw audio inlineData to a text-only model, where it previously became a placeholder. The image path has the same shape today, so low priority — but && !inlineModelOverrideActiveRef.current would close it.

11. AUDIO_FORMAT_ALIASES gaps. Missing audio/wave, audio/vnd.wave, audio/x-ms-wma. audio/x-ogg isn't a registered type. And DashScope already accepts mpeg, so that alias is cosmetic.

12. Message type inconsistency. Audio notices use MessageType.INFO/ERROR; vision uses the dedicated MessageType.VISION_NOTICE. Different styling and history treatment for two notices that mean the same class of thing.

13. Transcription is strictly sequential — up to 4 × 10 MB uploads back-to-back on the submit path with no progress indicator. Bounded concurrency, or at least a "transcribing…" pending state, would help.

14. No <think> stripping on ASR output. vision-bridge-service.ts runs stripThinkTags on bridge-model output; the audio bridge trims only. Low risk since resolveVoiceTransport pins the id to qwen3-asr-flash*, but that id can point at an arbitrary baseUrl.


Test coverage

Strong on the service itself. Gaps I'd want filled: (a) the mapped-IPv6 blanket-block regression test from #8; (b) an audio-capable-model @audio ACP case mirroring the new image test; (c) a case asserting the headless run degrades rather than dies if runAudioBridge throws (currently unreachable, but that's the point of #3).

@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

Round 41 — both Criticals verified real and fixed (head ba56d83d7f)

Both findings target applyToolResultMediaGate, the gate this PR added in round 40 (R40-7). I re-verified each against e5b375f931 before changing code, without presumption either way. Both are genuine holes in that gate; together they meant the gate's exact failure mode — nested tool-result media exact-routed to a target that cannot consume it, then silently placeholder-substituted by route slimming — was still reachable through two doors. Fixed in ba56d83d7f with a negative-control regression test for each.


R41-1 — Ctrl+Y Retry branch never consults the gate (useGeminiStream.ts, Retry arm of submitQuery)

Verified real. The Retry arm prepared the stored payload with only the top-level hasAudioParts(query) || hasImageParts(query) check — both blind to media nested in functionResponse.parts (the documented carrier for tool-result media; see detectNestedFunctionResponseMedia's own doc comment). The media-bearing arm ran applyBridgeConversionsIfNeeded (top-level media only) and the plain arm passed the payload through untouched; neither arm ran applyToolResultMediaGate. Meanwhile a stored retry payload can carry nested tool-result media (lastPromptRef stores finalQueryToSend for a failed ToolResult continuation), and a full-turn vision selector survives Retry (Retry only drops inline overrides), so the retry send is still exact-routed via the NUL-terminated selector (sendOptions.modelOverride passes it through unchanged). Core then resolves the route (geminiChat.sendMessageStream, model.slice(0, -1) + failClosed) and getRequestHistoryForRouteslimCompactionInput walks into functionResponse.parts and replaces unsupported nested media with [document: …] placeholders — silently.

Fix. Both Retry arms now run the prepared payload through applyToolResultMediaGate: chained after applyBridgeConversionsIfNeeded in the media-bearing arm (only when it proceeded), applied directly in the plain arm. The gate is idempotent, so already-gated or marker payloads pass through unchanged.

Regression test (negative control verified). useGeminiStream.test.tsx — "gates nested tool-result media re-submitted through the Retry branch": under an installed full-turn selector, a Retry submission carrying nested audio/wav in functionResponse.parts is sent with the bytes removed, the was not sent marker appended, and a visible ERROR item. On the unpatched code the sent payload contains the raw {"mimeType":"audio/wav","data":"UklGRg=="} (assertion fails), matching the reported probe.


R41-2 — gate early-returns on the NUL marker, which full-turn selectors always carry (useGeminiStream.ts, applyToolResultMediaGate)

Verified real. The guard contained activeOverride.endsWith('\0') → return query, on the rationale that a full-turn selector "owns the whole turn". But full-turn vision selectors are NUL-terminated by construction (getFullTurnVisionModelSelector returns ${selector}\0) and are installed with no capability probe — the install branch only checks agentCapable and that the establishing turn has no audio; mediaRoutedOverrideRef is set to the same selector, so the mediaRoutedOverrideRef.current !== activeOverride clause never caught it either. A tool-result continuation owned by such a selector therefore skipped the gate entirely; the send is exact-routed (sendOptions.modelOverride keeps the NUL selector; core resolves it via resolveForModel(model.slice(0, -1), { failClosed: true })) and route slimming silently placeholder-substitutes any nested modality the vision model doesn't support — audio first and foremost, since audio is precisely the modality the install path never validates. This is exactly the failure mode the gate was added in R40-7 to prevent.

Fix. The NUL marker is no longer a bypass: the guard drops the endsWith('\0') clause and the gate resolves activeOverride.slice(0, -1) (mirroring the audio/image bridge capability checks and core's own exact-route resolution, including the id\0baseUrl selector form) with { failClosed: true }, then runs the same replaceNestedFunctionResponseMedia + visible ERROR path. Modalities the route supports still pass (e.g. nested images under a vision selector); unvalidated ones fail closed visibly.

Regression test (negative control verified). useGeminiStream.test.tsx — "fails closed a tool-result audio a full-turn vision selector cannot hear": after a full-turn selector install, a ToolResult continuation nesting audio/wav is sent on the same exact route with the bytes removed, the marker appended, an ERROR item, and resolveForModel called with the NUL-stripped id\0baseUrl selector. On the unpatched code the payload carries the raw audio with no gate error item (assertion fails), matching the reported probe.

Isolation check. With only the R41-2 fix applied, the R41-1 test still fails (the Retry arm never invokes the gate even when the gate itself is correct) — confirming the two findings are independent defects and both fixes are required.


Verification (under the shared build lock)

  • useGeminiStream.test.tsx: 264/264 (262 prior + 2 new, each negative-control-verified against the unpatched code)
  • cli tsc --noEmit (2560 MB heap): 0 errors at ba56d83d7f
  • ESLint + Prettier clean on both changed files
  • origin/main re-fetched before push: 0 commits ahead, nothing to merge
  • Pushed e5b375f931..ba56d83d7f to dragon/audio-bridge (fast-forward, no force-push)

No other author-facing items in the full issue-comment history (71 comments, single page; last is the round-40 reply). The pending review under the author's name (550 drafts) remains untouched, as before.

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 42, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5694 — [review] history exactly-once claim asserted as at-least-once (toBeGreaterThan(0))
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6791 — [probe] rewritten bridge-failure test drops the text part; fail-closed text survival unguarded (mutation ships green)
  • packages/core/src/utils/fileUtils.ts:1294 — [probe] no exactly-10-MiB read-time boundary test; <= to < mutation ships green and wrongly rejects exact-cap attachments
  • packages/cli/src/services/audio-bridge-service.ts:215 — [review] MAX_AUDIO_PARTS_PER_TURN enforced per call, not per turn, on drain/steer paths
  • packages/cli/src/acp-integration/session/Session.test.ts:8248 — [review] cancel-during-bridge tests never assert the user's text part survives the fail-closed abort path
  • packages/cli/src/acp-integration/session/Session.test.ts:8642 — [review] mixed audio+image test never asserts the image part reaches the vision bridge
  • packages/cli/src/nonInteractiveCli.test.ts:1852 — [review] media-removal tests never pin the exact-route to bare selector flip on modelOverride
  • packages/cli/src/nonInteractiveCli.ts:1435 — [review] override resolution failure silently drops the override with no user-visible notice
  • packages/cli/src/services/audio-bridge-service.ts:96 — [review] notice attributes all failed files to the first failure reason (mixed reasons misreported)
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:1588 — [review] cancellation test pins INFO type only, not the audio egress disclosure text
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5486 — [review] drain-recovery tests never re-check the re-queue channel after successful re-delivery
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6346 — [probe] reconcile test pins the audio half only; the image segment's fate unguarded (mutation ships green)
  • packages/core/src/core/client.test.ts:6796 — [probe] onSteerResolved notify-before-send ordering asserted order-blind (mutation ships green)

Convergence: round 42 posted 6 inline comment(s), 6 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 41; 4 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill overrides are stored bare (itemModelOverride = override, ~:2916) and sent bare (modelOverride: itemModelOverride, :2827); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1683-1688). Core resolves exact routes only for trailing-NUL selectors, so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. This round's diff still does not touch the drain-item/mid-loop path (no hasAudioParts/runAudioBridge/applyToolResultMediaGate anywhere in it). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 42 轮,非阻断)——已记录,本轮不要求修改:共 13 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 42 轮发布了 6 条行内评论,其中 6 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 41 轮已出过发现,本轮又有 4 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill overrides are stored bare (itemModelOverride = override, ~:2916) and sent bare (modelOverride: itemModelOverride, :2827); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1683-1688). Core resolves exact routes only for trailing-NUL selectors, so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. This round's diff still does not touch the drain-item/mid-loop path (no hasAudioParts/runAudioBridge/applyToolResultMediaGate anywhere in it). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
…s on abort

Round-42 fixes for six Criticals:

- R42-1: handleResolvedSteer no longer nulls the retry store when the
  Steer send is restored; it reinstates the outer turn's payload so the
  errored outer turn stays Ctrl+Y-retryable (exactly-once preserved).
- R42-2: payloadCarriesRoutedMedia counts nested functionResponse.parts
  media, so a Retry of a failed nested-media continuation keeps the
  trailing-NUL exact-route marker instead of sending the bare selector
  into session-modality slimming.
- R42-3: the image-only capability-probe failure clears the inline
  override (R33-2 invariant, mirroring the audio branch), guarded by
  !mediaRouted so an already-routed audio route is not wiped; the
  cleared-override state with no delivery path now fails closed visibly
  with a pristine capture for Retry.
- R42-4: both stop-loop drain branches re-check pendingSend.signal after
  the awaited continuation claim (which converts an abort to
  'unavailable') and persist the dequeued messages before settling
  cancelled, instead of letting #runStopContinuation's initial-send
  preservation no-op drop them.
- R42-5: the finalize loop's abort override only recomputes from the raw
  parts when the bridge conversion did not run, so completed transcripts
  are no longer discarded.
- R42-6: applyToolResultMediaGate returns a pristine capture alongside
  its substitution and the function-response/Retry branches surface it,
  so the retry store keeps the media-bearing payload instead of resending
  the marker forever.

Tests: 5 new regression tests (each negative-controlled against the
unpatched code), 1 updated steer-restore test, 2 pre-existing
expectations corrected to the fixed behavior.
@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

Round 42 — all 6 Criticals verified; 6/6 real, all fixed in 68c0295d3e (head 1911ef07fa merges origin/main)

Every thread was verified against ba56d83d7f with code reading plus probes. Four of the six attack round-36–41 mechanisms; all four were genuine gaps in those mechanisms.

R42-1 — boundary-drain retry-store clobber (useGeminiStream.ts) — confirmed. handleResolvedSteer overwrote lastPromptRef with only the steer's retryParts, and its onRestore nulled the store outright. When the Steer send fails before its history push lands, settleSteerInput fires restore()onRestore → store null, while the nested failure surfaces in the outer turn (lastPromptErroredRef = true, "Press Ctrl+Y to retry" hint) — retryLastPrompt then finds lastPrompt === null → "No failed request to retry.", exactly the witness. Before this PR the boundary drain never touched the store, so the errored turn stayed retryable.
Fix: capture the pre-supersede value and reinstate it under the existing identity guard (lastPromptRef.current === stored). Exactly-once semantics are preserved: restore still drops the steer's payload (the re-queue owns recovery), but the outer turn's payload is handed back. Negative control: pre-fix the retry is inert (1 send total); post-fix the outer payload re-sends exactly once and carries no steer content. The test that pinned the nulling (drops the stored client-steer retry payload…) is updated to assert the restored behavior.

R42-2 — Ctrl+Y Retry of a failed nested-media continuation loses the NUL exact-route marker (useGeminiStream.ts) — confirmed. Retry mints a fresh prompt_id and never re-stamps mediaRoutedPromptIdRef; payloadCarriesRoutedMedia only inspected top-level parts, so media nested in functionResponse.parts was invisible (the exact limitation detectNestedFunctionResponseMedia exists for). The retry sent the bare selector; verified in core: geminiChat.sendMessageStream resolves an exact route only for a NUL-terminated model — otherwise requestModalities falls back to the session modalities and getRequestHistoryForRouteslimCompactionInput dives into functionResponse.parts and placeholder-substitutes the nested media silently.
Fix: nested media now counts toward payloadCarriesRoutedMedia (the suggested fix). The comment above that flag already promised "a send still carrying raw media belongs to the route that put it there even if the prompt stamp moved" — the top-level has* checks simply didn't see nested media. The R40-7/R41 gate validates the nested modality against the route before this stamp can apply. Negative control: pre-fix the retry sends skill-model bare with the nested audio still in the payload; post-fix skill-model\0.

R42-3 — image-only probe failure never clears the inline override (useGeminiStream.ts) — confirmed, fixed with two refinements to the suggested clearModelOverride, each proven necessary by a test:

  • The core gap verified: the catch set modelOverrideResolutionFailed but left the refs set; the R33-2 clamp block's premise ("the images are now destined for the default session model") was false and the send carried the bare unresolvable selector (witness reproduced: send modelOverride = 'image-model').
  • (a) The clear is guarded by !mediaRouted: when audio already routed to the same override, an unconditional clear would wipe mediaRoutedOverrideRef/mediaRoutedPromptIdRef and strand the raw audio off its exact route. Regression test keeps a routed audio route intact when the image probe fails fails under the suggested unconditional clear (modelOverride becomes undefined instead of voice-model\0 — verified by temporarily applying the unconditional clear).
  • (b) The clear exposes a silent-loss state the old code accidentally avoided: override cleared + text-only session model + no vision bridge configured → raw images would be forwarded to the session route and placeholder-substituted silently. The R33-2 block now fails that state closed visibly: ERROR item with the honest reason + marker + pristine capture for Retry (mirrors the audio branch's fail-closed capture).
    Negative controls: image-only + image-capable session — pre-fix the send carries image-model, post-fix undefined with the image intact; no-delivery-path state — pre-fix a mislabeled "does not support images" marker plus the dead selector, post-fix the "could not be resolved" marker with a pristine retry. One pre-existing test pinned the pre-fix selector-retention (clamps images forwarded through an inline override on an audio-free turn) and is updated to the corrected expectation.

R42-4 — abort race against the awaited continuation claim (Session.ts) — confirmed. Chain verified: #claimTodoStopGuardContinuation converts an abort to 'unavailable' (abortSignal.aborted || !isRecord(response)), control falls through blockUntilOrdinaryPromptStarts()acceptMidTurnUserInput()#runStopContinuation(...), whose loop-top abort check calls preservePendingMessage — which begins if (initialSend) return; — so nothing is persisted while the drain already spliced the messages from the host queue. Silent, permanent loss.
Fix: the suggested re-check after the claim handling, applied to both drain branches (pre-hook and post-hook): pendingSend.signal.abortedtodoStopGuard.suspend() + #preserveUnsentMessageHistory({role:'user', parts: drained.parts}, true) + {stopReason:'cancelled'}. Negative control: pre-fix addHistory is never called with the dequeued message (matching the probe's addHistoryCalls=0); post-fix it is persisted for replay.

R42-5 — finalize loop's abort override discards completed transcripts (Session.ts) — confirmed. The override recomputed finalized from the pre-conversion resolved.parts, discarding #applyBridgeConversionsIfNeeded's result, whose own abort return deliberately keeps completed transcripts. Implemented the suggested "only when the conversion did not run" variant (conversionRan flag); the alternative "apply the substitution to the conversion result" would double-substitute — the conversion's abort return already fail-closes the parts.
The negative control needed exact choreography because #resolvePrompt bridges every structured message first: the failing probe must land in the audioChecked pass so note 1 stays raw into the finalize re-bridge (resolves #1#3 audio-capable, #4 rejects), with the cancellation landing in the re-bridge's second transcription. Pre-fix the persisted history loses the completed note-1 transcript-a; post-fix it is kept, with only the still-pending part becoming the cancellation marker.

R42-6 — resolved-but-unsupported gate substitution destroys nested media without a pristine capture (useGeminiStream.ts) — confirmed. The function-response branch had no preOverrideParts channel, so lastPromptRef stored the marker payload; Retry drops the one-shot inline override, the gate re-runs on the stored payload and finds no media left → the marker re-sends on every retry — the exact anti-pattern the top-level branches' preOverrideParts captures prevent.
Fix: the gate now returns { parts, preOverrideParts } (capturing its input whenever it substitutes), surfaced through prepareQueryForGemini's function-response branch and both Retry arms; in the media arm the retry's input payload takes precedence as the pristine capture since it still carries any top-level media alongside the nested bytes. Negative control: pre-fix the retry resends the marker (no image bytes); post-fix it carries screenshot attached + the nested inlineData image/png to the session model.

Verification (all under the build lock): useGeminiStream.test.tsx 269/269 (264 prior + 5 new; 1 pre-existing updated), Session.test.ts 701/701 (699 prior + 2 new), cli tsc --noEmit EXIT=0 (2560 MB heap; core untouched, no dist rebuild), ESLint clean on all four changed files, Prettier applied. The !mediaRouted guard was separately validated against the suggested unconditional clear (it regresses the routed-audio test).

Flake note: under 2-core contention, Session.test.ts intermittently hits afterEach hook timeouts (10 s) on unrelated prompt tests ("respects permission-request hook…", "keeps ambient Shell cwd fixed…") — the known signature from rounds 38/40; unaffected by this change, standalone re-runs green.

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): chunk 27: none — wait, per the brief I should write no such line when nothing was cut short. Nothing was cut short.; "agent reverse-audit (round 2)": none — the chunk (diff lines 5504-5899) was read in full in one untruncated read, and every follow-up check above completed.; "agent reverse-audit (round 2)": identifying the exact origin of all four resolveForModel calls asserted in 'rechecks earlier native audio after a later route resolution fails' (the test pass….

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 43, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/nonInteractiveCli.ts:1435 — [review] inline override silently dropped on resolution failure — no notice
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5978 — [review] exactly-once history asserted only with toBeGreaterThan(0)
  • packages/core/src/core/client.ts:4059 — [review] three of four steerRouteOverride continuation sites untested
  • packages/cli/src/ui/hooks/useGeminiStream.ts:3984 — [review] client-driven drains never stamp the route — media-free continuations lose the exact route
  • docs/design/2026-08-01-audio-bridge.md:34 — [review] design doc scope contradicts the nested tool-result gate
  • packages/cli/src/acp-integration/session/Session.test.ts:8944 — [review] mixed-media ACP test tolerates a raw-image leak (arrayContaining)
  • packages/cli/src/acp-integration/session/Session.test.ts:9512 — [review] no test pins preserveUnsupportedImageForBridge absence
  • packages/cli/src/acp-integration/session/Session.test.ts:14001 — [review] pinned probe counts lock in the double capability-probe
  • packages/cli/src/services/audio-bridge-service.test.ts:282 — [review] all-failed-after-egress notice drops the failure reason
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5986 — [review] bridge-succeeded exactly-once test never runs the bridge
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6413 — [review] re-bridge retry test lacks not.toContain('audio/wav')
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1898 — [review] gate catch mislabels resolution failure as 'does not support'
  • packages/cli/src/acp-integration/session/Session.test.ts:8447 — [review] oversized-audio tests juggle an env var that never fires
  • packages/cli/src/services/audio-bridge-service.ts:215 — [review] 'maximum N per turn' cap is per bridge invocation
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:1150 — [review] inline-override nested-media tests never pin the continuation route
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6640 — [review] recheck test never asserts the drained image survives
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6876 — [review] fallback-phase send pinned only by transcript substrings
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7260 — [review] no test pins user text surviving the vision fail-closed fallback

Convergence: round 43 posted 8 inline comment(s), 8 of them reported for the first time; the previous round posted 6 (6 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 42; 8 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare (:2612, :2915) and sent bare (:2499, :2827); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1683-1688). Core resolves exact routes only for trailing-NUL selectors (geminiChat.ts:2318), so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. The \0 guards at :2609/:2910 reject mid-item override changes against a stamped value but never stamp drain-item sends. witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at the reviewed commit this round. Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):chunk 27:none — wait, per the brief I should write no such line when nothing was cut short. Nothing was cut short."agent reverse-audit (round 2)"none — the chunk (diff lines 5504-5899) was read in full in one untruncated read, and every follow-up check above completed."agent reverse-audit (round 2)"identifying the exact origin of all four resolveForModel calls asserted in 'rechecks earlier native audio after a later route resolution fails' (the test pass…

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 43 轮,非阻断)——已记录,本轮不要求修改:共 18 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 43 轮发布了 8 条行内评论,其中 8 条是首次提出;上一轮发布了 6 条(其中 6 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 42 轮已出过发现,本轮又有 8 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare (:2612, :2915) and sent bare (:2499, :2827); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1683-1688). Core resolves exact routes only for trailing-NUL selectors (geminiChat.ts:2318), so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. The \0 guards at :2609/:2910 reject mid-item override changes against a stamped value but never stamp drain-item sends. witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at the reviewed commit this round. Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
…actly-once

Address the round-43 review findings (all verified real, each with a
negative-control regression test):

- R43-1: normalize MIME case in the nested tool-result media
  detector/replacer and in core route slimming (supportsMimeType /
  isNonImageMime). RFC 6838 types are case-insensitive; uppercase MCP
  MIME slipped past the gate and was placeholder-substituted even on
  capable routes.
- R43-6: detect fileData carriers nested in functionResponse.parts
  alongside inlineData, matching core's convertToFunctionResponse and
  slimming/microcompact media tests.
- R43-7: clamp accepted nested media in the tool-result gate with the
  QWEN_CODE_MAX_INLINE_MEDIA_BYTES ceiling, like every other routing
  path added by this PR.
- R43-2: capability-probe top-level images against media-routed
  (non-inline) overrides and fail closed / bridge instead of passing
  raw images into the exact route.
- R43-3: report drain routing only when a route survived the drain;
  a sticky mediaRouted with a cleared selector let core fall back to
  the stale pre-drain override.
- R43-4: prefer the drain's fully-pristine capture over the gate
  capture when both exist in the retry store.
- R43-8: add SteerInput.onAccept and invalidate the retry store
  symmetrically on accept, so an accepted steer cannot be
  Ctrl+Y-redelivered when a later continuation fails.
- R43-10: advance the prompt counter on Retry so a retry's minted
  prompt_id cannot be re-minted by a later Notification drain and
  exact-routed into the stale override.

Tests: useGeminiStream 277/277, core compactionInputSlimming+client
403/403, cli tsc clean; every fix negative-controlled.
@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

Round 43 — all 8 Criticals verified against the code; 8/8 real, all fixed in 5709c7dd3b (head 397a54c379 merges origin/main)

Every finding was re-derived from the source (not the review text), and each fix has a negative-control regression test that was confirmed to fail on the unpatched code with the reviewer's symptom.


R43-1 — case-sensitive MIME in the nested tool-result detector (real, fixed)
Verified: detectNestedFunctionResponseMedia/replaceNestedFunctionResponseMedia matched mime.startsWith('image/'/'audio/') case-sensitively while this PR's own top-level isAudioPart lowercases (audio-bridge-service.ts:51). The second hop of the same chain is also case-sensitive: the route's supportsMimeType in compactionInputSlimming — the reviewer's own write-up names it ("the route's case-sensitive supportsMimeType falls through to false"), and fixing only the CLI side would still lose AUDIO/WAV to placeholder substitution after the gate passes it. Fixed both ends: the nested detector/replacer now match case-insensitively (shared nestedPartMime/nestedPartCarriesMedia helpers), and supportsMimeType + isNonImageMime lowercase the MIME (RFC 6838 types are case-insensitive).
Negative control: polices UPPERCASE-MIME nested tool-result media case-insensitively (uppercase audio keeps the exact route + marker at send time; uppercase image fails closed) and core matches MIME types case-insensitively against the modalities; reverting either fix re-breaks its test.

R43-2 — top-level images never probed against non-inline media-routed overrides (real, fixed)
Verified: the image capability probe was gated on inlineModelOverrideActiveRef only, and the no-bridge fail-closed arm covered inline overrides only. A skill-tool override (installed via applyModelOverride(..., false)) that routed audio thus let a top-level image pass raw; the send-time stamp then exact-routed it and the route slimming placeholder-substituted it silently. Fix: the probe condition and the no-bridge arm now also cover mediaRoutedOverrideRef.current === modelOverrideRef.current; the resolution-failure clear guard was extended so an established route is not clobbered by a transient image-probe failure (!mediaRouted && mediaRoutedOverrideRef.current !== modelOverrideRef.current), with a new branch failing the images closed (or handing them to a configured vision bridge) when resolution fails but the override stays.
Negative controls: fails closed a top-level image an image-incapable media-routed override cannot see (raw bytes absent, visible ERROR, exact route intact for the routed audio) and routes a top-level image an image-capable media-routed override can see (supported image stays raw on the exact route). Reverting only the probe extension re-breaks the second test — a deliverable image would be needlessly fail-closed — proving the probe is required alongside the fail-closed arm.

R43-3 — sticky drainMediaRouted emits routing with a cleared route (real, fixed)
Verified: the ||= accumulators never reset; a mid-drain fail-closed resolution failure clears the override (audio branch) and the recheck re-bridges every retained medium, yet the drain still reported mediaRouted: true with routeSelector: undefined — core's steerRouteOverride then falls back to the stale pre-drain options.modelOverride and exact-routes the fully degraded steer into the dropped route. Fix: the drain now reports routing only when a route survived: drainRouteSelector = drainMediaRouted ? mediaRoutedOverrideRef.current : undefined, mediaRouted: drainRouteSelector !== undefined.
Negative control: does not report drain routing when the route failed closed mid-drain asserts {mediaRouted: false, routeSelector: undefined} and a bare/absent selector on the send; on the unpatched sticky flag the drain reports mediaRouted: true.

R43-4 — ?? precedence discards the drain's pristine capture when both captures exist (real, fixed)
Verified: lastPromptRef.current = preOverrideParts ?? metadata?.preOverrideParts ?? finalQueryToSend let the gate's capture (its input — which still contains the drain's marker-substituted steer segment, pushed into responsesToSend before submitQuery receives the pristine steerRetryQuery) win over the drain's fully pristine capture. Fix: precedence swapped to metadata?.preOverrideParts ?? preOverrideParts ?? finalQueryToSend — the drain capture is the same tool responses plus the pristine steer media, strictly more pristine.
Negative control: stores the drain pristine capture when the tool-result gate also captured (push-landed settle so restore is a no-op; the retry re-fails-closed the pristine image a second time — ERROR count 2; with the old precedence the stored gate capture resends the marker and the count is 1).

R43-6 — nested detector models the carrier as inlineData-only (real, fixed)
Verified: core's convertToFunctionResponse nests fileData into functionResponse.parts exactly like inlineData (coreToolScheduler.ts:810-841), and core's slimming/microcompact treat inlineData || fileData as media — but the gate only saw inner.inlineData, so fileData media slipped through into silent route slimming. Fix: both helpers now match either carrier (nestedPartCarriesMedia: inlineData.data or fileData.fileUri; MIME from inlineData.mimeType ?? fileData.mimeType).
Negative control: fails closed fileData tool-result media an audio-only override cannot see (fileData image substituted + visible ERROR; fileData audio keeps the exact route); fails on the inlineData-only detector.

R43-7 — supported nested media bypasses QWEN_CODE_MAX_INLINE_MEDIA_BYTES (real, fixed)
Verified: the gate passed supported-modality nested media through untouched — the only routing path this PR adds without the size clamp (all five top-level paths clamp first; core's clampNestedImages covers only the vision-bridge path). Fix: new clampNestedFunctionResponseMedia applies clampInlineMediaPart to every inner part of functionResponse.parts before the gate returns (identity when nothing is oversized; deterministic, so no pristine capture — consistent with every other clamp path).
Negative control: clamps oversized nested tool-result media the route supports (16-byte test ceiling, ~22-byte nested audio becomes the [Media omitted: …] placeholder with no capability note); fails with the clamp removed.

R43-8 — accepted steers linger in the retry store (real, fixed)
Verified: handleResolvedSteer stored the steer payload and only onRestore invalidated it; when settle accepts (push landed), a later core-driven continuation failure surfaces with the Ctrl+Y hint while the store still holds the already-delivered steer — Ctrl+Y would re-deliver it (second bridge egress) and the actually failed continuation is never retryable. Fix: SteerInput.onAccept? added; the drain wrapper's accept closure fires it right after resolved.accept() (symmetric with onRestore in the restore closure); handleResolvedSteer installs an identity-guarded handler that hands the store back the outer payload it superseded.
Negative control: hands the retry store back to the outer payload once the steer is accepted; without the onAccept handler Ctrl+Y resends the steer payload.

R43-10 — prompt_id stamp collides for Retry and Notification (real, fixed)
Verified: Ctrl+Y and the idle Notification drain both mint sessionId + '########' + getPromptCount() (production passes undefined at the two notification submit sites), and Retry — unlike UserQuery/Cron/Teammate — never advanced the counter, so a media-free Notification right after a media-routed retry minted the SAME id, matched the retry's stamp, and was exact-routed into the stale override (fail-closed resolution, emptied fallbacks, NOOP compression). The PR's own notification test passed an explicit prompt_id, which is why it never exercised the collision. Fix: Retry joins the startNewPrompt() gate (its own block — telemetry logUserPrompt/thought reset deliberately NOT extended to retries).
Negative control: does not exact-route a media-free notification onto a retry stamp (stateful counter; the notification keeps the bare selector; reverting the gate re-breaks it). The existing does not re-apply the exact-route marker to a media-free notification turn was reshaped to the production minted-id shape.


Verification (all under the shared build lock)

  • useGeminiStream.test.tsx: 277/277 (269 prior + 8 new; one existing notification test reshaped).
  • core compactionInputSlimming.test.ts + client.test.ts: 403/403 (incl. the new case-insensitivity test).
  • Session.test.ts: 708/709 — the single failure is the known 2-core-contention afterEach 10s-timeout flake on an unrelated prompt test (rounds 38/40/42 signature); it passes when re-run in isolation.
  • cli tsc --noEmit: 0 errors (2560 MB heap); core rebuilt first (tests/tsc resolve core through dist).
  • ESLint/Prettier clean on all changed files.
  • Negative controls: each of the 8 fixes reverted individually re-breaks exactly its test, then restored.

Pending review with 550 drafts still open — inline replies remain blocked (422), so this consolidated comment serves as the per-thread response; threads resolved via GraphQL.

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": none — but one candidate examined and dropped, for the record: #processSlashCommandResult passes no getModelOverride to #resolvePrompt / #applyBridgeConver…; chunk 5: executing the four touched tests in Session.test.ts — the review worktree has no node_modules or built workspace dist/, and a full install + build exceeds the r….

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 44, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/nonInteractiveCli.ts:1435 — [review] headless silently drops an unresolvable inline /model override — no user-facing notice
  • packages/cli/src/ui/hooks/useGeminiStream.ts:2024 — [review] tool-result gate mislabels a resolution failure as 'does not support images/audio'
  • packages/core/src/core/client.ts:3903 — [review] steerRouteOverride continuation sites untested (3 of 4 send sites)
  • packages/cli/src/acp-integration/session/Session.ts:7128 — [probe] recheck's emit-notices-before-abort egress disclosure has no test
  • packages/cli/src/nonInteractiveCli.ts:1369 — [probe] bridgeImagesForInlineOverride catch branch untested
  • packages/cli/src/acp-integration/session/Session.ts:7517 — [review] 'maximum 4 per turn' audio cap is enforced per drained message on the mid-turn path
  • packages/cli/src/acp-integration/session/Session.ts:7556 — [review] abort fail-closed path markers audio but silently strips images
  • packages/cli/src/services/audio-bridge-service.ts:282 — [review] notice drops actionable runtime transcription errors after egress
  • packages/cli/src/services/audio-bridge-service.ts:119 — [review] oversized audio on an audio-capable target is silently clamped — no notice
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6681 — [review] client-steer re-bridge test pins a state production cannot produce (never settles)
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7013 — [probe] recheck test never asserts the drained image survives into the final send
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7679 — [probe] mid-drain reconcile test cannot distinguish re-bridge from segment drop
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:8289 — [probe] no test pins user text surviving the vision fail-closed fallback
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1555 — [review] fail-closed image branch's retry invariant/comment wrong for media-routed overrides
  • packages/cli/src/nonInteractiveCli.test.ts:2305 — [probe] mixed-media test never pins runVisionBridge's parts argument
  • packages/cli/src/ui/hooks/useGeminiStream.ts:2327 — [probe] tool-result gate skips inline/skill overrides that never routed media — silent slimming

Convergence: round 44 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 8 (8 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 43; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare (:2612, :2916) and sent bare (:2499, :2827); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1683-1688). Core resolves exact routes only for trailing-NUL selectors, so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at this commit in rounds 43 and 44 (code unchanged). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"none — but one candidate examined and dropped, for the record: #processSlashCommandResult passes no getModelOverride to #resolvePrompt / #applyBridgeConver…;chunk 5:executing the four touched tests in Session.test.ts — the review worktree has no node_modules or built workspace dist/, and a full install + build exceeds the r…

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 44 轮,非阻断)——已记录,本轮不要求修改:共 16 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 44 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 8 条(其中 8 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 43 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare (:2612, :2916) and sent bare (:2499, :2827); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1683-1688). Core resolves exact routes only for trailing-NUL selectors, so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at this commit in rounds 43 and 44 (code unchanged). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Follow-up to 5709c7d (R44-1): replaceNestedFunctionResponseMedia
replaced a structured (non-string) response.output with the fail-closed
note, erasing the tool result while the note claimed only the media was
omitted. Core's convertToFunctionResponse passes tool-supplied
functionResponse parts through verbatim, so response.output can be
structured; stringify it and append the note instead. Negative-control
test pins the structured value surviving the fail-close.
@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

Fixed in 91fbcf7458 (follow-up to 5709c7dd3b), verified real and now fully closed.

The first fix handled the string-output case but not the structured one: core's convertToFunctionResponse passes tool-supplied functionResponse parts through verbatim, so response.output can be a structured (non-string) value. replaceNestedFunctionResponseMedia replaced that structured value with the fail-closed note — erasing the tool result while the note claimed only the nested media was omitted. Fixed by stringifying the structured output and appending the note (typeof current === 'string' && current.length > 0 keeps the string path; undefined/null/'' still produce the bare note; anything else is JSON.stringify-ed with a String() fallback, then \n\n + note).

Negative control: preserves a structured pass-through output while fail-closing its nested image builds a tool result whose response.output is {probeKey: 'PROBE_STRUCTURED_VALUE_42'} with a nested image/png part; it asserts the sent payload carries the fail-closed note AND both probe fields survive. It failed on 5709c7dd3b (structured output replaced by the note) and passes on 91fbcf7458; full useGeminiStream.test.tsx suite 279/279.

fileUtils.ts (utils/) imported DEFAULT_MAX_AUDIO_BRIDGE_BYTES from
core/inlineMediaLimit.ts, tripping the new
architecture/no-core-utils-upward-import lint rule that requires
utils/ to stay a leaf layer.

Move the constant definition into a new leaf module
utils/audio-bridge-constants.ts (no imports, mirrors
vision-bridge-constants.ts) and re-export it from
core/inlineMediaLimit.ts so the public API — including the core
barrel and cli consumers — is unchanged.

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): chunk 23: running the new drain/restore tests in packages/cli to confirm they pass (worktree lacks node_modules/dist; install+build exceeds budget).

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 45, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/acp-integration/session/Session.ts:5521 — [review] The cancelled-drain preservation sequence…
  • packages/core/src/core/client.test.ts:7004 — [review] The new onSteerResolved contract test asserts only that…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5767 — [review] 'restores later messages when cancellation races with @…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7890 — [review] 'routes a top-level image an image-capable media-routed…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7898 — [review] 'does not report drain routing when the route failed…
  • packages/cli/src/ui/hooks/useGeminiStream.ts:2050 — [review] applyToolResultMediaGate conflates a resolution FAILURE…
  • packages/cli/src/ui/hooks/useGeminiStream.ts:2031 — [review] applyToolResultMediaGate early-returns unless…
  • packages/cli/src/nonInteractiveCli.ts:1423 — [review] When the fail-closed capability check cannot resolve the…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:855 (+2 locations) — [review] The capability-probe harness wiring (~15-25 lines:…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:8422 — [review] The rewritten mid-turn vision-bridge-failure test dropped…
  • packages/cli/src/nonInteractiveCli.ts:1385 — [review] The new bridgeImagesForInlineOverride catch branch…
  • packages/cli/src/services/audio-bridge-service.ts:215 — [review] MAX_AUDIO_PARTS_PER_TURN ('maximum 4 per turn') is…
  • packages/cli/src/acp-integration/session/Session.ts:5966 — [review] The empty-drain fallback branch in prepareBeforeCompressio…
  • packages/cli/src/services/audio-bridge-service.test.ts:310 — [probe] The Sent ${result.egressCount} count in the…
  • packages/cli/src/services/audio-bridge-service.ts:100 — [probe] formatAudioBridgeNotice 's zero-conversion branch…

Convergence: round 45 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 44; 2 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare (:2600, :2904) and sent bare (:2815 and the mid-loop send); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1671-1676). Core resolves exact routes only for trailing-NUL selectors, so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. This round's diff does not touch the drain-item/mid-loop path (no hunks between :2400 and :2916). witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at this commit in rounds 43, 44 and 45 (code unchanged). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):chunk 23:running the new drain/restore tests in packages/cli to confirm they pass (worktree lacks node_modules/dist; install+build exceeds budget)

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 45 轮,非阻断)——已记录,本轮不要求修改:共 15 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 45 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 44 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare (:2600, :2904) and sent bare (:2815 and the mid-loop send); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1671-1676). Core resolves exact routes only for trailing-NUL selectors, so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. This round's diff does not touch the drain-item/mid-loop path (no hunks between :2400 and :2916). witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at this commit in rounds 43, 44 and 45 (code unchanged). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
…s, settle accepted hook-path steers

Round-45 follow-ups to the audio-bridge media routing:

R45-3: replaceNestedFunctionResponseMedia spread a non-plain-object
functionResponse.response (array/scalar/string returned by verbatim
pass-through tool results) into the rebuilt object, silently mangling it
(array -> numeric-keyed object) or erasing it (scalar spreads to {}).
Spread only plain objects; fold any other shape into the appended note via
stringifyStructuredToolOutput so the tool output survives the fail-close.
Negative-control test pins array/number/string responses surviving.

R45-2: the headless loop had no counterpart to useGeminiStream's
applyToolResultMediaGate, so nested functionResponse.parts media returned
mid-turn was exact-routed to the persisted NUL-stamped override and silently
placeholder-substituted by core slimming when the target lacked the
modality. Add the gate to the main-turn and drain-turn continuation sends:
detect, fail-closed-resolve the selector, substitute the unsupported
modality with a visible marker + emitBridgeNotice, clamp survivors.
Negative-control test pins the nested image being fail-closed instead of
riding the audio-only route raw.

R45-1: the hook-path drain installed onRestore but not the onAccept
counterpart handleResolvedSteer installs for the core-driven path, so an
accepted hook-path steer left its pristine payload in the retry store and
Ctrl+Y could re-inject it when a later continuation of the same request
failed. Install onAccept to hand back the superseded outer payload under the
identity guard, symmetric with handleResolvedSteer; guarded so a
continuation with no preceding payload keeps the composite as the recovery
channel. Negative-control test pins the retry restarting the outer payload
instead of re-delivering the accepted steer.

The nested-media helpers move to a shared leaf module
(utils/nested-function-response-media.ts) so the hook and the headless loop
gate with one implementation.
@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

Round 45 — all 3 Critical findings verified real and fixed in 89c1fa28ef

R45-3 — replaceNestedFunctionResponseMedia corrupts/erases non-object functionResponse.responseconfirmed, fixed

Evidence. Extracted the real function from HEAD and ran it under Node on the verbatim pass-through shapes (core's convertToFunctionResponse passes a tool-supplied functionResponse through as-is when response.content is absent — the untyped-adapter boundary coreToolScheduler.test.ts exercises):

  • response: [{answer:42},{answer:43}]{"0":{answer:42},"1":{answer:43},"output":"[note]"} — array spread into a numeric-keyed object;
  • response: 42{"output":"[note]"} — tool output erased entirely;
  • response: "raw scalar output"{"0":"r","1":"a",…,"output":"[note]"} — scattered into char-indexed entries.

This violates the same preserve-don't-erase invariant the diff documents. (The round-44 fix covers a structured output inside a plain object; this is the sibling case where response itself is not a plain object.)

Fix. Spread only plain-object responses; fold any other shape into the appended note via stringifyStructuredToolOutput.

Negative control. New test preserves a non-object pass-through response while fail-closing its nested image (array + number + string). Reverting the fix fails it exactly as probed (array → numeric keys); restoring passes. The plain-object / structured-output controls are unchanged.

R45-2 — headless loop lacks a tool-result media gate — confirmed, fixed

Evidence. The trailing-NUL inline-override selector persists across every headless continuation send (the skill-tool setter refuses to displace a NUL-stamped value), but nonInteractiveCli.ts had no counterpart to the applyToolResultMediaGate this PR adds to useGeminiStream.ts. Negative-control run: with the gate absent, a nested image/png on an audio-only exact route rides the continuation send raw (…,"parts":[{"inlineData":{"mimeType":"image/png",…}}]), which core route slimming then placeholder-substitutes silently — the model answers about a tool-result image it never received, with nothing on stderr or in the JSON stream.

Fix. Added a headless applyToolResultMediaGate (detect nested media → fail-closed-resolve the NUL-stripped selector → substitute the unsupported modality with an explicit "was not sent" marker + emitBridgeNotice → clamp survivors) and apply it before the main-turn continuation send and the drain-turn send.

Negative control. New test gates nested tool-result media against the persisted exact-route override. Disabling the gate fails it (raw nested image rides the audio-only route); enabling passes (image fail-closed + stderr notice).

R45-1 — hook-path drain installs only onRestore, never onAcceptconfirmed, fixed

Evidence. handleResolvedSteer (core-driven path) installs both onAccept and onRestore; the hook-path drain in submitQuery installed only onRestore. An accepted hook-path steer therefore left its stored payload in lastPromptRef, and a later same-request continuation failure + Ctrl+Y would re-submit it — re-injecting the already-accepted steer. Negative control (new test): without onAccept, the retry re-submits the stored steer composite instead of the outer payload.

One clarification on the report's framing: the already-landed tool responses would not in fact duplicate on a Ctrl+Y re-send — core's repairOrphanedToolUseTurns drops duplicate functionResponse copies for the same callId on the Retry path. The segment with no such dedup is the steer itself, which is what this fix guards.

Fix. Install onAccept on the hook path to hand back the superseded outer payload under the same identity guard — symmetric with handleResolvedSteer (round-43, R43-8). The guard is load-bearing: a tool continuation is always preceded by the outer turn's prompt in production, but where there is nothing to hand back, the composite must stay the recovery channel rather than being nulled (keeps the existing accept-before-failure tests intact).

Negative control. New test hands the retry store back to the outer payload once a hook-path steer is accepted. Removing the onAccept install fails it (retry resends the steer composite); restoring passes (retry restarts the outer payload, carrying no steer audio).

Housekeeping

  • The nested-media helpers (detect/replace/clampNestedFunctionResponseMedia + internals) moved to a shared leaf module packages/cli/src/utils/nested-function-response-media.ts, so the hook and the headless loop gate with a single implementation.

Verification (all under the shared build lock)

  • useGeminiStream.test.tsx + nonInteractiveCli.test.ts: 432 passed | 1 skipped (pre-existing skip).
  • cli tsc --noEmit: zero errors in the changed files (only the pre-existing environment errors from @modelcontextprotocol/* being absent in this worktree's node_modules, identical at HEAD baseline).
  • ESLint clean and Prettier clean on all changed files.
  • All three negative controls verified to fail before the fix and pass after.

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): chunk 13: could not execute the two tests — no node_modules in the review worktree or parent checkout, and a full monorepo npm install + workspace builds exceeded the….

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Deferred under the convergence posture (round 46, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/nonInteractiveCli.test.ts:1946 (+1 locations) — [review] Both clamp tests never assert the override selector's fate…
  • packages/cli/src/nonInteractiveCli.test.ts:2188 (+1 locations) — [review] The audio fail-closed test never asserts the send's…
  • packages/cli/src/nonInteractiveCli.test.ts:2510 (+1 locations) — [probe] 'gates nested tool-result media against the persisted…
  • packages/cli/src/nonInteractiveCli.ts:1363 (+1 locations) — [review] The pre-existing per-turn vision-bridge else branch…
  • packages/cli/src/nonInteractiveCli.ts:1428 (+1 locations) — [review] Headless resolution-failure branch silently drops an…
  • packages/cli/src/nonInteractiveCli.ts:2994 (+1 locations) — [review] The headless drain-item call site of…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5493 (+1 locations) — [review] No test pins the (includeDeferred=false, goalTurnActive)…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5677 (+1 locations) — [review] 'drops a queued replacement prompt when a later goal…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5874 (+1 locations) — [review] 'restores later messages when cancellation races with @…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6336 (+1 locations) — [review] The failed-bridge drain send (send #1) never asserts the…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6353 (+2 locations) — [review] The retry-payload strip assertions in both audio-failure…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6379 (+1 locations) — [review] The re-drain redelivery assertions in 're-bridges mid-turn…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6569 (+1 locations) — [probe] 'delivers a failed drained steer exactly once when the…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6587 (+1 locations) — [probe] The history-side exactly-once claim is asserted only as…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7101 (+1 locations) — [probe] 'hands the retry store back to the outer payload once a…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7200 (+1 locations) — [review] 're-bridges a client-driven steer drain via Ctrl+Y when…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7201 (+1 locations) — [probe] 're-bridges a client-driven steer drain via Ctrl+Y' never…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7279 (+1 locations) — [probe] None of the new retry-store tests pins the hand-back…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7518 (+1 locations) — [probe] 'rechecks earlier drained audio against a later full-turn…
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7541 (+2 locations) — [probe] In 'keeps a failed skill route fallback sticky across…
  • …and 6 more (see the run report)

Convergence: round 46 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 3 (3 new). Findings keep coming back to the same files: packages/cli/src/nonInteractiveCli.ts (findings in round 45; 2 more now); packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 45; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare and sent bare; the send-time NUL exact-route suffix applies only to inlineModelOverride, so core resolves these sends without exact-route semantics: requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. This round's new applyToolResultMediaGate early-returns for non-NUL selectors (verified), so the bare-skill-override ride is unchanged. Witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at this commit in rounds 43-45 and again this round (the NUL suffix applies only to inlineModelOverride, and the new gate early-returns for bare selectors). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):chunk 13:could not execute the two tests — no node_modules in the review worktree or parent checkout, and a full monorepo npm install + workspace builds exceeded the…

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

收敛姿态下延后(第 46 轮,非阻断)——已记录,本轮不要求修改:共 26 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 46 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 3 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/cli/src/nonInteractiveCli.ts(第 45 轮已出过发现,本轮又有 2 条);packages/cli/src/ui/hooks/useGeminiStream.ts(第 45 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R10-5 (still standing, carried from round 10, re-verified at this commit): packages/cli/src/nonInteractiveCli.ts — headless mid-loop and drain-item skill-tool overrides are stored bare and sent bare; the send-time NUL exact-route suffix applies only to inlineModelOverride, so core resolves these sends without exact-route semantics: requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. This round's new applyToolResultMediaGate early-returns for non-NUL selectors (verified), so the bare-skill-override ride is unchanged. Witness: round-38 probe reproduced the raw-audio ride end-to-end; re-verified statically at this commit in rounds 43-45 and again this round (the NUL suffix applies only to inlineModelOverride, and the new gate early-returns for bare selectors). Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/nonInteractiveCli.ts Outdated
…gates in headless path

R46-1: settle accepts a steer as soon as its push lands — before any model
content. When that send then errors with no model content, the accepted steer
never reached the model, yet the onAccept hand-back plus the retry's orphan
strip stranded it (neither delivered nor re-queued). Track per-send model
content (StreamContentTracker) and undo the accept hand-back on a retryable
no-content failure (undoAcceptedSteerHandBackIfNeeded) so Ctrl+Y's
strip-then-push re-delivers the composite exactly once — for both the
hook-path steer and the client-driven drain twin (handleResolvedSteer).

R46-2: headless resolution-failure branch — with no vision bridge and a
text-only session model, images rode the session route and were silently
placeholder-substituted. Mirror the interactive R33-2 branch after the clamp
block: replace the images with a visible "Image was not sent: …" marker and
emit a bridge notice; correct the inaccurate comment.

R46-3: headless applyToolResultMediaGate early-returned for any non-NUL
selector, so bare fail-closed/text-only overrides shipped nested tool-result
media raw. Gate against ANY persisted override selector (fail-close-resolving
bare selectors, reusing the first-turn probe verdict).

Tests: 2 hook steer-recovery regression tests, 1 headless fail-closed-image
test, 1 headless bare-gate test (all fail with their fix disabled).

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): chunk 17: could not execute the new test to confirm it passes — the review worktree has no node_modules and a full npm ci + workspace npm run build (required by the…; chunk 18: could not execute useGeminiStream.test.tsx — the shared review worktree has no node_modules and installing there would disturb concurrent agents; verdict is fro…; chunk 19: executed the three tests under vitest to confirm green (worktree has no node_modules , and a full monorepo install + build exceeds the tool budget); verificati….

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 47, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/nonInteractiveCli.ts:3050 — [review] headless drain-item media gate has no paired test
  • packages/cli/src/ui/hooks/useGeminiStream.ts:5961 — [probe] skill-tool undefined-clear executing path untested (mutation survives)
  • packages/cli/src/nonInteractiveCli.ts:1931 — [review] headless gate-time probe branch untested
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5699 — [probe] goal-drain test missing handleSlashCommand dispatch assertions
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1771 — [probe] NUL full-turn override: fail-closed else arm ships raw images into the exact route
  • packages/cli/src/nonInteractiveCli.test.ts:2584 — [review] headless gate tests don't pin functionResponse preservation
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6551 — [review] exactly-once test missing steer-text negative on retry payload
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6587 — [review] exactly-once test's history assertion is >=1, not exactly one
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:8517 — [probe] mid-drain fail-closed test can't see a dropped segment
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:9014 — [probe] text-retention assertion deleted by the bridge-failure test rewrite
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1683 — [probe] redundant second capability probe for the same route
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1921 — [review] gate mislabels resolution failures as unsupported modalities
  • packages/cli/src/nonInteractiveCli.test.ts:1885 — [probe] clamp tests don't pin the bare-selector invariant
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7132 — [probe] retry-payload assertions fabricated by the bridge mock

Convergence: round 47 posted 6 inline comment(s), 6 of them reported for the first time; the previous round posted 3 (3 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 46; 4 more now); packages/cli/src/nonInteractiveCli.ts (findings in round 46; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R10-5 (carried from round 10, still standing at this commit — re-checked against HEAD 96fe03e): headless mid-loop and drain-item skill overrides are stored bare (modelOverride = override in the setter at ~:2731) and sent bare (mid-loop send ~:2618, drain-item send :2955); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1711-1719). The nested tool-result media gate added this round polices only nested functionResponse.parts media for the override route — it does not stamp or gate TOP-LEVEL media on skill-override sends. Core resolves exact routes only for trailing-NUL selectors (geminiChat.ts ~:2566-2572), so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. A round-38 probe reproduced the raw-audio ride end-to-end; the mechanism was re-verified statically at this commit. Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):chunk 17:could not execute the new test to confirm it passes — the review worktree has no node_modules and a full npm ci + workspace npm run build (required by the…;chunk 18:could not execute useGeminiStream.test.tsx — the shared review worktree has no node_modules and installing there would disturb concurrent agents; verdict is fro…;chunk 19:executed the three tests under vitest to confirm green (worktree has no node_modules , and a full monorepo install + build exceeds the tool budget); verificati…

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 47 轮,非阻断)——已记录,本轮不要求修改:共 14 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 47 轮发布了 6 条行内评论,其中 6 条是首次提出;上一轮发布了 3 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 46 轮已出过发现,本轮又有 4 条);packages/cli/src/nonInteractiveCli.ts(第 46 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R10-5 (carried from round 10, still standing at this commit — re-checked against HEAD 96fe03e): headless mid-loop and drain-item skill overrides are stored bare (modelOverride = override in the setter at ~:2731) and sent bare (mid-loop send ~:2618, drain-item send :2955); the send-time NUL exact-route suffix applies only to inlineModelOverride (:1711-1719). The nested tool-result media gate added this round polices only nested functionResponse.parts media for the override route — it does not stamp or gate TOP-LEVEL media on skill-override sends. Core resolves exact routes only for trailing-NUL selectors (geminiChat.ts ~:2566-2572), so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. A round-38 probe reproduced the raw-audio ride end-to-end; the mechanism was re-verified statically at this commit. Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Comment thread packages/cli/src/nonInteractiveCli.ts Outdated
…ld images

Rework the R46 steer-recovery machinery to key on settle OUTCOMES and drain
ORIGIN instead of lastPromptRef identity / enclosing-tracker state:

- handleResolvedSteer records settledVia ('accept'|'restore') on the undo
  entry and invalidates it on restore: a restored drain is owned by the
  re-queue, so the retry store is never re-armed alongside it (R47-1).
- handleResolvedSteer no longer gates on retryParts: the common drain shape
  (text steers, clean media) stores the drain's parts and gets the same
  swap/onAccept/onRestore/undo bookkeeping as the hook path (R47-2).
- Client drains mint a per-drain StreamContentTracker at resolution; the
  client undo branch keys on it (plus owner-send identity) instead of the
  enclosing send's tracker, which is always true at boundary-drain time
  (R47-3).
- Hook-path onAccept is installed unconditionally: with no outer payload to
  hand back (goal-first sessions) accept trims the steer segment from the
  stored composite so a consumed steer is never re-injected by Ctrl+Y;
  zero-content failures still re-arm the composite via the undo (R47-4).
- Tests drive core's real settle ordering (settleSteerInput in finally
  before the enclosing failure surfaces, content before boundary drains);
  the R46 test that never settled is replaced by faithful variants (R47-5).
- nonInteractiveCli no-vision-bridge branch appends a model-facing marker:
  an image-only payload no longer collapses to an empty user turn (R47-6).

Negative controls verified for all six: each fix reverted individually
fails its regression test with the reported symptom.
@DragonnZhang
DragonnZhang requested a review from qqqys as a code owner August 25, 2026 08:05

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): chunk 17: executing the three new tests in packages/cli (worktree has no node_modules/dist; install+build exceeds remaining tool budget) — correctness was verified by s….

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 48, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/nonInteractiveCli.test.ts:1839 — [review] three override-path tests never assert modelOverride — bare-vs-NUL semantics unpinned where conditional on surviving media
  • packages/cli/src/nonInteractiveCli.test.ts:2657 — [review] exact-route persistence test never asserts the continuation's modelOverride — a stamp-drop regression ships green
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:3720 — [review] vacuous batchId contrast — committed setup-tool group existence never pinned
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:5677 — [probe] goal-clear drop test cannot observe its named behavior — removing '/goal clear' leaves it passing
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6217 (+8 locations) — [review] makeCompletedToolCall factory duplicated 7x across the drain-test cluster (+1 similar copy)
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6579 — [review] exactly-once restore tests never re-check terminal state after re-drain delivery
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6923 — [review] retry re-bridge test missing raw-audio absence assertion
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7311 — [review] bridge mock fabricates the functionResponse output — testTool assertion blind to the stored composite
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:9299 — [probe] text-retention assertion deleted by the bridge-failure test rewrite (mutation ships green)

Convergence: round 48 posted 4 inline comment(s), 4 of them reported for the first time; the previous round posted 6 (6 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 47; 4 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R10-5 (carried from round 10, still standing at HEAD ba76096 — re-checked against the code this round): headless mid-loop and drain-item skill overrides are stored bare (modelOverride = override in the setter at ~:2743) and sent bare (mid-loop send :2630, drain-item send :2966); the NUL exact-route suffix at send time (:1727) applies only to inlineModelOverride. The nested tool-result media gate (:1900) polices only nested functionResponse.parts media — it does not stamp or gate TOP-LEVEL media on skill-override sends. Core resolves exact routes only for trailing-NUL selectors (geminiChat.ts ~:2565-2570), so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. The author reply claims a fix in 9a3e34f, but the code at HEAD still shows bare skill-override storage/sends and an inline-only NUL stamp — the mechanism still fires. Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):chunk 17:executing the three new tests in packages/cli (worktree has no node_modules/dist; install+build exceeds remaining tool budget) — correctness was verified by s…

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 48 轮,非阻断)——已记录,本轮不要求修改:共 9 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 48 轮发布了 4 条行内评论,其中 4 条是首次提出;上一轮发布了 6 条(其中 6 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 47 轮已出过发现,本轮又有 4 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R10-5 (carried from round 10, still standing at HEAD ba76096 — re-checked against the code this round): headless mid-loop and drain-item skill overrides are stored bare (modelOverride = override in the setter at ~:2743) and sent bare (mid-loop send :2630, drain-item send :2966); the NUL exact-route suffix at send time (:1727) applies only to inlineModelOverride. The nested tool-result media gate (:1900) polices only nested functionResponse.parts media — it does not stamp or gate TOP-LEVEL media on skill-override sends. Core resolves exact routes only for trailing-NUL selectors (geminiChat.ts ~:2565-2570), so requestModalities falls back to the session config and slimCompactionInput keeps inline audio when the SESSION model is audio-capable — preserved raw audio rides the continuation send to a text-only skill target with no marker and no notice, while the interactive/ACP twins gate media routing on the target's modalities. The author reply claims a fix in 9a3e34f, but the code at HEAD still shows bare skill-override storage/sends and an inline-only NUL stamp — the mechanism still fires. Fix: route skill-override sends carrying surviving media through the target's modalities fail-closed (NUL exact route when media survives, or bridge/clamp before the bare send), matching the interactive/ACP twins.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
… and retryable

R48-1: extend the image capability probe and the no-bridge fail-closed gate
to any active override (drop the inline/stamp restriction) so unstamped
skill-tool overrides no longer escape media policing (mirror the headless twin).
R48-2: the drain recheck pass only fills an absent pristine capture, keeping
the first-pass full-pristine media for Retry instead of the degraded variant.
R48-3: thread the accumulated resolution failure into each drain segment
(inheritedResolutionFailed) so clamp / fail-closed protections persist across
segments in both drain passes.
R48-4: add overrideCleared to the drain/SteerInput contract; core
steerRouteOverride degrades to the session model instead of resurrecting the
cleared pre-drain selector.
# Conflicts:
#	packages/cli/src/acp-integration/session/Session.test.ts
#	packages/cli/src/acp-integration/session/Session.ts

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 49, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/services/audio-bridge-service.ts:284 — [review] Bridge notice never surfaces the sanitized per-part failure reason
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1961 (+2 locations) — [review] Both media gates blame 'does not support' when the override failed to resolve (TUI + headless twin)
  • docs/design/2026-08-01-audio-bridge.md:31 — [review] Design doc Scope contradicts the tool-result media gate this PR adds
  • packages/cli/src/services/audio-bridge-service.ts:120 — [review] The 4-audio-per-turn cap does not apply to the native passthrough branch
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6599 — [review] Exactly-once steer test's transcript-side assertion is too weak to catch a double-add
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:9873 — [review] Rewritten fail-closed test dropped the only user-text-survival assertion
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:8138 — [review] Recheck test never asserts the already-routed image survives the recheck
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:885 — [review] Capability-route mock scaffold copy-pasted across 13 new tests

Convergence: round 49 posted 5 inline comment(s), 5 of them reported for the first time; the previous round posted 4 (4 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 48; 4 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 49 轮,非阻断)——已记录,本轮不要求修改:共 8 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 49 轮发布了 5 条行内评论,其中 5 条是首次提出;上一轮发布了 4 条(其中 4 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 48 轮已出过发现,本轮又有 4 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/utils/nested-function-response-media.ts
DragonnZhang and others added 3 commits August 26, 2026 21:11
…rs on auth errors, gate bare overrides and untyped nested media

- R49-1: stamp mediaRoutedPromptIdRef in the core-driven boundary drain's
  onSteerResolved wrapper when the drain routed media, so the steered
  turn's media-free tool continuations keep the exact route (mirrors the
  hook-path drain's metadata.steerMediaRouted stamp). Without it they
  send the bare selector, re-enabling the fallback chain mid-turn.
- R49-2: run undoAcceptedSteerHandBackIfNeeded and set lastPromptErroredRef
  in the UnauthorizedError catch branch too, so an accepted-but-never-seen
  steer keeps its Ctrl+Y recovery channel on 401 exactly like any other
  non-abort failure.
- R49-3: gate ANY active override in applyToolResultMediaGate (the headless
  twin already does, R46-3) — bare inline/skill overrides own continuation
  sends too, and their bare selectors resolve request modalities from the
  session config, silently placeholder-substituting nested media. When the
  override supports a detected modality, establish the route so surviving
  nested media exact-routes to the override instead of being slimmed
  against session modalities.
- R49-4: snapshot both undo branches' preconditions before writing
  lastPromptRef; when a hook-path steer AND a client-driven drain both
  settled accepted without model content, store the merged payload so the
  retry's strip-then-push re-delivers both steers exactly once.
- R49-5: detect MIME-less nested media carriers (hasUntyped) and fail them
  closed visibly in both gates — core's slimming resolves a missing MIME
  to DEFAULT_MIME, which matches no modality and placeholder-substitutes
  the media on every route.
# Conflicts:
#	packages/cli/src/acp-integration/session/Session.ts
…is union-safe

The Retry branch ternary unions the gate result with an inline fallback
object; TS2339 flagged gated.mediaRouted because only the awaited gate
member declared it (the literal-branch property normalization applies
only when both ternary arms are object literals). Add mediaRouted:
undefined to the fallback member, matching the gate's optional property.

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

Partially reviewed — gaps disclosed.

Unresolved, please confirm:

  • [Critical] 128 older-round inline blocker threads (rounds 1-48): every anchored blocker is either outdated (anchored lines rewritten) or on files changed since the comment, and individual mechanism traces of all 128 were not feasible within this round…

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): chunk 18: none — though I did not execute the test suite (logic traced statically; assertions are internally consistent), and test 2's accept() generator and final asse…; chunk 23: none — no checks were cut short.; chunk 4: executing the new tests ( cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts ) to confirm they pass — the review worktree has no nod….

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 50, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/acp-integration/session/Session.test.ts:10350 — [review] image-capable no-bridge test doesn't pin the flag's absence
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:8011 — [review] no-retryParts steer test misses the outer-prompt-absence assertion
  • packages/cli/src/utils/nested-function-response-media.ts:29 — [review] walkers throw TypeError on null/undefined nested part entries
  • packages/cli/src/utils/nested-function-response-media.ts:130 — [review] new 236-line gate module has zero tests
  • packages/cli/src/nonInteractiveCli.ts:1975 — [review] hasUntyped fail-closed branch has zero test coverage
  • packages/cli/src/services/audio-bridge-service.ts:186 — [review] actionable config errors truncated at 200 chars, losing the escape hatch
  • packages/cli/src/acp-integration/session/Session.ts:7975 — [review] abort after a successful conversion discards completed transcripts
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1916 — [review] no-override gate path: unclamped nested blobs and silent-slim after visible fail-close
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1458 — [review] fail-closed retry re-derivation dead under skill-tool overrides
  • packages/cli/src/ui/hooks/useGeminiStream.ts:1990 — [review] gate conflates resolution failure with 'does not support'
  • packages/cli/src/nonInteractiveCli.test.ts:2686 — [review] headless exact-route test never pins the continuation's stamp
  • packages/cli/src/nonInteractiveCli.ts:2008 — [review] headless gate clamp has no witness (TUI twin is pinned)
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:6602 — [review] exactly-once steer test asserts history with toBeGreaterThan(0)
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:7117 — [review] hook-path hand-back test's steer-absence guard is vacuous
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:9225 — [review] R48-2 retryParts witness pins the audio half only
  • packages/cli/src/ui/hooks/useGeminiStream.test.tsx:9963 — [review] fail-closed textOnly spread has no witness
  • packages/cli/src/ui/hooks/useGeminiStream.ts:5285 — [review] UnauthorizedError recovery channel (R49-2 fix) has zero test coverage
  • docs/design/2026-08-01-audio-bridge.md:34 — [review] design doc claims nested tool-result audio is unchanged — this PR changes it
  • packages/cli/src/acp-integration/session/Session.test.ts:14767 — [review] recheck test never pins sibling-image survival on abort

Convergence: round 50 posted 3 inline comment(s), 2 of them reported for the first time; the previous round posted 5 (5 new). Findings keep coming back to the same files: packages/cli/src/ui/hooks/useGeminiStream.ts (findings in round 49; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R50-3 (media-route stamp lifecycle): the inline location (useGeminiStream.ts:4605-4607) is occupied by the open R43-10 thread, so this confirmed class finding posts in the body. The media-route prompt stamp (mediaRoutedPromptIdRef) is a single session-wide slot whose write lifecycle does not match turn ownership — three confirmed modes: (a) OVERWRITE — a concurrent ?btw side question admitted during Responding skips the turn-start reset and unconditionally overwrites the foreground turn's stamp, contradicting the invariant in the comment directly above the write; (b) LINGERING — the write precedes the !shouldProceed / goal-claim-deferred early returns while startNewPrompt() advances AFTER them, so a send that stamped then aborted (Ctrl+C during the vision phase after audio routed) leaves a live stamp on a prompt_id the counter re-mints — every later media-free Notification drain exact-routes into the stale route's fail-closed resolution until the next user turn; (c) RETRY — a Ctrl+Y retry under an established route mints a fresh prompt_id but never re-stamps (the retry branch reports mediaRouted only when establishingRoute, false for an established route; retryLastPrompt passes no metadata), so the retried turn's media-free continuations send the bare selector and split the media-routed turn across models. Deterministic ref-state traces at the reviewed commit; exactly two stamp writers exist (:4606 and the onSteerResolved drain writer ~:5052) and neither fires for modes (b)/(c). Fix: scope stamp ownership to the owning turn — guard the write against sends that skip the turn-start reset, move it below the early returns, and re-stamp when a Retry belongs to an established media route; witness: three tests beside 'does not exact-route a media-free notification onto a retry stamp', one per mode, each going red when its guard is removed.

中文说明

仅完成部分审查,审查缺口已披露。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):chunk 18:none — though I did not execute the test suite (logic traced statically; assertions are internally consistent), and test 2's accept() generator and final asse…;chunk 23:none — no checks were cut short.;chunk 4:executing the new tests ( cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts ) to confirm they pass — the review worktree has no nod…

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 50 轮,非阻断)——已记录,本轮不要求修改:共 19 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 50 轮发布了 3 条行内评论,其中 2 条是首次提出;上一轮发布了 5 条(其中 5 条首次提出)。发现反复回到同一批文件:packages/cli/src/ui/hooks/useGeminiStream.ts(第 49 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R50-3 (media-route stamp lifecycle): the inline location (useGeminiStream.ts:4605-4607) is occupied by the open R43-10 thread, so this confirmed class finding posts in the body. The media-route prompt stamp (mediaRoutedPromptIdRef) is a single session-wide slot whose write lifecycle does not match turn ownership — three confirmed modes: (a) OVERWRITE — a concurrent ?btw side question admitted during Responding skips the turn-start reset and unconditionally overwrites the foreground turn's stamp, contradicting the invariant in the comment directly above the write; (b) LINGERING — the write precedes the !shouldProceed / goal-claim-deferred early returns while startNewPrompt() advances AFTER them, so a send that stamped then aborted (Ctrl+C during the vision phase after audio routed) leaves a live stamp on a prompt_id the counter re-mints — every later media-free Notification drain exact-routes into the stale route's fail-closed resolution until the next user turn; (c) RETRY — a Ctrl+Y retry under an established route mints a fresh prompt_id but never re-stamps (the retry branch reports mediaRouted only when establishingRoute, false for an established route; retryLastPrompt passes no metadata), so the retried turn's media-free continuations send the bare selector and split the media-routed turn across models. Deterministic ref-state traces at the reviewed commit; exactly two stamp writers exist (:4606 and the onSteerResolved drain writer ~:5052) and neither fires for modes (b)/(c). Fix: scope stamp ownership to the owning turn — guard the write against sends that skip the turn-start reset, move it below the early returns, and re-stamp when a Retry belongs to an established media route; witness: three tests beside 'does not exact-route a media-free notification onto a retry stamp', one per mode, each going red when its guard is removed.

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +28 to +30
function nestedPartCarriesMedia(inner: Part): boolean {
const hasInline =
typeof inner.inlineData?.data === '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] R49-5: The tool-result media gates are still keyed on a hand-enumerated MIME class list (image/, audio/, missing-MIME) over an unbounded entrance space of tool-supplied MIME strings — this round re-confirmed the finding with four new demonstrated corners. Any carrier outside that list — video/*, application/pdf, application/octet-stream (the MCP default for a MIME-less embedded resource), an empty-string mimeType (MCP image/audio blocks pass mimeType through verbatim), or a depth-2 nested functionResponse carrier — bypasses both gates (TUI useGeminiStream.ts:1924, headless nonInteractiveCli.ts:1929) entirely: no visible fail-closed marker, no ERROR item, no clamp. Core route slimming then placeholder-substitutes the media on the exact route, so the model answers about tool-result media it never received with nothing disclosed to the user. Probe against this commit's built modules: detectNestedFunctionResponseMedia returns {hasImage:false, hasAudio:false, hasUntyped:false} for video/mp4, application/octet-stream and '' (the missing-MIME control reports hasUntyped:true), and slimCompactionInput substitutes [document: video/mp4] / [document: ] placeholders — for ''-MIME on every route, including an all-capable one. Enumerating MIME prefixes never converges: key the gates on carrier presence (any nested media carrier), classify each MIME against the resolved route's authoritative modality map (core supportsMimeType semantics, normalizing empty/whitespace MIME to absent), fail closed visibly for anything the route cannot consume, and recurse into nested functionResponse parts the way core's transformPart does so the marker and the byte ceiling hold at every depth slimming can reach. A colocated unit test should assert detect reports carriers for video/mp4, octet-stream, ''-MIME and depth-2 shapes and that replace/clamp substitute them; removing the classification must turn it red.

中文说明

[Critical] R49-5:工具结果媒体门控仍然基于手工枚举的 MIME 类别清单(image/、audio/、缺失 MIME)来检查工具提供的、无界的 MIME 字符串输入空间——本轮审计重新确认了该问题,并新增四个已实证的绕过入口。凡不在该清单内的载体——video/*application/pdfapplication/octet-stream(MCP 对未声明 MIME 的内嵌资源的默认值)、空字符串 mimeType(MCP 图像/音频块原样透传 mimeType)、二层嵌套的 functionResponse 载体——都会完全绕过两个门控(TUI useGeminiStream.ts:1924、headless nonInteractiveCli.ts:1929):没有可见的失败关闭标记、没有 ERROR 项、也没有字节上限检查。随后核心路由瘦身会在精确路由上把该媒体替换为占位符,模型实际上是在回答它从未收到的工具结果媒体,而用户看不到任何提示。已在本 commit 构建的模块上探针验证:detectNestedFunctionResponseMediavideo/mp4application/octet-stream'' 均返回 {hasImage:false, hasAudio:false, hasUntyped:false}(缺失 MIME 的对照组会报告 hasUntyped:true),且 slimCompactionInput 会把这些载体替换为 [document: video/mp4] / [document: ] 占位符——空字符串 MIME 在所有路由(包括全能力路由)上都会被替换。枚举 MIME 前缀永远不会收敛:建议把门控改为基于载体存在性(任意嵌套媒体载体),按解析出的路由的权威模态表(核心 supportsMimeType 语义,把空/空白 MIME 归一化为缺失)对每个 MIME 分类,对路由无法消费的载体做可见的失败关闭替换,并像核心 transformPart 一样递归进入嵌套的 functionResponse parts,使标记与字节上限在瘦身可达的每一层都生效。请补充同目录单元测试,断言 detect 能报告 video/mp4、octet-stream、''-MIME 和二层嵌套形状的载体、且 replace/clamp 会替换它们;移除该分类逻辑后测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +2006 to +2008
// Clamp whatever nested media survives the gate — the exact-route path
// would otherwise skip QWEN_CODE_MAX_INLINE_MEDIA_BYTES.
return normalizePartList(clampNestedFunctionResponseMedia(result));

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 headless applyToolResultMediaGate validates nested tool-result media against the override but never establishes the exact route for the continuation: when media survives under a BARE persisted selector, nothing NUL-stamps modelOverride — the TUI twin sets mediaRoutedOverrideRef and returns mediaRouted: true, which stamps \0 at send time. The continuation sends the bare selector, core resolves requestModalities from the SESSION config, and slimCompactionInput silently placeholder-substitutes the very media the gate just validated. Concretely: a headless run with a text-only session model and an inline override to a media-capable model whose first turn carries no media (modelOverride persists bare — the init stamp at :1725-1730 fires only when first-turn media survives); a tool mid-turn returns an image nested in functionResponse.parts; the gate resolves the bare selector, sees the override supports images and passes the image through with no notice; the continuation send passes the bare selector unchanged; the nested image becomes an [image: image/png] placeholder — the model answers about an image it never received, with nothing on stderr or in the JSON stream. Probe against this commit's slimCompactionInput: session modalities {} → the nested image becomes [{"text":"[image: image/png]"}] while the image route survives; grep confirms no send-time \0 stamping exists in this file. Have the gate report whether supported media survived (e.g. return { parts, mediaSurvived }) and NUL-stamp the persisted selector at both call sites when media survived under a bare selector (modelOverride = ${routeSelector}\0``, same for the drain loop's itemModelOverride), so the continuation exact-routes and request modalities come from the resolved route. Extend `gates nested tool-result media against the persisted exact-route override` to assert the continuation send's `modelOverride` ends with `\0` — mutation-tested: withholding the stamp on continuations keeps every existing test green, and the added assertion turns that mutant red.

中文说明

[Critical] headless 的 applyToolResultMediaGate 会按 override 校验嵌套的工具结果媒体,但从不为后续请求(continuation)建立精确路由:当媒体在 BARE(未加 NUL 标记的)持久化选择器下存活时,没有任何代码给 modelOverride\0 标记——而 TUI 的对应实现会设置 mediaRoutedOverrideRef 并返回 mediaRouted: true(发送时打 \0)。于是 continuation 以裸选择器发送,核心按 SESSION 配置解析 requestModalitiesslimCompactionInput 会把门控刚刚校验过的那个媒体静默替换为占位符。具体场景:文本-only 会话模型 + 内联 override 到一个支持媒体的模型、且第一轮不带媒体(modelOverride 保持裸状态——:1725-1730 的初始打点只在第一轮媒体存活时触发);工具在回合中途返回嵌套在 functionResponse.parts 里的图像;门控解析裸选择器、确认 override 支持图像后放行且不发任何提示;continuation 原样以裸选择器发送;嵌套图像变成 [image: image/png] 占位符——模型回答了一张它从未收到的图像,而 stderr 和 JSON 流上什么都没有。已在本 commit 的 slimCompactionInput 上探针验证:会话模态为 {} 时嵌套图像变成 [{"text":"[image: image/png]"}],而 image 路由可以存活;grep 确认本文件中不存在发送时的 \0 打点。建议让门控返回媒体是否存活(如 { parts, mediaSurvived }),并在媒体于裸选择器下存活时对两个调用点的持久化选择器加 \0 标记(modelOverride = ${routeSelector}\0``,drain 循环中的 itemModelOverride 同理),使 continuation 走精确路由、请求模态取自解析出的路由。请在 `gates nested tool-result media against the persisted exact-route override` 中补充断言:continuation 发送的 `modelOverride` 以 `\0` 结尾——已做变异测试:从 continuation 上剥离该标记时现有测试全部仍为绿色,加上该断言后该变异变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines 4588 to 4589
metadata?.onAdmissionFailed?.();
throw 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.

[Critical] submitQuery's prepare-throw catch fires only onAdmissionFailed, never onDeliveryFailed — so a tool continuation whose prepare throws never calls drainedSteer?.restore(), the only channel that re-queues steer messages the continuation destructively drained from the queue. The new applyToolResultMediaGate introduces the first throwing path into ToolResult prepare: the walker throws TypeError on a null/undefined nested part entry (untyped extension-supplied parts that core passes through verbatim — probe on this commit: detectNestedFunctionResponseMedia({functionResponse:{parts:[null]}})TypeError: Cannot read properties of null (reading 'inlineData')). The drained user message is neither sent, recorded, re-queued, nor Ctrl+Y-retryable (lastPromptRef is written only after prepare succeeds): the user's typed input silently vanishes behind a generic admission-failed error. Every other submitQuery failure branch (concurrency guards, !shouldProceed, stream errors) calls onDeliveryFailed; only this catch skips it, and before this PR, function-response prepare could not throw. Re-queue the attached steer before rethrowing: add metadata?.steerInput?.restore(); next to metadata?.onAdmissionFailed?.(); (restore is idempotent via its settled flag), matching every other delivery-failure path. Add a test: with a mid-turn message queued, drive a tool continuation whose tool result nests a null part entry so the gate throws, and assert the drained text is re-queued after submitQuery rejects; removing the added restore call must turn it red.

中文说明

[Critical] submitQuery 的 prepare 抛错 catch 只触发 onAdmissionFailed,从不触发 onDeliveryFailed——因此 prepare 抛错的工具 continuation 从不调用 drainedSteer?.restore(),而这是把被该 continuation 从队列中破坏性取出(destructively drained)的 steer 消息重新入队的唯一通道。新增的 applyToolResultMediaGate 给 ToolResult prepare 引入了第一个会抛异常的路径:walker 在嵌套 part 条目为 null/undefined 时抛 TypeError(core 会原样透传扩展提供的未定型 parts——在本 commit 上探针验证:detectNestedFunctionResponseMedia({functionResponse:{parts:[null]}})TypeError: Cannot read properties of null (reading 'inlineData'))。被取出的用户消息既不会被发送、不会被记录、不会重新入队、也无法 Ctrl+Y 重试(lastPromptRef 只在 prepare 成功后写入):用户键入的输入在一个笼统的 admission-failed 错误背后静默消失。submitQuery 的其他每个失败分支(并发守卫、!shouldProceed、流错误)都会调用 onDeliveryFailed,唯独这个 catch 没有;而且在本 PR 之前,function-response 的 prepare 不可能抛异常。建议在重新抛出前先把附带的 steer 重新入队:在 metadata?.onAdmissionFailed?.(); 旁加上 metadata?.steerInput?.restore();(restore 通过 settled 标志保证幂等),与其他所有投递失败路径保持一致。请补充测试:队列中存在回合中途消息时,驱动一个工具结果嵌套 null part 条目、使门控抛错的工具 continuation,并断言 submitQuery 拒绝后被取出的文本已重新入队;移除新增的 restore 调用后该测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

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

Labels

autofix/needs-human The autofix loop stopped on this PR — a human must re-arm, split, merge, or close it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants