fix(cli): source /context token total from the per-session chat#5764
Conversation
collectContextData read apiTotalTokens from uiTelemetryService, a process-global singleton shared by every session in a serve daemon, so each session's /context-usage reported whichever session most recently completed a turn. Read the per-session GeminiChat's count instead (config already in scope), falling back to the global singleton only when no chat is initialized yet. Fixes both /context and the serve GET /session/:id/context-usage route at the source. Closes QwenLM#5763 Co-authored-by: Qwen-Coder <[email protected]>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Hi @qqqys, thanks for the PR — the fix itself looks well-targeted.
The PR body is missing the Reviewer Test Plan section from the PR template. The template also expects Risk & Scope, Linked Issues, and a 中文说明 details block.
Could you add these sections? The Reviewer Test Plan in particular is important — without it, maintainers can't quickly verify the fix. Something like:
- How to verify: steps to reproduce the multi-session
/context-usagebug and confirm it's fixed - Tested on: which OS you verified on
The code change and tests look good — just the PR description needs the template structure. Happy to re-review once updated. 🙏
中文说明
@qqqys 你好,感谢 PR — 修复本身很精准。
PR 正文缺少 PR 模板 中要求的 Reviewer Test Plan 部分,同时也缺少 Risk & Scope、Linked Issues 和 中文说明 折叠块。
请补充这些部分,特别是 Reviewer Test Plan — 维护者需要它来快速验证修复。建议包含:
- How to verify: 复现多会话
/context-usagebug 并确认修复的步骤 - Tested on: 验证过的操作系统
代码改动和测试看起来不错 — 只需要补全 PR 描述结构。更新后会重新审查。🙏
— Qwen Code · qwen3.7-max
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running.
— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
|
Thanks for the PR! Template looks good ✓ On direction: this is a real bug — On approach: the change is minimal — 12 lines of production code, 42 lines of tests covering both the happy path (per-session chat initialized) and the fallback (not yet initialized). No scope creep, no drive-by refactors. The optional chaining on Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 方向:这是一个真实的 bug —— 方案:改动极小 —— 12 行生产代码,42 行测试覆盖了主路径(session chat 已初始化)和回退路径(尚未初始化)。没有范围蔓延,没有顺手重构。 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewClean fix. The approach — prefer per-session chat token count, fall back to global singleton — is the obvious and correct solution. I would have written the same code. Reviewed the production change in
No correctness issues, no security concerns, no regressions. The diff is minimal and focused — exactly what the stated goal needs, nothing more. TestsBoth new tests pass (9/9 total in
Real-Scenario TestingThis bug only manifests in Verified the dev build runs correctly: The unit tests adequately cover the logic change. The serve-mode integration scenario is best verified manually per the Reviewer Test Plan in the PR description. 中文说明代码审查干净的修复。优先使用 per-session chat 的 token 计数、回退到全局单例的方案是显而易见且正确的做法。我也会写同样的代码。 审查了
没有正确性问题,没有安全隐患,没有回归。Diff 最小且聚焦 —— 恰好是目标所需的,没有更多。 测试两个新测试都通过了(
真实场景测试这个 bug 只在 已验证 dev 构建正常运行。单元测试充分覆盖了逻辑变更。Serve 模式集成场景最好按 PR 描述中的 Reviewer Test Plan 手动验证。 — Qwen Code · qwen3.7-max |
|
This is a textbook bugfix PR: real bug, minimal change, clear tests, honest description of tradeoffs. The The diff is 12 production lines. No abstractions, no helper functions, no "while I'm here" refactors. Two focused tests that prove the fix works and that the fallback isn't broken. If every PR looked like this, maintainers would have a lot less to do. LGTM. ✅ 中文说明这是一个教科书式的 bugfix PR:真实的 bug、最小的改动、清晰的测试、诚实的权衡描述。
Diff 只有 12 行生产代码。没有抽象,没有辅助函数,没有"顺便"重构。两个聚焦的测试证明修复有效且回退没有被破坏。如果每个 PR 都像这样,维护者的工作会轻松很多。 LGTM. ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
✅ Local verification — per-session
|
| Session (last turn = B) | Before (83caca65e) |
After (this PR) |
|---|---|---|
A (TOKENS=11111) |
totalTokens=222222, tier hard ❌ (leaks B's global value) |
totalTokens=11111, tier safe ✅ |
B (TOKENS=222222) |
totalTokens=222222, tier hard |
totalTokens=222222, tier hard |
Before — both sessions report the last-completed-turn's global value; session A's own 11111 is lost:
[A] context-usage: totalTokens=222222 tier=hard
[B] context-usage: totalTokens=222222 tier=hard
RESULT: globalBugReproduced=true, perSessionCorrect=false
After — each session reports its own value (and its own tier):
[A] context-usage: totalTokens=11111 tier=safe
[B] context-usage: totalTokens=222222 tier=hard
RESULT: perSessionCorrect=true, globalBugReproduced=false
2) CLI /context unchanged (test-plan step 5)
Single TUI session, prompt TOKENS=12345, then /context:
█ Used 12.3k tokens (9.4%)
Current tier safe
Headline total (12.3k = 12345) is the per-session chat value — single-session CLI /context is behaviorally correct/unchanged (the global and chat values coincide there).
Verdict
The fix holds end-to-end on a real daemon: concurrent serve sessions each report their own /context-usage prompt-token total and tier instead of the last-completed-turn's global value, while single-session CLI /context is unchanged. LGTM for merge.
中文版(合并参考)
✅ 本地真实验证 —— qwen serve 中按 session 的 /context token 总数
在真实构建的 daemon(npm ci && npm run build,head df30f5d03,独立 worktree,Linux)上做了端到端验证:通过 tmux 启动 qwen serve,指向一个确定性 mock OpenAI 服务器,用真实的 TypeScript SDK DaemonClient 驱动,并与 merge-base 83caca65e 做 A/B 对比。
环境
- Daemon ——
qwen serve --workspace <ws> --port … --token …,env 把模型指向 mock OpenAI。bridge 每个 workspace 只起一个qwen --acp子进程,所有 session 多路复用到它上面(bridge.ts),因此uiTelemetryService单例在多个 session 间是真正共享的 —— 正是 Multi-session /context-usage reports a process-global token count instead of the per-session value #5763 描述的条件。 - Mock —— 从 prompt 中的
TOKENS=N标记解析并返回usage.prompt_tokens = N,让每个 session 记录到不同的 per-session 总数。 - 两个
sessionScope:'thread'session。A 先完成 turn(记录 11111),B 后完成(记录 222222),因此进程级全局单例最终停在 B 的值。
1) Serve GET /session/:id/context-usage —— A/B
| Session(最后一个 turn = B) | 改动前(83caca65e) |
改动后(本 PR) |
|---|---|---|
A(TOKENS=11111) |
totalTokens=222222,tier hard ❌(泄漏了 B 的全局值) |
totalTokens=11111,tier safe ✅ |
B(TOKENS=222222) |
totalTokens=222222,tier hard |
totalTokens=222222,tier hard |
改动前 —— 两个 session 都报告最后完成 turn 的全局值,A 自己的 11111 丢失(globalBugReproduced=true)。
改动后 —— 每个 session 报告各自的值(以及各自的 tier):A=11111/safe,B=222222/hard(perSessionCorrect=true)。
2) CLI /context 行为不变(验证步骤 5)
单个 TUI session,发送 TOKENS=12345,再运行 /context:headline 总数 Used 12.3k tokens(=12345),tier safe —— 即 per-session chat 值;单 session CLI /context 行为正确/不变(此处全局值与 chat 值一致)。
结论
该修复在真实 daemon 上端到端成立:并发的 serve session 各自报告自己的 /context-usage prompt-token 总数和 tier,而不再是最后完成 turn 的全局值;同时单 session CLI /context 保持不变。LGTM,可以合并。
Verified by @wenshao on Linux via a real built daemon + mock OpenAI server driven over the TS SDK (head df30f5d03 vs merge-base 83caca65e).
✅ Local runtime verification (real
|
| source | getLastPromptTokenCount() on the chat |
data.totalTokens |
tier |
|---|---|---|---|
| base (reverted) | never called ❌ (reads the global singleton) | would be 999_000 |
hard |
| PR | called ✅ | 50_000 |
safe |
- PR → both new tests pass.
- base → the per-session test fails:
expected "spy" to be called at least once— base readsuiTelemetryServicedirectly and never consults the per-session chat, so a stale cross-session total (and a wronghardtier) leaks through. - Full
contextCommand.test.tson PR: 9/9, no regression.
Live daemon E2E (the fix is wired into the real route)
Real qwen serve (PR bundle, tmux) + mock model that returns a controllable usage.prompt_tokens:
POST /session/:id/prompt {prompt:[{type:"text",text:"PTOK=50000 hi"}]}
→ mock reports prompt_tokens=50000
GET /session/:id/context-usage
→ usage.totalTokens = 50000, breakdown.currentTier = "safe"
This exercises the full path GET /session/:id/context-usage → bridge → ACP child → collectContextData → geminiClient.getChat().getLastPromptTokenCount() — i.e. the fix is live on the route that actually mattered (#5763).
Single-session parity (PR claim: CLI /context unchanged)
Same prompt on the base daemon vs the PR daemon → both report totalTokens=50000, tier=safe. In a single session the chat value and the global coincide, so single-session /context behavior is unchanged, as the PR states.
Note on a live 2-session A/B
A single daemon can't host two concurrent distinct sessions over HTTP (it binds one workspace and coalesces same-cwd POSTs; a foreign cwd returns workspace_mismatch; fork is an agentic sub-agent, too noisy to measure cleanly). The two-session bleed is therefore reproduced deterministically by the unit A/B above (which models the shared-singleton condition exactly), while the live daemon E2E confirms the route reads the per-session chat. Together they cover both the bug's manifestation and the fix's wiring.
Verdict
The fix sources the /context headline total from the per-session chat (with a correct fallback to the global when no chat exists yet), proven by a deterministic A/B and confirmed live on the real qwen serve route, with no single-session regression. LGTM. ✅
中文版(合并参考)
✅ 本地真实运行时验证(tmux 下真实 qwen serve daemon + 确定性 A/B)
从本 PR 的最新 head(df30f5d03)构建了两个真实 bundle——一个含修复,另一个把 contextCommand.ts 回退到 merge-base——并通过 tmux 中真实的 qwen serve daemon(配 mock OpenAI 模型)以及确定性单元 A/B,验证了 collectContextData。
架构(已确认的 bug 条件)
qwen serve --http-bridge 绑定一个 workspace,并把 N 个 session 复用到同一个 qwen --acp 子进程上(已确认:只有一个 --acp 子进程,QwenAgent 持有 Map<string, Session>)。每个 session 的 GeminiChat 都在这一个进程里,并把各自的 prompt-token 数镜像到共享的模块级 uiTelemetryService 单例——于是该单例只保存“最近完成 turn”的那个 session 的值(#5763)。
跨 session 串值——确定性 A/B(权威)
packages/cli/src/ui/commands/contextCommand.test.ts 复现了 daemon 的确切条件:某个“错误” session 的全局值(999_000)对比本 session 的 chat 值(50_000)。
| 源码 | chat 上的 getLastPromptTokenCount() |
data.totalTokens |
tier |
|---|---|---|---|
| base(回退) | 从未调用 ❌(读全局单例) | 会是 999_000 |
hard |
| PR | 调用 ✅ | 50_000 |
safe |
- PR → 两个新测试通过。
- base → per-session 测试失败:
expected "spy" to be called at least once——base 直接读uiTelemetryService,从不查 per-session chat,于是过期的跨 session 值(以及错误的hardtier)就泄漏了。 - PR 上完整
contextCommand.test.ts:9/9,无回归。
真实 daemon E2E(修复已接入真实 route)
真实 qwen serve(PR bundle,tmux)+ 可控 usage.prompt_tokens 的 mock 模型:
POST /session/:id/prompt {prompt:[{type:"text",text:"PTOK=50000 hi"}]}
→ mock 返回 prompt_tokens=50000
GET /session/:id/context-usage
→ usage.totalTokens = 50000, breakdown.currentTier = "safe"
这条链路 GET /session/:id/context-usage → bridge → ACP child → collectContextData → geminiClient.getChat().getLastPromptTokenCount() 完整跑通——即修复确实接在了真正出问题的那条 route 上(#5763)。
单 session 一致性(PR 声称:CLI /context 不变)
相同 prompt 在 base daemon 与 PR daemon 上都报 totalTokens=50000, tier=safe。单 session 时 chat 值与全局值一致,因此单 session 的 /context 行为不变,与 PR 描述一致。
关于实机 2-session A/B 的说明
单个 daemon 无法通过 HTTP 同时承载两个不同的 session(它绑定一个 workspace,并对同 cwd 的 POST 做合并;异 cwd 会返回 workspace_mismatch;fork 是 agentic 子代理,测量噪声太大)。因此双 session 串值由上面的单元 A/B 确定性复现(它精确建模了共享单例的条件),而真实 daemon E2E 证明 route 读取的是 per-session chat。二者合起来覆盖了 bug 的表现与修复的接线。
结论
修复让 /context 的 headline 总数来自 per-session chat(并在尚无 chat 时正确回退到全局),经确定性 A/B 证明、并在真实 qwen serve route 上实机确认,单 session 无回归。LGTM。 ✅
✅ Independent re-verification — the live 2-session
|
A (→11111) |
B (→77777, last turn) |
distinct sessions | |
|---|---|---|---|
Before (parent ac1e4737, dist fix-line = 0) |
77777 ❌ — leaks B's global value | 77777 | ✅ true |
After (PR df30f5d0, dist fix-line = 1) |
11111 ✅ — its own | 77777 | ✅ true |
Raw driver output (RESULT: lines, abbreviated):
AFTER : {"distinctSessions":true,"observed":{"A_totalTokens":11111,"B_totalTokens":77777},"A_isolated":true, "A_leaked_B":false}
BEFORE: {"distinctSessions":true,"observed":{"A_totalTokens":77777,"B_totalTokens":77777},"A_isolated":false,"A_leaked_B":true}
The discriminator is session A: before the fix it reports 77777 (B's count — the most-recently-completed turn); after the fix it reports its own 11111. B reads 77777 either way (correct by luck, since it ran last).
Note (reconciling the two notes above)
Two concurrent distinct sessions on one daemon are reachable — via POST /session with sessionScope:'thread' (the SDK's per-request override, CreateSessionRequest.sessionScope: "forces a distinct session for this call"). No workspace_mismatch, no fork sub-agent, no same-cwd coalescing. So the cross-session bleed is observable live on the route that actually mattered, complementing the deterministic unit A/B.
Verdict
Confirms the fix end-to-end on a real daemon: concurrent serve sessions each report their own /context-usage prompt-token total instead of the last-completed-turn's global value. LGTM for merge.
中文版(合并参考)
✅ 独立复核 —— 真实的「双 session」serve A/B 是可复现的
在一份全新构建上重跑,专门厘清上面两条验证说明分歧的那一点:单个 daemon 能否同时承载两个不同的并发 session、从而在线上复现跨 session 串值。可以 —— 通过 SDK 文档化的 sessionScope:'thread' 覆盖项 —— 因此 #5763 的串值能在真实的 GET /session/:id/context-usage route 上线上复现,而不仅是单元 A/B。
环境
- 全新 worktree,head
df30f5d0,npm ci && npm run build,Linux。 - 在 tmux 中启动真实的
qwen serve --workspace <ws> --port 0 --token … --hostname 127.0.0.1;模型 env 指向本地 fake OpenAI server。 - 一个 daemon ⇒ 一个
qwen --acp子进程,两个 session 都复用到它上面,因此共享模块级uiTelemetryService单例 —— 正是 Multi-session /context-usage reports a process-global token count instead of the per-session value #5763 的条件。 - fake server 根据 prompt 里的标记返回
usage.prompt_tokens/total_tokens:A → 11111,B → 77777。 - 驱动(真实 TS SDK
DaemonClient):创建 A(sessionScope:'thread')、创建 B(sessionScope:'thread')→ 两个不同的 session id;先 prompt A,再 prompt B(使共享单例最终停在 B 的77777);随后对两者分别GET …/context-usage。 - A/B 只在 PR(HEAD)与其父提交
ac1e4737(改动前)之间切换contextCommand.ts,仅重建@qwen-code/qwen-code—— 两次运行使用完全相同的 daemon 测试装置。
结果 —— GET /session/:id/context-usage → usage.totalTokens
A(→11111) |
B(→77777,最后一个 turn) |
是否不同 session | |
|---|---|---|---|
改动前(父提交 ac1e4737,dist fix 行 = 0) |
77777 ❌ —— 泄漏了 B 的全局值 | 77777 | ✅ 是 |
改动后(PR df30f5d0,dist fix 行 = 1) |
11111 ✅ —— 各自的值 | 77777 | ✅ 是 |
判别点在 session A:改动前它报告 77777(B 的值 —— 最近完成 turn 的那个);改动后它报告自己的 11111。B 两种情况都是 77777(碰巧正确,因为它最后运行)。
说明(与上面两条说明的对账)
单个 daemon 上的两个并发不同 session 是可达的 —— 通过 POST /session 带 sessionScope:'thread'(SDK 的按请求覆盖项,CreateSessionRequest.sessionScope:“forces a distinct session for this call”)。无需 workspace_mismatch、不依赖 fork 子 agent、也不会被同 cwd 合并。因此跨 session 串值能在真正出问题的那条 route 上线上观测到,与确定性单元 A/B 相互印证。
结论
在真实 daemon 上端到端确认了该修复:并发的 serve session 各自报告自己的 /context-usage prompt-token 总数,而不再是最后完成 turn 的全局值。LGTM,可以合并。
What this PR does
collectContextData, which powers both CLI/contextand the serveGET /session/:id/context-usageroute, now reads the prompt token total from the current session'sGeminiChatinstead of the process-globaluiTelemetryServicesingleton. It still falls back to the singleton when no chat is initialized yet.Why it's needed
In a
qwen servedaemon, multiple sessions share one process. The global telemetry singleton stores only the last completed turn across that process, so every session's/context-usagecould report the same prompt-token total: whichever session most recently completed a turn. The per-session chat already tracks the correct value.Reviewer Test Plan
How to verify
qwen serveand create two sessions.GET /session/:id/context-usagefor both sessions./contextin a single active session and confirm the reported total remains unchanged from existing behavior.Evidence (Before & After)
Before:
collectContextDatareaduiTelemetryService.getLastPromptTokenCount(), a process-global value shared by all serve sessions.After: it prefers
config.getGeminiClient().getChat().getLastPromptTokenCount()when the session chat is initialized, with tests covering both the per-session path and the uninitialized fallback path.Tested on
Environment (optional)
Focused context data tests and existing context command / ACP agent suites.
Risk & Scope
/contextshould be behaviorally unchanged because the global value and that session's chat value coincide there.Linked Issues
Closes #5763
中文说明
这个 PR 做了什么
collectContextData同时支撑 CLI/context和 serve 的GET /session/:id/context-usage。它现在优先从当前 session 的GeminiChat读取 prompt token 总数,而不是读取进程级全局uiTelemetryService单例;当 chat 尚未初始化时仍回退到全局值。为什么需要
qwen servedaemon 里多个 session 共享同一个进程。全局 telemetry 单例只保存整个进程最近一次完成 turn 的 token 数,因此不同 session 的/context-usage可能都显示同一个值:最后完成 turn 的那个 session 的值。每个 session 自己的 chat 已经保存了正确值。评审验证步骤
qwen serve并创建两个 session。GET /session/:id/context-usage。/context,确认输出与既有行为一致。风险与范围
/context行为应保持不变,因为全局值与该 session chat 值一致。