Skip to content

fix(cli): keep /context detail MCP rows within the MCP tools row - #13390

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
Shizoqua:fix/context-detail-mcp-rows
Oct 5, 2026
Merged

wenshao merged 1 commit into
QwenLM:mainfrom
Shizoqua:fix/context-detail-mcp-rows

Conversation

@Shizoqua

@Shizoqua Shizoqua commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

What this PR does

/context detail now scales its per-tool MCP rows by the same correction the MCP tools row already gets, so the rows listed under MCP tools add up to that row instead of to the raw schema sizes. This applies on both the estimate path and the provider-count path.

Why it's needed

Since #13246, billed tool schemas that are not in the declared tool list (for example with tools.codeModeOnly and an MCP server whose tools are always loaded) are charged back to the MCP tools row. The per-tool rows under it kept the raw schema sizes, so /context detail listed more MCP tokens than the row it sits under. In the existing clamp test fixture the MCP detail rows added up to 128 tokens while the MCP tools row showed 17. This was the detail/category mismatch deferred from the #13246 review.

The fix computes the share of the MCP schemas the MCP row keeps and applies it to the MCP detail rows, on top of the existing overhead scale on the provider-count path. The small scaling helper is now shared by both paths, replacing the one that only existed on the provider-count path.

Reviewer Test Plan

How to verify

With code mode and an MCP server whose tools are always loaded, run /context detail before the first reply and again after it. The MCP tool rows should add up to the MCP tools row in both cases. Sessions where the declared tools cover the MCP schemas are unchanged, because the share is then 1.

Evidence (Before & After)

The existing clamp test now also checks, with details on, that the MCP detail rows add up to the MCP tools row on the estimate path, the estimate path with nothing declared, and the provider-count path. Before the fix:

× collectContextData (contextCommand) > category identity (#12033) > charges the builtin-clamp deficit to the mcp row, not to messages
  → expected 128 to be 17

After the fix it passes, along with every existing test file that renders /context (8 files, 2770 tests).

A screenshot isn't included because reproducing this in the app needs code mode plus a live MCP server with always-loaded tools; the test above uses the same configuration.

Tested on

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

Environment (optional)

Unit tests and npm run preflight on Linux, in a container running as root, where a set of existing tests fail on a clean main because they rely on file permissions or signals that root ignores. Besides those, two daemon tests (managed-runtime-session-worker, workspace-agents) failed under the full parallel run; both files pass when run on their own, with and without this change, and neither touches /context.

Risk & Scope

  • Main risk or tradeoff: low. Only the MCP detail rows change, and only when the MCP row has been charged back. With several MCP tools, each row is rounded on its own, so the list can differ from the MCP row by a token or two of rounding, as the other scaled detail rows already can.
  • Not validated / out of scope: the skills detail rows are unchanged; the remainder charged to skills comes from the Skill tool definition, which has no detail row of its own.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #13389

中文说明

此 PR 做了什么

/context detail 现在会用 MCP tools 行已经使用的同一修正来缩放每个 MCP 工具的明细行,使列在 MCP tools 下面的各行加起来等于该行,而不是原始 schema 的大小。这一修改同时适用于估算路径和提供商计数路径。

为什么需要

自 #13246 起,不在声明的工具列表中但已计费的工具 schema(例如启用 tools.codeModeOnly 并且 MCP 服务器的工具始终加载时)会被扣回到 MCP tools 行。但其下的每工具明细行仍保持原始 schema 大小,因此 /context detail 列出的 MCP token 多于其所在的行。在现有的 clamp 测试用例中,MCP 明细行加起来为 128 个 token,而 MCP tools 行显示 17。这是 #13246 评审中延后处理的明细与分类不一致问题。

此修复计算 MCP 行保留的 MCP schema 比例,并将其应用到 MCP 明细行上;在提供商计数路径上,它叠加在已有的开销缩放之上。这个小的缩放辅助函数现在由两条路径共用,取代了原先只存在于提供商计数路径上的那个。

审阅者测试计划

如何验证

在启用 code mode 并连接工具始终加载的 MCP 服务器时,分别在第一次回复之前和之后运行 /context detail。两种情况下 MCP 工具行都应加起来等于 MCP tools 行。如果声明的工具已经覆盖 MCP schema,则比例为 1,会话行为保持不变。

证据(修改前后)

现有的 clamp 测试现在还会在开启明细时检查:在估算路径、未声明任何工具的估算路径以及提供商计数路径上,MCP 明细行之和都等于 MCP tools 行。修复前该检查失败(预期 17,实际 128)。修复后该测试通过,所有渲染 /context 的现有测试文件也全部通过(8 个文件,2770 个测试)。

这里没有附截图,因为要在应用中复现需要开启 code mode 并连接一个工具始终加载的真实 MCP 服务器;上面的测试使用的就是同样的配置。

测试平台

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

环境(可选)

在 Linux 上运行了单元测试和 npm run preflight,环境是以 root 身份运行的容器,在干净的 main 上已有一组测试失败,因为它们依赖 root 会忽略的文件权限或信号。除此之外,有两个守护进程相关的测试(managed-runtime-session-worker、workspace-agents)在完整并行运行时失败;这两个文件单独运行时,无论是否包含此修改都能通过,且都与 /context 无关。

风险与范围

  • 主要风险或权衡:风险低。只有 MCP 明细行会变化,而且只在 MCP 行被扣回时才变化。当有多个 MCP 工具时,每一行各自舍入,因此列表之和可能与 MCP 行相差一两个 token 的舍入误差,这与其他已缩放的明细行情况相同。
  • 未验证 / 超出范围:skills 明细行保持不变;计入 skills 的剩余部分来自 Skill 工具定义,而它本身没有明细行。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #13389

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Oct 4, 2026
@wenshao

wenshao commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification: real CLI, code mode, live always-loaded MCP server (head 9c75b71)

Verdict: ready to merge. I reproduced the bug in the real app on main (merge-base fde56a8) and confirmed that this PR fixes it on all three surfaces that render /context detail: the interactive TUI, the SDK/stream-json get_context_usage control request, and the headless text renderer. Sessions where the clamp is not in force produce byte-identical output. The two test-coverage gaps raised in the earlier bot reviews are real, and I confirmed both by mutation. Neither blocks the merge. A ready-to-paste test that closes both is at the end.

Setup

  • Two worktrees built from source with npm run build && npm run bundle (both exit 0): base = fde56a8 (merge-base), PR = 9c75b71. I checked the bundles before running anything. Base has detailMcpTools=mcpTools and scaleDetail(mcpTools), and the PR has scaleTokens(mcpTools,scale*mcpDetailShare).
  • The scenario the PR targets is tools.codeModeOnly: true plus a real stdio MCP server (@modelcontextprotocol/sdk) with alwaysLoadTools: true. It exposes 4 tools whose input schemas carry long per-property descriptions, like real tracker/GitHub-style servers. Code mode renders a binding as a TypeScript signature and drops those property descriptions, while /context measures each tool's full JSON schema. A large server therefore puts the clamp in force.
  • The model is a local OpenAI-compatible fake whose final usage chunk reports a chosen prompt_tokens. That lets me drive both provider-count paths: scale = 1 (40,000) and scale < 1 (12,000 and 5,000).
  • The clamp needs a large enough server. With this server at 1× (3.1k MCP tokens) the clamp never engages. At 4× (11.7k) it engages over stream-json. The interactive TUI declares more tools, so I used 6× (17.5k) there.

Result: interactive TUI (real Ink render, xterm.js capture)

Before the first reply (estimate path):

before first reply

After the first reply (provider total 12,000, below the measured overhead, so scale < 1):

after first reply

The panels are crops of the real screenshots, with captions added. I cut out only the built-in rows in the middle, and those are identical in both arms. Full uncropped screenshots: base before · PR before · base after · PR after.

Result: exact numbers (get_context_usage {show_details:true} over stream-json, MCP server 4×)

Path provider total MCP tools row Σ MCP detail rows, base Σ MCP detail rows, PR
estimate (before first reply) — 9,943 11,667 (+1,724) 9,942 (−1)
provider, scale = 1 40,000 9,943 11,667 (+1,724) 9,942 (−1)
provider, scale < 1 12,000 6,357 7,460 (+1,103) 6,357 (0)
provider, scale < 1 5,000 2,649 3,108 (+459) 2,649 (0)

The PR distributes the clamped row proportionally rather than just matching the total. On the estimate path every row is round(raw × 9,943 / 11,667): search_issues 7,929 → 6,757, create_issue 2,854 → 2,432, list_comments 844 → 719, get_status 40 → 34. Replaying the PR's arithmetic with the real schema sizes reproduces every per-tool row the CLI printed in all three runs I checked.

Rounding drift. I swept every provider total from 1 to 18,768 (the whole range below the overhead) through the same arithmetic with this 4-tool server. Σrows − row was −1 for 4.2% of totals, 0 for 45.8%, +1 for 46.0% and +2 for 4.1%. That matches the "a token or two" disclosed under Risk & Scope, and values ≥ 1k render as x.yk anyway.

Controls: no behaviour change outside the clamp. In each of the following scenarios the full get_context_usage payload was byte-identical between base and PR, both before and after the reply:

  • code mode with an always-loaded server too small to trigger the clamp
  • code mode with MCP tools deferred
  • direct mode with an always-loaded server

The headless text renderer shows the same fix. qwen -p "/context -d" lists the MCP rows as 7.9k/2.9k/844/40 under a 9.5k MCP row on base, and as 6.5k/2.3k/689/33 under the same 9.5k on the PR.

Tests and static checks

  • contextCommand.test.ts passes 59/59 on the PR. To confirm the new assertion catches the bug, I ran the PR's test file against base source. Exactly the extended test fails, with expected 128 to be 17 at contextCommand.test.ts:1115, which reproduces the author's "before" claim.
  • All 8 CLI test files that touch /context passed, 2,771/2,771: contextCommand, ContextUsage, context-usage-labels, item-projection, ControlDispatcher, acpAgent, serve/server, acp-http/transport.
  • eslint --max-warnings 0 and prettier --check are clean on both changed files.

Test coverage gaps, confirmed by mutation (non-blocking)

I placed mutant copies of contextCommand.ts next to the original (untracked, deleted afterwards) and ran the PR's test file against each:

Mutant PR's tests + per-row probe case
first MCP row takes the whole clamped amount, rest 0 (sum-preserving) survives (59/59) killed
Math.round → Math.ceil in scaleTokens survives killed
provider path drops scale (scale * mcpDetailShare → mcpDetailShare) survives killed
provider path back to base (scale only) killed killed
estimate path back to base (raw rows) killed killed

This confirms by execution the R1-1 inline comment (the distribution across rows is not pinned) and point 2 of the stage-2 review (the provider case runs at scale = 1). The third mutant is the one I'd most like covered. In the 12,000 run above it would list about 9,942 tokens of MCP rows under a 6,357 row, which is the same bug on the provider path, and the current tests stay green.

Suggested test: two MCP tools, per-row assertions, both paths, scale < 1 (kills all five mutants; passes on the PR as-is)

Paste inside describe('category identity (#12033)'):

it('splits the clamped mcp row across several tools on both paths', async () => {
  const mcp = (tool: string, pad: number) =>
    Object.defineProperties(Object.create(DiscoveredMCPTool.prototype), {
      name: { value: `mcp__server__${tool}` },
      serverName: { value: 'server' },
      serverToolName: { value: tool },
      schema: {
        value: {
          name: `mcp__server__${tool}`,
          description: `MCP ${tool} ${'x'.repeat(pad)}`,
          parameters: { type: 'OBJECT', properties: {} },
        },
      },
    }) as DiscoveredMCPTool;
  const big = mcp('big', 400);
  const small = mcp('small', 130);
  const controlSchema = {
    name: 'tool_call',
    parameters: { type: 'OBJECT', properties: {} },
  };
  const tools = [
    { ...skillToolDouble, getLoadedSkillContentNames: () => new Map() },
    big,
    small,
    { name: controlSchema.name, schema: controlSchema },
  ];
  const declared = [skillToolSchema, controlSchema];
  const history = [prelude, ...conversation];
  const raw = [big, small].map((t) =>
    estimateContextTextTokens(JSON.stringify(t.schema)),
  );

  // Estimate path: each row is its own schema times the kept share.
  const est = await collectContextData(
    makeChatConfig({ total: 0, tools, declared, history }),
    true,
  );
  const share = est.breakdown.mcpTools / (raw[0]! + raw[1]!);
  expect(share).toBeGreaterThan(0);
  expect(share).toBeLessThan(1);
  expect(est.mcpTools.map((t) => t.tokens)).toEqual(
    raw.map((r) => Math.round(r * share)),
  );

  // Provider path with the total below the overhead, so scale < 1 too.
  const rawOverhead = sumRows(est.breakdown) - est.breakdown.messages;
  const total = Math.floor(rawOverhead / 2);
  const prov = await collectContextData(
    makeChatConfig({ total, tools, declared, history }),
    true,
  );
  const factor = (total / rawOverhead) * share;
  expect(prov.mcpTools.map((t) => t.tokens)).toEqual(
    raw.map((r) => Math.round(r * factor)),
  );
});

Pre-existing, not changed by this PR (possible follow-ups under #12235)

  1. Built-in rows under a 0 Built-in category. In the same clamp scenario, the Built-in tools row is 0 while its detail section still lists 17 tools over stream-json, totalling 9,514 tokens, and 22 in the TUI. This confirms point 1 of the stage-2 review in the real app. It is identical on base and on the PR.
  2. search_memory / manage_memory listed but never sent. In direct mode the request the model received carried neither tool, because getFunctionDeclarations withholds them under the legacy recall protocol. /context detail still lists them as built-in rows (630 + 138 tokens), and that accounts for 768 of the 769-token gap between the Built-in row (8,688) and its rows (9,457). The per-tool loop in collectContextData lacks the isMemoryRecallToolDeclared filter.
  3. Headless /context detail prints nothing. qwen -p "/context detail" prints only Command executed successfully. on both base and PR, because the detail subcommand calls contextCommand.action!(context, 'detail') without returning its message. /context -d works, and the interactive TUI is unaffected.

Not covered

  • macOS and Windows were not run. The change is pure arithmetic with no platform code.
  • The ACP/serve context-usage status (buildSessionContextUsageStatus in acpAgent.ts) was not driven separately. It serializes the same collectContextData result.
  • I did not build a merged-with-main arm. main is 29 commits ahead, none of them touch these files, and git merge-tree is clean. PR CI on 9c75b71 is green.

Evidence (screenshots, per-run JSON, mutation logs, harness: MCP server, fake provider, stream-json and TUI drivers): asserts@6f38922c6f/pr-13390

中文说明

维护者验证:真实 CLI + code mode + 真实常驻加载 MCP 服务器(head 9c75b71)

结论:可以合入。 我在 main(merge-base fde56a8)的真实应用中复现了该问题,并确认本 PR 在渲染 /context detail 的三个界面上都修复了它:交互式 TUI、SDK/stream-json 的 get_context_usage 控制请求、headless 文本渲染。未触发 clamp 的会话输出逐字节不变。此前 bot 评审提出的两处测试覆盖缺口确实存在,我用变异测试分别确认了;两者都不阻塞合入。文末附一个可直接粘贴、能同时补上这两处缺口的测试。

环境

  • 从源码构建两个 worktree,npm run build && npm run bundle 均 exit 0:base = fde56a8(merge-base),PR = 9c75b71。运行前先核对了 bundle:base 中是 detailMcpTools=mcpTools 与 scaleDetail(mcpTools),PR 中是 scaleTokens(mcpTools,scale*mcpDetailShare)。
  • PR 针对的场景是 tools.codeModeOnly: true 加一个 真实 stdio MCP 服务器(@modelcontextprotocol/sdk),配置 alwaysLoadTools: true。它暴露 4 个工具,input schema 带很长的逐属性描述,和真实的 tracker / GitHub 类服务器一样。code mode 把工具绑定渲染成 TypeScript 签名,丢掉了这些属性描述;而 /context 按每个工具的完整 JSON schema 计量。所以服务器足够大时 clamp 就会生效。
  • 模型是本地 OpenAI 兼容假服务,最后的 usage chunk 返回指定的 prompt_tokens,借此覆盖 provider 计数的两条路径:scale = 1(40,000),以及 scale < 1(12,000 和 5,000)。
  • clamp 需要足够大的服务器才会触发。该服务器在 1× 体量(MCP 3.1k token)时 clamp 不会生效;4×(11.7k)时在 stream-json 下生效。交互式 TUI 声明的工具更多,因此 TUI 用了 6×(17.5k)。

结果:交互式 TUI(真实 Ink 渲染,xterm.js 截图)

首次回复前(估算路径):

首次回复前

首次回复后(provider 总量 12,000,低于实测开销,因此 scale < 1):

首次回复后

图中面板裁剪自真实截图并加了说明文字;中间只裁掉了 built-in 明细行,这部分两边完全一致。完整未裁剪截图:base 回复前 · PR 回复前 · base 回复后 · PR 回复后。

结果:精确数值(经 stream-json 发送 get_context_usage {show_details:true},MCP 服务器 4×)

路径 provider 总量 MCP tools 行 MCP 明细行之和(base) MCP 明细行之和(PR)
估算(首次回复前) — 9,943 11,667(+1,724) 9,942(−1)
provider,scale = 1 40,000 9,943 11,667(+1,724) 9,942(−1)
provider,scale < 1 12,000 6,357 7,460(+1,103) 6,357(0)
provider,scale < 1 5,000 2,649 3,108(+459) 2,649(0)

PR 是按比例把 clamp 后的行分配到各工具,而不只是让总和对上。估算路径上每一行都等于 round(raw × 9,943 / 11,667):search_issues 7,929 → 6,757,create_issue 2,854 → 2,432,list_comments 844 → 719,get_status 40 → 34。用真实 schema 大小重放 PR 的算术,在我核对的三次运行中逐行复现了 CLI 打印的每一个值。

舍入漂移。 对这个 4 工具服务器,我把 1 到 18,768(低于开销的全部区间)的每个 provider 总量都代入同一算术。Σ明细 − 行 为 −1 的占 4.2%,0 占 45.8%,+1 占 46.0%,+2 占 4.1%。这与 Risk & Scope 中披露的"一两个 token"一致,而且 ≥ 1k 的值本来就显示为 x.yk。

对照组:clamp 之外行为不变。 以下每个场景中,base 与 PR 的完整 get_context_usage payload 在回复前后都 逐字节一致:

  • code mode,常驻加载的服务器小到不足以触发 clamp
  • code mode,MCP 工具为延迟加载
  • direct 模式,服务器常驻加载

headless 文本渲染也体现了同样的修复:qwen -p "/context -d" 在 base 上列出 7.9k/2.9k/844/40,挂在 9.5k 的 MCP 行下;在 PR 上列出 6.5k/2.3k/689/33,挂在同一个 9.5k 下。

测试与静态检查

  • PR 上 contextCommand.test.ts 59/59 通过。为确认新断言能抓到这个 bug,我把 PR 的测试文件跑在 base 源码上:恰好只有被扩展的那个用例失败,报 expected 128 to be 17(contextCommand.test.ts:1115),与作者给出的修复前结果一致。
  • CLI 中涉及 /context 的 8 个测试文件全部通过,共 2,771/2,771:contextCommand、ContextUsage、context-usage-labels、item-projection、ControlDispatcher、acpAgent、serve/server、acp-http/transport。
  • 两个改动文件的 eslint --max-warnings 0 和 prettier --check 都干净。

测试覆盖缺口,经变异确认(非阻塞)

我在原文件旁放了 contextCommand.ts 的变异副本(未跟踪,跑完已删除),逐个用 PR 的测试文件跑:

变异 PR 现有测试 + 逐行探针用例
第一个 MCP 行独占全部 clamp 后的量,其余为 0(保持总和) 存活(59/59) 杀死
scaleTokens 中 Math.round → Math.ceil 存活 杀死
provider 路径丢掉 scale(scale * mcpDetailShare → mcpDetailShare) 存活 杀死
provider 路径退回 base(只乘 scale) 杀死 杀死
估算路径退回 base(原始行) 杀死 杀死

这用实际执行确认了 R1-1 行内评论(各行之间的分配方式没有被钉住),以及 stage-2 评审 第 2 点(provider 用例只跑在 scale = 1)。第三个变异是我最希望补上覆盖的:在上面 12,000 那次运行里,它会在 6,357 的行下列出约 9,942 token 的 MCP 明细,也就是 provider 路径上的同一个 bug,而现有测试依旧全绿。建议的测试代码见上方英文部分(两个 MCP 工具、逐行断言、覆盖两条路径与 scale < 1;能杀死全部 5 个变异,在 PR 现状下通过)。

既有问题,本 PR 未改变(可作为 #12235 下的后续)

  1. Built-in 分类为 0,下面仍列着明细行。 同一 clamp 场景下,Built-in tools 行为 0,但其明细区仍列出工具:stream-json 下 17 个,共 9,514 token;TUI 下 22 个。这在真实应用中确认了 stage-2 评审第 1 点。base 与 PR 完全一致。
  2. search_memory / manage_memory 被列出,但从未发送。 direct 模式下,模型实际收到的请求里没有这两个工具,因为 legacy recall 协议下 getFunctionDeclarations 会扣下它们。/context detail 却仍把它们列为 built-in 行(630 + 138 token),这正好解释了 Built-in 行(8,688)与其明细(9,457)之间 769 token 差额中的 768。collectContextData 的逐工具循环缺少 isMemoryRecallToolDeclared 过滤。
  3. headless 下 /context detail 不输出内容。 qwen -p "/context detail" 在 base 和 PR 上都只打印 Command executed successfully.,因为 detail 子命令调用了 contextCommand.action!(context, 'detail') 却没有返回其消息。/context -d 正常,交互式 TUI 也不受影响。

未覆盖

  • 没有在 macOS 和 Windows 上运行。改动是纯算术,不含平台相关代码。
  • ACP/serve 的 context-usage 状态(acpAgent.ts 中的 buildSessionContextUsageStatus)没有单独驱动。它序列化的是同一个 collectContextData 结果。
  • 没有构建与 main 合并后的版本。main 领先 29 个提交,均未触及这两个文件,git merge-tree 无冲突;9c75b71 上的 PR CI 全绿。

证据(截图、每次运行的 JSON、变异日志、harness:MCP 服务器、假 provider、stream-json 与 TUI 驱动):asserts@6f38922c6f/pr-13390

🤖 Generated with Claude Code — Claude Opus 5.5

@wenshao
wenshao added this pull request to the merge queue Oct 5, 2026
Merged via the queue into QwenLM:main with commit dd82140 Oct 5, 2026
91 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/context detail lists more MCP tokens than the MCP tools row

2 participants