Skip to content

[Bug]: Returned content bytes double-counted in reduction_pct denominator #1025

Description

@EvolveAegis

Platform

Claude Code (the bug is in shared metering code in src/server.ts, not in any host adapter)

context-mode version

1.0.169 (latest, ctx upgrade reports "Already on latest")

Debug script output

scripts/ctx-debug.sh only dumps install/env info and doesn't surface this — the relevant artifact is the persisted per-session stats file ($HOME/.claude/context-mode/sessions/stats-<sessionId>.json). After one ctx_batch_execute over a synthetic 10,001-byte stdout it reads:

{
  "bytes_indexed": 10052,
  "bytes_returned": 3284,
  "kept_out": 10052,
  "total_processed": 13336,
  "reduction_pct": 75,
  "by_tool": {
    "ctx_batch_execute": { "calls": 1, "bytes": 725 },
    "ctx_stats": { "calls": 2, "bytes": 2559 }
  }
}

The command's actual stdout is 10,001 bytes. bytes_indexed already books ~100% of it as kept-out, yet total_processed (13,336) is 3,335 bytes larger than the universe.

Exact prompt that triggered the bug

No chat prompt needed — it triggers on any tool call that indexes content and then echoes a snippet of it back. Minimal trigger is one ctx_batch_execute (or ctx_search / ctx_fetch_and_index) whose output gets indexed and then partially returned:

ctx_batch_execute({
  commands: [{ label: "fixture", command: "python3 -c \"print('x'*10000,end='')\"" }],
  queries: ["xxxxxxxxxxxx"]
})

(print('x'*10000,end='') is a synthetic payload, not real data.)

Full error output

No crash. The defect is silent metering: reduction_pct and tokens_saved are computed against a denominator that double-counts the returned snippet bytes.

Steps to reproduce

The structural fact: in persistStats (src/server.ts),

const keptOut = sessionStats.bytesIndexed + sessionStats.bytesSandboxed + sessionStats.cacheBytesSaved;
const totalProcessed = keptOut + totalReturned;
const reductionPct = totalProcessed > 0 ? Math.round((1 - totalReturned / totalProcessed) * 100) : 0;

For ctx_batch_execute, trackIndexed(totalBytes) at line 3823 books the entire command stdout into bytesIndexed (→ keptOut). The handler then runs the caller's queries via formatBatchQueryResults (line 1371), which calls extractSnippet(result.content, query, 3000, ...) and inlines that snippet — a slice of the same indexed stdout — into the response. trackResponse("ctx_batch_execute", ...) then books the response (snippet included) into bytesReturned (→ totalReturned).

So the snippet bytes land in both halves of totalProcessed = keptOut + totalReturned. reduction_pct's denominator is inflated by the snippet size, and tokens_saved = round(keptOut / 4) keeps a byte as "avoided" even after it has been returned to the model. Same shape applies to ctx_search and ctx_fetch_and_index — any path that returns a slice of previously-indexed content.

Self-contained repro against the real MCP server over stdio (synthetic payload, isolated HOME):

git clone https://github.com/mksglu/context-mode
cd context-mode
git checkout 252e74b7a947b5fbb5624037f8710d3a5319af3c   # v1.0.169
npm install
node --input-type=module <<'EOF'
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const home = mkdtempSync(join(tmpdir(), "cm9-home-"));
const proj = mkdtempSync(join(tmpdir(), "cm9-proj-"));
const sid = "cm9e2e";
const srv = spawn("npx", ["tsx", "src/server.ts"], {
  env: { ...process.env, HOME: home, XDG_DATA_HOME: home,
    CLAUDE_SESSION_ID: sid, CLAUDE_PROJECT_DIR: proj,
    CONTEXT_MODE_PROJECT_DIR: proj, PWD: proj },
  stdio: ["pipe", "pipe", "inherit"],
});
let buf = ""; const pending = new Map(); let id = 1;
srv.stdout.on("data", d => {
  buf += d;
  let i;
  while ((i = buf.indexOf("\n")) >= 0) {
    const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
    if (!line) continue;
    try { const m = JSON.parse(line); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } } catch {}
  }
});
const rpc = (method, params) => new Promise(res => { const i = id++; pending.set(i, res); srv.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n"); });
const sleep = ms => new Promise(r => setTimeout(r, ms));
await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "repro", version: "0" } });
srv.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
await sleep(400);
await rpc("tools/call", { name: "ctx_batch_execute", arguments: {
  commands: [{ label: "fixture", command: "python3 -c \"print('x'*10000,end='')\"" }],
  queries: ["xxxxxxxxxxxx"], timeout: 30000 } });
await rpc("tools/call", { name: "ctx_stats", arguments: {} });
await sleep(700); // pass the 500ms persist throttle
await rpc("tools/call", { name: "ctx_stats", arguments: {} }); // flush
await sleep(300);
const s = JSON.parse(readFileSync(join(home, ".claude", "context-mode", "sessions", "stats-" + sid + ".json"), "utf8"));
const universe = 10001;
console.log({ universe, bytes_indexed: s.bytes_indexed, bytes_returned: s.bytes_returned,
  kept_out: s.kept_out, total_processed: s.total_processed, reduction_pct: s.reduction_pct,
  by_tool: s.by_tool });
console.log("batch response bytes (includes the indexed-content snippet):", s.by_tool.ctx_batch_execute.bytes);
console.log("total_processed - universe =", s.total_processed - universe);
srv.kill(); process.exit(0);
EOF

Expected: bytes_indexed covers the full stdout, and the batch-response byte count (in by_tool.ctx_batch_execute.bytes) is a slice of that same stdout yet is added a second time inside total_processed.

What I tried to fix it

Read persistStats and traced ctx_batch_execute: trackIndexed(totalBytes)bytesIndexed, then the query-result snippet is built from store.searchWithFallback(...).content and echoed through trackResponsebytesReturned. The overlap is structural. Netting the returned snippet out of keptOut (so a byte that comes back to the model is no longer also counted as "avoided") fixes both total_processed and the tokens_saved overstatement.

Operating System

macOS (Apple Silicon)

JS Runtime

Node v24.17.0 (also reproduces under bun)

Pre-submission checklist

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions