Subagent transcripts: `message.usage.output_tokens` differs across the block records of one response; the first record carries a partial count

Status Open
Reported on v2.1.241
Maintainer reply None cached
Activity 4 comments · opened Sep 11, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Claude Code writes one assistant record per content block for each API response, and the records of one response share requestId and message.id. In the main session transcript every record of a response carries the same, final message.usage, so a reader can count usage once per message.id.

In subagent transcripts (<session-id>/subagents/agent-*.jsonl, and subagents/workflows/<runId>/agent-*.jsonl for Workflow agents) that is not the case: message.usage.output_tokens is different on each record of the same response. It looks like the running count at the moment each block was written, so the first record (typically a thinking or text block) shows 2–8 output tokens while the last record shows the real total (hundreds or thousands). The input and cache fields repeat on every record as they do in the main transcript.

The effect is that any tool reading the transcript the way the main-session shape suggests (usage once per response, first record wins) badly under-counts subagent output tokens. On our side this was one of the two causes of an 8-hour, subagent-heavy session being reported at $21.83 when it had cost $322.36 (the other was Workflow agents living under subagents/workflows/, which a reader that only knows subagents/agent-*.jsonl never sees).

What Should Happen?

Either every record of a response carries the same final usage (as the main transcript does), or the format documents that usage on split records is a running count and the last record for a message.id is authoritative. Consistency between main and subagent transcripts would be the simplest fix.

Error Messages/Logs

None; nothing errors. The numbers are just inconsistent between records.

Steps to Reproduce

  1. Run a session that launches a subagent (the Agent tool or a Workflow) that produces a multi-block response (thinking + text + tool_use is enough).
  2. Open ~/.claude/projects/<project>/<session-id>/subagents/agent-<id>.jsonl.
  3. Group the assistant records by message.id and compare message.usage.output_tokens across the group.
  4. Observe that the values differ and that the first one is a small partial count; repeat step 3 on the main <session-id>.jsonl and observe that there the values are identical.

The script below does step 3 for every subagent transcript under ~/.claude/projects and prints ids and usage numbers only (no prompts or content):

// usage-check.mjs — node usage-check.mjs [projects-dir]
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os';
const root = process.argv[2] || path.join(os.homedir(), '.claude', 'projects');
const files = [];
(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (/agent-.*\.jsonl$/.test(e.name)) files.push(p); } })(root);
let responses = 0, varying = 0, shown = 0;
for (const f of files) {
  const by = new Map();
  for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
    let r; try { r = JSON.parse(line); } catch { continue; }
    if (r.type !== 'assistant' || !r.message?.usage) continue;
    const k = r.message.id; if (!by.has(k)) by.set(k, []);
    by.get(k).push({ block: r.apiBlockIndex, out: r.message.usage.output_tokens, in: r.message.usage.input_tokens, cacheRead: r.message.usage.cache_read_input_tokens });
  }
  for (const [id, recs] of by) {
    responses++;
    if (new Set(recs.map((x) => x.out)).size > 1) { varying++; if (shown++ < 5) console.log(path.relative(root, f), id, JSON.stringify(recs)); }
  }
}
console.log(`${files.length} subagent files, ${responses} responses, ${varying} with differing output_tokens across their records`);

Example output shape:

-home-me-proj/abc…/subagents/agent-a1b2.jsonl msg_01X… [{"block":0,"out":4,…},{"block":1,"out":4,…},{"block":2,"out":1800,…}]
31 subagent files, 212 responses, 187 with differing output_tokens across their records

Claude Model

Other

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.241.

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Windows Terminal

Additional Information

Found while auditing 33 real sessions for Glassbox, an open-source local session checker. The workaround shipped in 0.4.2 keeps the largest value seen per usage field across a response's records: https://github.com/aldohushi1-stack/glassbox/blob/main/CHANGELOG.md (0.4.2, "Streamed usage") and maxUsage() in https://github.com/aldohushi1-stack/glassbox/blob/main/src/trace-core.js. Happy to share a redacted subagent transcript (usage fields and ids only) if that helps.

Two related observations I have not filed separately (say if you'd like them as their own issues): a frame-link record written weeks after the session carries a current timestamp, so a reader that bounds the session by first/last record timestamps sees a multi-week session; and Workflow agent transcripts live under subagents/workflows/<runId>/ rather than subagents/, with a journal.jsonl beside them.

View original on GitHub ↗

4 Comments

aldohushi1-stack · 1 month ago

Related: #76484 (closed by the stale bot on 14 Sep) names the child transcripts under subagents/ as the fallback for per-child usage, saying they have full per-turn usage. They do, but only on the last block record of each response. Earlier records with the same message.id carry a partial output_tokens, so a reader that joins children back by agentId and counts once per response still under-counts unless it keeps the largest value per field.

Workflow agents also sit one level deeper, in subagents/workflows/<runId>/. The two together turned $322.36 into $21.83 on the session described above.

roziko · 17 days ago

Additional data from another Windows machine. It confirms this report, and adds one point: for most subagent responses on recent versions, no record of the response ever carries the final usage. Keeping the largest value per message.id (as suggested in the comment above) does not recover those.

Counts

All transcripts under ~/.claude/projects on this machine (Claude Code 2.1.247 – 2.1.283, Windows 11). Records are grouped by message.id. Synthetic records (all input-side tokens 0) are skipped.

| | responses | output_tokens differs between records | last record has stop_reason and final usage | last record is still partial |
|---|---|---|---|---|
| main <session-id>.jsonl (159 files) | 21,094 | 0 | 21,091 | 3 |
| subagents/agent-*.jsonl (79 files) | 2,319 | 685 | 895 | 1,424 (61%) |

A partial last record looks like this:

  • stop_reason is null
  • usage has no iterations, output_tokens_details, server_tool_use or speed keys (the final usage on the main transcript always has them)
  • output_tokens is a small running count (average 9)

Responses whose last record is final average 1,985 output tokens. In 1,408 of the 1,424 partial cases the last record is a tool_use block. Among subagent responses that end in tool_use, 826 have a final record (stop_reason: "tool_use") and 1,408 do not.

The input and cache fields (input_tokens, cache_read_input_tokens, cache_creation_input_tokens) are the same on every record of a response, in both main and subagent transcripts.

By version (subagent responses)

| version | last record partial | last record final |
|---|---|---|
| 2.1.266 | 63 | 25 |
| 2.1.267 | 141 | 23 |
| 2.1.268 | 14 | 37 |
| 2.1.270 | 3 | 93 |
| 2.1.272 | 0 | 33 |
| 2.1.273 | 0 | 12 |
| 2.1.274 | 13 | 288 |
| 2.1.278 | 314 | 13 |
| 2.1.280 | 654 | 302 |
| 2.1.282 | 66 | 15 |
| 2.1.283 | 152 | 51 |

2.1.270 – 2.1.274 mostly wrote the final record. 2.1.278 and later mostly do not. Every subagent here was launched in the background (requestShape: "background" in agent-*.meta.json; agent types Explore, fork, general-purpose, claude-code-guide). I did not find a split by agent type or by the number of tool calls in the response.

Effect

The transcripts under-count subagent cost, and the missing part cannot be recovered from any file. On this machine, sessions that used subagents came out 6–28% below the session cost Claude Code reports to the status line (total_cost_usd), even when counting the last record of each response. Sessions without subagents are within 0–5% (a smaller gap with other causes). On a per-day basis the gap reached 39%. The status line total suggests Claude Code does receive the final usage; it just does not reach the subagent transcript.

Check

Same idea as the script in the report. It prints counts only, and marks a response as final when its last record has a stop_reason:

// node final-check.mjs [projects-dir]
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os';
const root = process.argv[2] || path.join(os.homedir(), '.claude', 'projects');
const tally = { main: [0, 0], sub: [0, 0] };   // [last record partial, last record final]
(function walk(d) {
  for (const e of fs.readdirSync(d, { withFileTypes: true })) {
    const p = path.join(d, e.name);
    if (e.isDirectory()) { walk(p); continue; }
    if (!e.name.endsWith('.jsonl')) continue;
    const by = new Map();
    for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
      let r; try { r = JSON.parse(line); } catch { continue; }
      const u = r.message?.usage;
      if (r.type !== 'assistant' || !u || !((u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0))) continue;
      if (!by.has(r.message.id)) by.set(r.message.id, []);
      by.get(r.message.id).push(r);
    }
    const t = tally[/[\\/]subagents[\\/]/.test(p) ? 'sub' : 'main'];
    for (const recs of by.values()) t[recs.at(-1).message.stop_reason == null ? 0 : 1]++;
  }
})(root);
console.log(tally);

Expected

Every subagent response should end with a record that carries the final usage and stop_reason, as the main transcript does.

depristo · 16 days ago

🤖 AI-generated reply, posted by Claude Code under depristo's account.

Regression in 2.1.281: many subagent responses now have no record with the final usage at all.

The workaround above ("the last record for a message.id is authoritative") no longer works on 2.1.281. For most subagent responses, every record has stop_reason: null and a partial output_tokens, so no record in the transcript carries the response's real output count.

Measured 2026-09-28 across one macOS machine's ~/.claude/projects (subagent transcripts are <session-id>/subagents/agent-*.jsonl; a response counts as "no final record" when none of its records has a non-null stop_reason):

| Client version | Transcript | Responses | No final record |
|---|---|---:|---:|
| 2.1.219 | subagent | 2,543 | 282 (11%) |
| 2.1.280 | subagent | 2,581 | 12 (0.5%) |
| 2.1.281 | subagent | 7,409 | 4,974 (67%) |
| 2.1.281 | main | 8,732 | 0 |

Characteristics of the affected responses:

  • The subagent itself finishes normally (its last response ends end_turn); this is not interruption.
  • Across all versions, 99% of responses with no final record end in a tool_use block, and the rate rises with parallel tool calls (2% of one-tool responses, 7–8% of two- or three-tool responses, pooled over all versions and both transcript kinds). Example: one response issued 5 tool calls and every one of its 5 records reports output_tokens: 37 with stop_reason: null.
  • input_tokens, cache_creation_input_tokens, cache_read_input_tokens and the cache_creation TTL split are unaffected. On responses that do have a final record, all 13,041 partial records matched the final record's input-side fields exactly. Only output is short.
  • The Agent tool result's usage does not recover it; it matches the subagent's last response only.

Impact: output tokens for those responses are simply absent from disk. As an estimate of the size: on 4,949 finished subagent responses where both figures exist, the first record's partial count was 11% of the final output, and pricing with it understated cost by 15%. Any tool that computes subagent cost from transcripts can now only report a lower bound.

Expected: each response's final record (with its stop_reason and final usage) is written to the subagent transcript, as 2.1.280 did, or every record carries the final usage as the main transcript does.

Counting script (prints counts only, no content):

import collections, glob, json, os, sys
root = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/.claude/projects")
stats = collections.Counter()
for path in glob.glob(f"{root}/*/*/subagents/agent-*.jsonl") + glob.glob(f"{root}/*/*.jsonl"):
    kind = "subagent" if "/subagents/" in path else "main"
    calls = collections.defaultdict(list)
    for line in open(path):
        if '"type":"assistant"' not in line:
            continue
        rec = json.loads(line)
        m = rec.get("message") or {}
        if rec.get("type") == "assistant" and m.get("id") and m.get("model") != "<synthetic>":
            calls[m["id"]].append((rec.get("version"), m.get("stop_reason")))
    for group in calls.values():
        key = (group[-1][0], kind)
        stats[key, "responses"] += 1
        stats[key, "no_final"] += all(stop is None for _, stop in group)
for (key, field), n in sorted(stats.items(), key=str):
    if field == "responses" and n >= 20:
        print(*key, n, stats[key, "no_final"])

Environment: Claude Code desktop app (Code tab) on macOS 26 (Darwin 25.6.0), client 2.1.281, subagents launched via the Agent tool on claude-opus-5-5.

aldohushi1-stack · 16 days ago

Thanks @roziko and @depristo. That moves this from "the first record is partial" to "for most subagent responses there is no final record at all", which is the worse problem: the largest-value-per-message.id workaround I suggested can't recover output that was never written.

A third machine for the table: Windows 11, a copy of ~/.claude/projects taken in early September, so it predates the 2.1.278+ jump. Same rule as the script above (a response has no final record when none of its records has a non-null stop_reason), subagent and Workflow-agent transcripts only:

| Client version | Subagent responses | No final record |
|---|---:|---:|
| 2.1.237 | 2,054 | 69 (3%) |
| 2.1.241 | 101 | 3 (3%) |
| 2.1.266 | 112 | 35 (31%) |

So it was there at a low rate before, and the rise lines up with what you both measured.

On the tooling side, Glassbox 0.10.1 (published today) no longer presents a floor as the bill: it counts these responses, shows subagent cost as ≥ $X, and estimates the gap from the median of the session's own complete subagent tool-use responses, labelled as an estimate (rule missing-final-usage, info level so it doesn't fail CI). That is a reporting workaround only. The fix still has to be here: write each subagent response's final record, with its stop_reason and final usage, as 2.1.280 did, or carry the final usage on every record as the main transcript does.