fix(acp): route-scope the session token-limit cache in Session.ts - #9862
Conversation
The ACP Session keeps a private `lastPromptTokenCount` fed from streamed `usageMetadata`, reset only when the chat instance changes (#syncPromptTokenCountWithCurrentChat). ACP model switches (unstable_setSessionModel -> setModel -> config.switchModel) rebuild the content generator but keep the same GeminiChat, so a count recorded on the previous route survived and anchored the session-token-limit gate for the new route: any modelOverride send (compression skipped) or any send whose compression attempt throws reached #getPostCompressionTokenCount(null) with the stale pre-switch count and was wrongly dropped with SessionTokenLimitExceeded / stopReason 'max_tokens'. Attribute the cached count to the route that produced it (Config.getModelRouteIdentity) and invalidate it on a route change, mirroring the route-scoping #9506 applied to the GeminiChat counts. Same-route counting and the chat-instance reset are unchanged. Fixes #9529
|
Re-run at head Template looks good ✓ Problem: observed bug, now with independent confirmation. Linked #9529 is open and self-reported, but the defect is no longer the author's word alone — @wenshao reproduced it end to end in a real Direction: aligned. This is the last surviving instance of the route-blind token-count class already confirmed and fixed by the merged #9506 / #9454 — a private Session cache that machinery could not reach. Consistency follow-up on an approved direction, not new surface. Size: no core-module paths touched ( Approach: scope is right. The PR grew across review rounds — route-keyed cache, then in-send COMPRESSED invalidation, then request-route attribution in the cron/background/stop loops, then the abort re-check after async route resolution — but every addition is the same defect class inside the same private cache, each pinned by its own regression test, and the maintainer's mutation pass shows the pieces are load-bearing. No drive-by changes; the disclosed non-goals (#9564, Stop-hook display reading the raw cache) are pre-existing and untouched. Risk: Moving on to code review. 🔍 中文说明本次为在 head 模板完整 ✓ 问题:已观测到的 bug,且已有独立确认。关联的 #9529 为开启状态、由作者自报,但该缺陷已不再只有作者单方陈述——@wenshao 在真实的 方向:对齐。这是已在合并的 #9506 / #9454 中确认并修复的"路由无感知计数"缺陷类的最后一个残存实例——一处该机制触及不到的 Session 私有缓存。属于已批准方向上的一致性收尾,不是新方向。 规模:未触及核心模块路径( 方案:范围合理。PR 在评审轮次中有所增长——按路由键控缓存、发送中 COMPRESSED 失效、cron/后台/Stop 循环按请求路由归属计数、异步路由解析后的 abort 复查——但每一项新增都是同一私有缓存中同一缺陷类的实例,各自有回归测试钉住,维护者的变异测试也证明这些部分都是承重的。无夹带改动;已披露的非目标(#9564、Stop-hook 展示读取原始缓存)均为既有行为、未被触碰。 风险: 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewBefore reading the diff, my independent proposal for this problem was exactly what the PR does: attribute the private cache to
No blockers, no convention violations. The disclosed non-goals (#9564 sibling defaults, Stop-hook TestingCI at the reviewed commit, fetched via the API (per gate rules this run does not execute PR code). The board is fully green and fully settled — every
On the behavioural claim itself, the gap this comment flagged in earlier rounds is now closed from the strongest possible direction: @wenshao's verification up-thread is a real-environment A/B — the bundled CLI driven as a real Not verified by this run, by its rules: re-running the PR's own test suite (this workflow never executes PR code; the PR's CI above is the evidence). 中文说明代码审查读 diff 之前,我对这个问题的独立方案与 PR 完全一致:把私有缓存归属到
无阻塞问题,无规范违反。已披露的非目标(#9564 兄弟默认值、Stop-hook 测试被审提交上的 CI 证据,经 API 获取(按门禁规则,本运行不执行 PR 代码)。看板全绿且已全部落定——该 head 上所有 关于行为性结论本身:早先轮次本评论指出的缺口,如今已由最强方向关闭——@wenshao 上面的验证评论是一次真实环境 A/B:打包后的 CLI 作为真实 按本运行规则未验证:重跑 PR 自带测试套件(本流程从不执行 PR 代码,以上 PR 自身 CI 即证据)。 — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 5/5 — observed bug, minimal correct fix, independently reproduced and A/B-verified in a real runtime, mutation-pinned tests, and a fully green settled CI board; both reservations from the earlier pass are closed. Stepping back: my first pass at the initial commit already said this was what a follow-up fix should look like, and six review rounds made it more so instead of less. The two honest reservations I carried then are both settled. The CI board is no longer in flight — every Going back to my independent proposal: the PR matches it on the core move and exceeds it on everything I'd have gotten wrong — I'd have scoped the cache to the route identity and stopped there, missing that in-send COMPRESSED events, the cron/background/stop record sites, and the async-resolution abort window are all instances of the same defect. Each was found in review, each is pinned by a test, and the scope stayed inside one file and its collocated suite. Same-route semantics are provably unchanged; the only semantic test change moves the zero- Verdict: approve, pinned to the reviewed commit. 中文说明置信度:5/5 —— 已观测到的 bug、最小且正确的修复、在真实运行时被独立复现并做了 A/B 验证、测试经变异钉住、CI 看板全绿且已全部落定;上一轮的两点保留意见均已关闭。 退一步看:我在首个提交上的第一轮评审就认为这是一个后续修复该有的样子,六轮评审之后它变得更好而不是更差。当时我带着的两点诚实保留都已落定。CI 看板不再是在跑状态——该 head 上所有 回到我的独立方案:PR 在核心动作上与之一致,并在我会犯错的所有地方做得更好——我会把缓存按路由身份作用域化就到此为止,漏掉发送中 COMPRESSED 事件、cron/后台/Stop 记录点、异步解析 abort 窗口其实都是同一缺陷的实例。每一处都在评审中被发现、各有测试钉住,且范围始终收敛在一个文件及其同目录测试内。同路由语义可证不变;唯一的语义性测试改动把零 结论:批准,绑定到被审提交。 — Qwen Code · qwen3.8-max Reviewed at |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed. Suggestions are inline.
Not reviewed: verification and reverse audit — each ran and opened its brief, but neither was launched with the prompt the CLI built — the launches were written by hand, so the posted findings cannot be counted as verified, and what the agents were actually asked is not what this skill certifies.
中文说明
仅完成部分审查,审查缺口已披露。 建议见行内评论。
未审查:验证与反向审计——两者都运行并打开了各自的 brief,但都不是用 CLI 构建的 prompt 启动的——启动 prompt 是手写的,发布的发现不能算作已验证,agent 实际被要求做的也不是本 skill 所认证的内容。
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
Closeout update: fixed the ACP token-limit cache to key by the request route, including modelOverride/exact-selector sends, and retained counts per route so A-B-A switches do not destroy the returning route count. Added #9529 regressions for override-target sends and A-B-A retention. Intentionally not changed: exposing GeminiChat route-key derivation as a new API, to keep this PR scoped to Session private cache. Verification: Prettier and git diff --check passed; focused Vitest was blocked locally by missing fresh-worktree build artifacts for workspace packages. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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 6c": full Session.test.ts suite run for unrelated regressions (only the four #9529 tests were executed).; "agent 3b": tracing how the per-request model field in the new third test ( session.prompt({ ..., model: 'route-b-model' }) ) reaches chat.sendMessageStream — the insta…; "agent 1c": full npm run typecheck to confirm the model excess-property compile error in Session.test.ts (the runtime test failure was instead confirmed by direct vites….
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未探索到全部深度(达到工具调用预算):"agent 6c":full Session.test.ts suite run for unrelated regressions (only the four #9529 tests were executed).;"agent 3b":tracing how the per-request model field in the new third test ( session.prompt({ ..., model: 'route-b-model' }) ) reaches chat.sendMessageStream — the insta…;"agent 1c":full npm run typecheck to confirm the model excess-property compile error in Session.test.ts (the runtime test failure was instead confirmed by direct vites…。
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
Closeout update: refreshed with latest main, fixed the request-route token-count recording compile/regression issue, and resolved the two fixed threads. Verified with |
Add a #9529 regression test that drives a route override through the full-turn vision selector (fullTurnModelOverride): the first override send streams usage metadata over the session token limit, and a second same-override send whose compression falls back to the cache must then resolve max_tokens — proving the first count was recorded under the override route key, not the active route's. Reverting the record site to the default route key makes the test fail. Also make the hoisted requestRouteKey initialization in #executePromptInner and #runStopContinuation use optional chaining (this.config.getModelRouteIdentity?.(...) ?? ''), matching the #currentRouteKey convention for partial Config mocks; the unguarded call threw for every prompt in the ~340 Session tests whose mock config does not define getModelRouteIdentity.
|
Round closeout (cap-4, completed across a concurrent-push race): While this round was in flight, an external push (8ff5a94, "fix(cli): retain token counts for request route") landed and fixed the three Criticals:
This round's push 8ff5a94..9799682 (single commit) then closed the first Suggestion:
Session.test.ts full suite + #9529 subset green, cli typecheck/eslint/prettier clean. Deferred to next round: the five remaining Suggestions (sentinel-resolution dedupe, retention-cache bound, mock-factor, \0-branch coverage, clear()-discrimination, warn-string route key). No /triage — the push auto-triggers the review lane. |
|
Closeout update: bounded the private ACP route token cache, added route/model details to token-limit drop warnings, and covered runtime-scoped override resolution plus chat-reset route-cache clearing. Intentionally not changed: shared route resolver extraction, to avoid adding a cross-package API in this bugfix. Verification: npm ci/prepare build completed, Prettier and git diff --check passed, and focused Session Vitest for #9529/token-limit cases passed. New CI is pending.\n\n |
|
Accuracy addendum to the closeout above: the mock-compat guard in 9799682 ( |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
Convergence: round 3 posted 4 inline comment(s), 2 of them reported for the first time; the previous round posted 10 (9 new). Findings keep coming back to the same files: packages/cli/src/acp-integration/session/Session.ts (findings in rounds 1, 2; 2 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. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
已审查——无阻断问题。 建议见行内评论。
收敛情况:第 3 轮发布了 4 条行内评论,其中 2 条是首次提出;上一轮发布了 10 条(其中 9 条首次提出)。发现反复回到同一批文件:packages/cli/src/acp-integration/session/Session.ts(第 1、2 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
Closeout update: handled the remaining #9529 route-cache suggestions. Changed: removed two dead route-key initializers and added an eviction regression proving the oldest retained route count drops after eight distinct routes. Intentionally not changed: token-count fixture extraction and broader internal send-loop coverage, to keep this bugfix scoped. Verified Prettier, git diff --check, npm ci/prepare build, and focused |
The hoisted requestRouteKey initializer in #executePromptInner and #runStopContinuation computed a route identity that was discarded on every turn: the null-stream paths return before any record site, and every path that reaches a record site first assigns requestRouteKey from the send result. Replace both with a plain empty initializer.
The ~25-line mock setup that streams a 101-token usage metadata chunk on the first send and an empty stream on the second was pasted verbatim in eight #9529 session-token-limit tests. Extract it into createOverLimitUsageSendStream next to the existing stream helpers and migrate all eight copies.
The evict-oldest branch in #setLastPromptTokenCount had no coverage: existing tests exercise at most three route identities, so deleting the eviction block, flipping the size comparison, or evicting the newest entry all survived silently. Drive nine distinct route identities (one past MAX_RETAINED_SESSION_ROUTE_COUNTS) through session.prompt, then assert the evicted oldest route reads back no cached count (its send goes out) while a retained route still trips the gate.
The existing override-route recording test only exercises the primary prompt record site in #executePromptInner; the only Stop-hook gate test drops the continuation send before streaming and never mocks getModelRouteIdentity, so the #runStopContinuation record site was unpinned. Drive a Stop-hook continuation whose send streams over-limit usage under a \0 exact-route override, then assert a second same-override send trips the gate from the cached count — reverting the continuation record site to the default route key makes the test fail.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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.
Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:
packages/cli/src/acp-integration/session/Session.ts:7162 — [review] FIFO eviction does not refresh recency; active route double-booked (7 effective parked slots)packages/cli/src/acp-integration/session/Session.test.ts:12254 — [probe] override tests queue tryCompressChat mocks never consumed; comments misstate the gate mechanism
Convergence: round 4 posted 4 inline comment(s), 4 of them reported for the first time; the previous round posted 4 (2 new). Findings keep coming back to the same files: packages/cli/src/acp-integration/session/Session.test.ts (findings in round 2; 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, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)
[Critical] R4-5: The route-keyed fallback cache lastPromptTokenCountsByRouteKey (Session.ts:1857-1859) is never invalidated when compression rewrites the shared history, so it can false-drop a returning route's send on a count sized against destroyed history. The mirror is cleared only on chat-instance change (#syncPromptTokenCountWithCurrentChat); its only compression hook, #recordCompressionTokenCount, is called solely from the pre-send path (Session.ts:6772) and re-stamps only the active route. GeminiChat's owner-side equivalent clears ALL retained per-route counts whenever compression rewrites the shared history (geminiChat.ts:2360 — the #9506 rationale quotes this exact harm), including in-send compression inside GeminiChat.sendMessageStream (hard-tier rescue ~2782, reactive overflow ~3500), which ACP reaches because Session calls chat.sendMessageStream directly; Session handles no StreamEventType.COMPRESSED event. Concrete trigger with sessionTokenLimit=100: route A's send streams usage 101, so the mirror retains {A: 101}; the user switches to route B on the same chat instance; a B send trips hard-rescue and compresses in-send — GeminiChat clears its retained counts, but the mirror keeps {A: 101} sized against the destroyed history; switching back to A while tryCompressChat throws or is rate-limited (the exact fallback this PR's tests target), the gate reads the stale map[A]=101 > 100 and drops a send that fits the compressed history with stopReason 'max_tokens' and a misleading 'Session token limit exceeded' diagnostic. Pre-diff this sequence passed (the single scalar held B's post-compression usage), so the retention map introduces the corner; the gate drops before any send, so route A cannot self-heal while compression stays unavailable. Witness: not run — the deciding link is the absence of any invalidation path between GeminiChat's in-send compression and Session's private mirror (Session's unit harness mocks chat.sendMessageStream, the very layer where the compression occurs, so a probe would script the compression rather than run it); the absence was established by enumerating every mirror writer (sole clear at Session.ts:7183, sole compression-record caller at 6772, no COMPRESSED-event handler anywhere in Session.ts), and the mirror-based drop half ran via the PR's own A-B-A retention test in the scratch tree (max_tokens, 2 of 3 sends went out). Suggested fix: at the gate's fallback site, read the owner's keyed count — this.#getCurrentChat().getLastPromptTokenCount(requestRouteKey), the shape of core client.ts's gate, no new cross-package surface — and retire the Session-side mirror; if the mirror must stay, invalidate it on every compression (clearing only in #recordCompressionTokenCount does NOT close the class, because in-send compressions never reach that hook). Note: this finding's natural inline anchor (Session.ts:1859) is already occupied by existing comment 3842445670 (a round-2 Suggestion on the same mirror machinery), so the finding is carried in the body rather than re-posted inline.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 4 轮发布了 4 条行内评论,其中 4 条是首次提出;上一轮发布了 4 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/cli/src/acp-integration/session/Session.test.ts(第 2 轮已出过发现,本轮又有 4 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)
[Critical] R4-5: The route-keyed fallback cache lastPromptTokenCountsByRouteKey (Session.ts:1857-1859) is never invalidated when compression rewrites the shared history, so it can false-drop a returning route's send on a count sized against destroyed history. The mirror is cleared only on chat-instance change (#syncPromptTokenCountWithCurrentChat); its only compression hook, #recordCompressionTokenCount, is called solely from the pre-send path (Session.ts:6772) and re-stamps only the active route. GeminiChat's owner-side equivalent clears ALL retained per-route counts whenever compression rewrites the shared history (geminiChat.ts:2360 — the #9506 rationale quotes this exact harm), including in-send compression inside GeminiChat.sendMessageStream (hard-tier rescue ~2782, reactive overflow ~3500), which ACP reaches because Session calls chat.sendMessageStream directly; Session handles no StreamEventType.COMPRESSED event. Concrete trigger with sessionTokenLimit=100: route A's send streams usage 101, so the mirror retains {A: 101}; the user switches to route B on the same chat instance; a B send trips hard-rescue and compresses in-send — GeminiChat clears its retained counts, but the mirror keeps {A: 101} sized against the destroyed history; switching back to A while tryCompressChat throws or is rate-limited (the exact fallback this PR's tests target), the gate reads the stale map[A]=101 > 100 and drops a send that fits the compressed history with stopReason 'max_tokens' and a misleading 'Session token limit exceeded' diagnostic. Pre-diff this sequence passed (the single scalar held B's post-compression usage), so the retention map introduces the corner; the gate drops before any send, so route A cannot self-heal while compression stays unavailable. Witness: not run — the deciding link is the absence of any invalidation path between GeminiChat's in-send compression and Session's private mirror (Session's unit harness mocks chat.sendMessageStream, the very layer where the compression occurs, so a probe would script the compression rather than run it); the absence was established by enumerating every mirror writer (sole clear at Session.ts:7183, sole compression-record caller at 6772, no COMPRESSED-event handler anywhere in Session.ts), and the mirror-based drop half ran via the PR's own A-B-A retention test in the scratch tree (max_tokens, 2 of 3 sends went out). Suggested fix: at the gate's fallback site, read the owner's keyed count — this.#getCurrentChat().getLastPromptTokenCount(requestRouteKey), the shape of core client.ts's gate, no new cross-package surface — and retire the Session-side mirror; if the mirror must stay, invalidate it on every compression (clearing only in #recordCompressionTokenCount does NOT close the class, because in-send compressions never reach that hook). Note: this finding's natural inline anchor (Session.ts:1859) is already occupied by existing comment 3842445670 (a round-2 Suggestion on the same mirror machinery), so the finding is carried in the body rather than re-posted inline.
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
Closeout pass on the 4 unresolved bot Suggestions (all the same finding: vision-override mock setup pasted verbatim into four #9529 tests). Fixed in f619920 (
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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.
Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:
packages/cli/src/acp-integration/session/Session.test.ts:12261 — [probe] override tests queue tryCompressChat mocks never consumed; comments misstate the gate mechanism
[Critical] R4-5: Still stands (re-checked at head f619920; this round's delta is test-only and does not address it). The route-keyed fallback cache lastPromptTokenCountsByRouteKey (Session.ts:1857-1859) is never invalidated when compression rewrites the shared history, so it can false-drop a returning route's send on a count sized against destroyed history. The mirror is cleared only on chat-instance change (#syncPromptTokenCountWithCurrentChat; sole clear at Session.ts:7183); its only compression hook, #recordCompressionTokenCount, is called solely from the pre-send path (Session.ts:6772) and re-stamps only the active route. GeminiChat's owner-side equivalent clears ALL retained per-route counts whenever compression rewrites the shared history (geminiChat.ts:2360 — the #9506 rationale quotes this exact harm), including in-send compression inside GeminiChat.sendMessageStream (hard-tier rescue ~2782/2848, reactive overflow ~3500), which ACP reaches because Session calls chat.sendMessageStream directly; Session handles no StreamEventType.COMPRESSED event. Concrete trigger with sessionTokenLimit=100: route A's send streams usage 101, so the mirror retains {A: 101}; the user switches to route B on the same chat instance; a B send trips hard-rescue and compresses in-send — GeminiChat clears its retained counts, but the mirror keeps {A: 101} sized against the destroyed history; switching back to A while tryCompressChat throws or is rate-limited (the exact fallback this PR's tests target), the gate reads the stale map[A]=101 > 100 and drops a send that fits the compressed history with stopReason 'max_tokens' and a misleading 'Session token limit exceeded' diagnostic. Pre-diff this sequence passed (the single scalar held B's post-compression usage), so the retention map introduces the corner; the gate drops before any send, so route A cannot self-heal while compression stays unavailable. Witness: not run — the deciding link is the absence of any invalidation path between GeminiChat's in-send compression and Session's private mirror (Session's unit harness mocks chat.sendMessageStream, the very layer where the compression occurs, so a probe would script the compression rather than run it); the absence was re-established at this head by enumerating every mirror writer (sole clear at Session.ts:7183, sole compression-record caller at 6772, no COMPRESSED-event handler anywhere in Session.ts) and GeminiChat's owner-side clears (geminiChat.ts:2360/2494/2848), and the mirror-based drop half ran via the PR's own A-B-A retention test in the scratch tree in round 4 (max_tokens, 2 of 3 sends went out). Suggested fix: at the gate's fallback site, read the owner's keyed count — this.#getCurrentChat().getLastPromptTokenCount(requestRouteKey), the shape of core client.ts's gate, no new cross-package surface — and retire the Session-side mirror; if the mirror must stay, invalidate it on every compression (clearing only in #recordCompressionTokenCount does NOT close the class, because in-send compressions never reach that hook). Note: this finding's natural inline anchor (Session.ts:1859) is already occupied by existing comment 3842445670 (a round-2 Suggestion on the same mirror machinery), and Session.ts carries no hunks in this round's incremental diff, so the finding is carried in the body rather than re-posted inline.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。
[Critical] R4-5: Still stands (re-checked at head f619920; this round's delta is test-only and does not address it). The route-keyed fallback cache lastPromptTokenCountsByRouteKey (Session.ts:1857-1859) is never invalidated when compression rewrites the shared history, so it can false-drop a returning route's send on a count sized against destroyed history. The mirror is cleared only on chat-instance change (#syncPromptTokenCountWithCurrentChat; sole clear at Session.ts:7183); its only compression hook, #recordCompressionTokenCount, is called solely from the pre-send path (Session.ts:6772) and re-stamps only the active route. GeminiChat's owner-side equivalent clears ALL retained per-route counts whenever compression rewrites the shared history (geminiChat.ts:2360 — the #9506 rationale quotes this exact harm), including in-send compression inside GeminiChat.sendMessageStream (hard-tier rescue ~2782/2848, reactive overflow ~3500), which ACP reaches because Session calls chat.sendMessageStream directly; Session handles no StreamEventType.COMPRESSED event. Concrete trigger with sessionTokenLimit=100: route A's send streams usage 101, so the mirror retains {A: 101}; the user switches to route B on the same chat instance; a B send trips hard-rescue and compresses in-send — GeminiChat clears its retained counts, but the mirror keeps {A: 101} sized against the destroyed history; switching back to A while tryCompressChat throws or is rate-limited (the exact fallback this PR's tests target), the gate reads the stale map[A]=101 > 100 and drops a send that fits the compressed history with stopReason 'max_tokens' and a misleading 'Session token limit exceeded' diagnostic. Pre-diff this sequence passed (the single scalar held B's post-compression usage), so the retention map introduces the corner; the gate drops before any send, so route A cannot self-heal while compression stays unavailable. Witness: not run — the deciding link is the absence of any invalidation path between GeminiChat's in-send compression and Session's private mirror (Session's unit harness mocks chat.sendMessageStream, the very layer where the compression occurs, so a probe would script the compression rather than run it); the absence was re-established at this head by enumerating every mirror writer (sole clear at Session.ts:7183, sole compression-record caller at 6772, no COMPRESSED-event handler anywhere in Session.ts) and GeminiChat's owner-side clears (geminiChat.ts:2360/2494/2848), and the mirror-based drop half ran via the PR's own A-B-A retention test in the scratch tree in round 4 (max_tokens, 2 of 3 sends went out). Suggested fix: at the gate's fallback site, read the owner's keyed count — this.#getCurrentChat().getLastPromptTokenCount(requestRouteKey), the shape of core client.ts's gate, no new cross-package surface — and retire the Session-side mirror; if the mirror must stay, invalidate it on every compression (clearing only in #recordCompressionTokenCount does NOT close the class, because in-send compressions never reach that hook). Note: this finding's natural inline anchor (Session.ts:1859) is already occupied by existing comment 3842445670 (a round-2 Suggestion on the same mirror machinery), and Session.ts carries no hunks in this round's incremental diff, so the finding is carried in the body rather than re-posted inline.
— qwen3.8-max via Qwen Code /review (v0.22.0)
…rite The route-keyed fallback cache was only cleared on a chat-instance change and re-stamped by the pre-send compression hook, so compressions inside GeminiChat.sendMessageStream (hard-tier rescue, reactive overflow — surfaced as StreamEventType.COMPRESSED, which the session loops ignored) left it holding pre-compression counts sized against destroyed history. A returning route's send could then be false-dropped with 'Session token limit exceeded' when tryCompressChat failed. Handle StreamEventType.COMPRESSED in all four session send loops and clear every retained route count on any COMPRESSED result (pre-send or in-send), re-stamping the fresh count under the request route and the active route when they differ — mirroring GeminiChat clearing its keyed counts in the COMPRESSED branch of tryCompress. Move the pre-send record after request-route resolution so the invalidation keys correctly. Update the zero-newTokenCount COMPRESSED test to pin the corrected semantics: after a successful rewrite the pre-compression count must not gate the send (owner-side parity).
|
R4-5 verified real at f619920 and fixed in 8836460. Root cause: the mirror cache cleared only on chat-instance change, and its sole compression hook was pre-send only. In-send compressions (hard-rescue / reactive overflow inside GeminiChat.sendMessageStream) clear GeminiChat's own keyed counts but surface as StreamEventType.COMPRESSED, which the session send loops ignored — stale route counts sized against destroyed history could false-drop a returning route's send when tryCompressChat failed. Fix: handle COMPRESSED in all four session send loops; on any COMPRESSED result (pre-send or in-send) clear every retained route count and re-stamp the fresh count under the request route (and active route when it differs), mirroring GeminiChat's COMPRESSED-branch clear. Pre-send record moved after request-route resolution so the invalidation keys correctly. Regression test pins the exact trigger: route A streams usage 101 (limit 100) → route B in-send COMPRESSED → return to A with tryCompressChat rejecting → send must go out. Mutation-checked: reverting the fix fails it with stopReason 'max_tokens'. Also re-pinned the zero-newTokenCount COMPRESSED test — its old expectation gated on the pre-compression count after a successful rewrite (same false-drop class; owner-side clears and passes there). Verification: Session.test.ts 702/702 passed; packages/cli typecheck clean. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round:
packages/cli/src/acp-integration/session/Session.ts:8145 (+2 locations) — [probe] cron and background-notification loops record usage under the record-time active route instead of the request routepackages/cli/src/acp-integration/session/Session.ts:5275 (+4 locations) — [probe] COMPRESSED invalidation runs after each loop's per-event abort check; a cancel on the compression event skips itpackages/cli/src/acp-integration/session/Session.ts:8111 (+3 locations) — [review] COMPRESSED-event invalidation is tested only in the main prompt looppackages/cli/src/acp-integration/session/Session.ts:7143 — [review] active-route re-stamp branch of the COMPRESSED invalidation is never exercisedpackages/cli/src/acp-integration/session/Session.ts:369 — [review] retention cap hand-duplicates core's module-private MAX_RETAINED_ROUTE_COUNTSpackages/cli/src/acp-integration/session/Session.ts:7165 — [review] dead default routeKey parameter on #getPostCompressionTokenCount is a wrong-key trap
中文说明
收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改:共 6 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
Round-6 closeout (patrol): Critical R6-1 verified real at 8836460 and fixed in 9f9496f. The |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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 6b": did not execute Session.test.ts — the review worktree has no node_modules and no built packages/core/dist , so running it would require a full npm ci + ….
Deferred under the convergence posture (round 7, not a blocker) — recorded, not requested in this round:
packages/cli/src/acp-integration/session/Session.ts:6313 — [probe] COMPRESSED-event invalidation is tested only in the main prompt loop; deleting the three sibling handlers keeps the suite greenpackages/cli/src/acp-integration/session/Session.ts:7148 — [probe] active-route re-stamp branch of the COMPRESSED invalidation is never exercised; branch deletion survives the suite
Convergence: round 7 posted 2 inline comment(s), 2 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/acp-integration/session/Session.ts (findings in round 6; 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.)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未探索到全部深度(达到工具调用预算):"agent 6b":did not execute Session.test.ts — the review worktree has no node_modules and no built packages/core/dist , so running it would require a full npm ci + …。
收敛姿态下延后(第 7 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 7 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/cli/src/acp-integration/session/Session.ts(第 6 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
…equest route (#9529) The cron/loop-tick and background-notification send loops captured the request route key and threaded it into the COMPRESSED handler, but their post-stream usage record still called #recordPromptTokenCount(usageMetadata), whose default route key is the record-time active route. A model switch landing between request and record stored the outgoing route's API-reported count under the incoming route's key, so the next new-route send whose pre-send compression failed was false-dropped with 'Session token limit exceeded' (and, on the cron path, could permanently disable cron via #stopCronAfterTokenLimit). Pass the captured requestRouteKey into the usage record at both call sites, matching the interactive prompt loops. Add collocated tests pinning that each loop records usage under the request route even when the route switches mid-stream. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No blocking issues. LGTM! ✅
Deferred under the convergence posture (round 8, not a blocker) — recorded, not requested in this round:
packages/cli/src/acp-integration/session/Session.ts:7161 — [review] dead default routeKey parameters default to the active route — the exact mis-keying this PR removes; make them requiredpackages/cli/src/acp-integration/session/Session.ts:6313 — [review] in-send COMPRESSED invalidation tested only in the main prompt loop; deleting the three sibling handlers keeps the suite green (+2 locations)packages/cli/src/acp-integration/session/Session.ts:7148 — [review] active-route re-stamp branch of the COMPRESSED invalidation is never exercised; branch deletion survives the suitepackages/cli/src/acp-integration/session/Session.test.ts:12915 — [review] same-route zero-info COMPRESSED expectation flipped max_tokens to end_turn but the PR body says same-route counting is unchangedpackages/cli/src/acp-integration/session/Session.ts:7251 — [probe] crossing-adoption never refreshes recency and double-books the scalar route; probe shows an over-limit send leaking past the gate after eviction
中文说明
无阻断问题。LGTM!✅
收敛姿态下延后(第 8 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.22.0)
# Conflicts: # packages/cli/src/acp-integration/session/Session.test.ts # packages/cli/src/acp-integration/session/Session.ts
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
5 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- mirror route-keyed cache re-implementing LlmChat's public retained-count cache — already reported (comment 3842445670)
- fourth copy of the \0 selector route derivation — already discussed, author declined (thread 3842445658)
- in-stream COMPRESSED handlers untested outside the main prompt loop — already recorded in rounds 6-8 deferral lists
- active-route re-stamp branch of COMPRESSED invalidation never exercised — already recorded in round 8 deferral list
- FIFO eviction not refreshing recency — already recorded in rounds 4 and 8 deferral lists
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 4": none — no check was cut short..
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 5 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未探索到全部深度(达到工具调用预算):"agent 4":none — no check was cut short.。
— qwen3.8-max via Qwen Code /review (v0.22.2)
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 756 passed · 0 failed · 756 total Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:756 通过 · 0 失败 · 756 总计 抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence Verification reportPR #9862 verification — fix(acp): route-scope the session token-limit cache in Session.tsVerdict: 中文摘要
Central claim and A/BCentral claim: after an ACP model switch that keeps the same The control differs from head by exactly one file:
All 10 base reds fail with the defect's behavioral signature (stale count drops the send: The 3 base-green cells, each explained with evidence (base trace in
Mutation matrix (head, one mutant at a time, test in isolation, then restored)Witness:
0 survivors. M0 proves the harness detects gate removal in both directions; M1–M5 prove each guard the PR introduces is load-bearing. Targeted gates
Findings (non-blocking, completeness reporting)F1 — the in-send-compression regression test passes on the unfixed code (coarse revert survives; fine mutation kills it). Reverting the whole PR leaves F2 — the same-route control test cannot pass on base by construction. It asserts the PR's enriched drop message ( Neither finding is a merge condition; both are completeness notes per the mutation-adjudication rules. Not covered
MethodologyEnvironment: CI verify container ( Flakiness gate logEvidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
Maintainer verification — real environment, not unit testsI built this PR locally and reproduced the defect end to end in a real runtime: the bundled Result: the defect is real, user-visible, and this PR fixes it. The gate's existing behaviour is unchanged where it should be. Recommending merge. One correction to the PR description: the Evidence section says How the defect is reachable without touching a debugger
So the trigger is "a user pastes an image into an ACP session that has a vision fallback configured", not a contrived state. Harness
1. Real-environment A/BCase 1 — cross-route (the bug). Route A (
Case 2 — same-route control (the guard against an over-broad fix). Route B records the 9000 itself, then a second route-B image turn is sent.
The 2. Mutation testing — do the PR's own tests actually hold the fix down?Nine single-behaviour mutations of The 2 survivors are equivalent mutants, not coverage gaps, and I checked both rather than assuming:
Both are dead defensive code rather than risk. Fine to leave; noting them only so a future reader doesn't mistake them for coverage. 3. The new diagnostic, captured from a real runBoth lines were captured from actual 4. Merge readiness
What I did not verify live (scope, for the record)
VerdictThe change is correctly scoped: it moves one private cache from route-blind to route-keyed and leaves same-route semantics bit-for-bit identical, which is exactly what the real A/B shows. The defect it closes is a silently dropped user turn on a perfectly ordinary configuration (text primary + vision fallback + an image). LGTM — merge. 中文说明维护者验证 —— 真实环境,不是单测我在本地构建了这个 PR,并在真实运行时里端到端复现了这个缺陷:打包后的 结论:缺陷真实存在、用户可见,这个 PR 确实修好了它;同时该拦截的场景行为逐字未变。建议合入。 对 PR 描述的一处更正:前后证据 一节写的是 这个缺陷怎么在正常使用中被触发让拦截门回退去读
所以触发条件是「用户往一个配了视觉回退模型的 ACP 会话里贴了张图」,而不是人为构造的状态。 验证台
1. 真实环境 A/B用例 1 —— 跨路由(缺陷本身)。 路由 A(
用例 2 —— 同路由对照(防止修得过宽)。 路由 B 自己记录 9000,然后再发一次路由 B 的图片轮次。
截图里每个面板的 2. 变异测试 —— PR 自带的测试真的守得住这个修复吗对 2 个存活的是等价变异体,不是测试盲区,这两个我都实际查了、没有想当然:
两者都属于「死掉的防御性代码」而非风险。保留无妨,写在这里只是免得以后有人把它们误当成覆盖率缺口。 3. 新增诊断,取自真实运行截图里前后两行都是从实际的 4. 合入就绪度
未做真机验证的部分(范围说明)
结论这个改动的范围是准确的:它把一个私有缓存从「不分路由」改成「按路由 key」,同路由语义逐字未变——真实 A/B 显示的正是这一点。它关掉的是一个在非常普通的配置(文本主模型 + 视觉回退 + 一张图片)下静默丢弃用户一轮对话的缺陷。LGTM,建议合入。 |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 773 passed · 0 failed · 773 total Flakiness gate: 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:773 通过 · 0 失败 · 773 总计 抖动门: Verification reportPR #9862 verification (follow-up round) — fix(acp): route-scope the session token-limit cache in Session.tsVerdict: Previous-finding status (follow-up round)
No finding was declined or deferred in the previous round, so there is nothing to re-price; both rows above were re-run, not diffed. 中文 — 判定:✅ 通过 · 可合入(agent 判定)这是第二轮验证:PR head(
Central claim and A/BCentral claim (unchanged from the previous round): after an ACP model switch that keeps the same The control differs from the merged head by exactly one file:
The 10 base reds fail with the defect's behavioral signatures: Merge-specific re-verification (this round's delta). Mutation matrix (merged head, one mutant at a time, target test in isolation, file restored and blob-verified between mutants)Witness:
0 survivors, and the kill signatures are behavioral (expected-vs-actual stopReason / route-key mismatches), not import or fixture breakage. M0 proves the harness detects gate removal in both directions. (One harness iteration with an unescaped Targeted gates
FindingsNo new findings this round. The two carried non-blocking completeness notes (F1, F2 in the status table above) were re-measured and stand; neither is a merge condition. Not covered
MethodologyEnvironment: CI verify container ( Flakiness gate logEvidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅










What this PR does
Route-scopes the ACP session's private token cache so a model switch can no longer make a stale count from the previous route trip the session-token-limit gate. The cache now records which model route produced it (via
Config.getModelRouteIdentity()) and is invalidated whenever the route changes, the same way #9506 scoped the GeminiChat counts. Same-route counting and the existing chat-instance reset are unchanged.Why it's needed
Follow-up to #9506 (route-scoped token counts, #9454).
packages/cli/src/acp-integration/session/Session.tskeeps its own privatelastPromptTokenCountfed from streamedusageMetadataand resets it only when the chat instance changes (#syncPromptTokenCountWithCurrentChat). ACP model switches (unstable_setSessionModel→session.setModel→config.switchModel) rebuild the content generator but keep the sameGeminiChat, so that reset never fires on a route change. Any send that reaches the gate withcompressionInfonull — amodelOverridesend (compression is skipped) or a send whose compression attempt throws — then reads the stale pre-switch count through#getPostCompressionTokenCount(null)and is wrongly dropped withSessionTokenLimitExceeded/stopReason: 'max_tokens'. #9506's route-scoping machinery (GeminiChat getters + telemetry mirror) cannot reach this private cache, so the surviving instance of the defect class needed the same treatment. See #9529.Reviewer Test Plan
How to verify
Added a failing-then-passing test that drives the real gate:
does not drop a route-B send using a stale route-A token count after a model switch (#9529). Route A records an API-reported101withsessionTokenLimit=100; the model is switched to route B on the same chat instance; a compression-throw send on route B must proceed (end_turn) instead of being dropped (max_tokens). Before the fix it resolved{ stopReason: 'max_tokens' }; after,{ stopReason: 'end_turn' }. A same-route control test (still intercepts a same-route send whose recorded count exceeds the session token limit (#9529)) pins that the gate still trips when the route never changes, so only the cross-route case is altered. Run:cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts -t "#9529". The fullSession.test.ts(694 tests) passes. ThemodelOverridetrigger path is covered by the same code path and was confirmed by walking the four#sendMessageStreamWithAutoCompressioncall sites — each routes through the same gate, and the route-change reset lives in#syncPromptTokenCountWithCurrentChat, which runs before the gate on everycompressionInfo === nullsend.Evidence (Before & After)
N/A — ACP session-internal logic with no user-visible UI. Red/green test output is in "How to verify" above (before:
AssertionError: expected { stopReason: 'max_tokens' } to deeply equal { stopReason: 'end_turn' }; after: passing).Tested on
Environment (optional)
Unit tests only (
vitest run); no runtime/sandbox.Risk & Scope
getModelRouteIdentity()changes between recording and the next gate read. This matches fix(core): invalidate token counts recorded for a switched model route #9506's semantics (fall back to the fresh send's own count) and is strictly safer than the prior route-blind behavior.buildContextUsagedisplay still reads the raw cache (display-only, pre-existing behavior).Linked Issues
Fixes #9529
中文说明
这个 PR 做了什么
把 ACP 会话私有的 token 缓存按模型路由(route)作用域化,使模型切换后不会再让上一个路由遗留的陈旧计数误触发会话 token 上限拦截。缓存现在会记录产生它的模型路由(通过
Config.getModelRouteIdentity()),并在路由变化时失效,和 #9506 对 GeminiChat 计数做的作用域化方式一致。同一路由下的计数行为、以及既有的「chat 实例变化时重置」语义都保持不变。为什么需要
这是 #9506(按路由作用域化 token 计数,#9454)的后续。
packages/cli/src/acp-integration/session/Session.ts自己维护一个私有的lastPromptTokenCount,由流式usageMetadata喂入,且只在 chat 实例 变化时重置(#syncPromptTokenCountWithCurrentChat)。而 ACP 模型切换(unstable_setSessionModel→session.setModel→config.switchModel)会重建 content generator 但保留同一个GeminiChat,所以这个重置在路由变化时永远不会触发。凡是compressionInfo为 null 就到达拦截门的发送——modelOverride发送(跳过压缩)或压缩尝试抛错的发送——都会通过#getPostCompressionTokenCount(null)读到切换前的陈旧计数,被错误地以SessionTokenLimitExceeded/stopReason: 'max_tokens'丢弃。#9506 的路由作用域机制(GeminiChat getter + telemetry 镜像)触及不到这个私有缓存,因此这一类缺陷残存的最后一处也需要同样的处理。见 #9529。评审测试计划
如何验证
新增了一个「先失败后通过」的测试,直接驱动真实的拦截门:
does not drop a route-B send using a stale route-A token count after a model switch (#9529)。路由 A 在sessionTokenLimit=100下记录了 API 上报的101;随后在同一个 chat 实例上切换到路由 B;此时一次压缩抛错的发送在路由 B 上必须放行(end_turn),而不是被丢弃(max_tokens)。修复前它返回{ stopReason: 'max_tokens' },修复后返回{ stopReason: 'end_turn' }。另有一个同路由对照测试(still intercepts a same-route send whose recorded count exceeds the session token limit (#9529))确保路由不变时拦截门仍会触发,从而只有跨路由场景被改变。运行:cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts -t "#9529"。完整Session.test.ts(694 个测试)全部通过。modelOverride触发路径走的是同一条代码路径,已通过走查四个#sendMessageStreamWithAutoCompression调用点确认——它们都经过同一个拦截门,而路由变化重置位于#syncPromptTokenCountWithCurrentChat,该方法在每个compressionInfo === null的发送到达拦截门之前都会执行。前后证据
N/A——属于 ACP 会话内部逻辑,无用户可见 UI。红/绿测试输出见上方「如何验证」(修复前:
AssertionError: expected { stopReason: 'max_tokens' } to deeply equal { stopReason: 'end_turn' };修复后:通过)。测试环境
环境(可选)
仅单元测试(
vitest run);无运行时/沙箱。风险与范围
getModelRouteIdentity()发生变化就会被丢弃。这与 fix(core): invalidate token counts recorded for a switched model route #9506 的语义一致(回退到当前这次发送自身的计数),并且严格比之前「不分路由」的行为更安全。buildContextUsage展示仍然读取原始缓存(纯展示、既有行为)。关联 Issue
Fixes #9529