Skip to content

[Bug]: context meter ratchets up and never reflects /compact — task_progress writes cumulative tokens into usedTokens via Math.max #4650

Description

@iKon85

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Start a thread with the claude-code backend (Claude Opus 5, context mode High · 1M).
  2. Run it long enough that the context meter fills up (a long orchestration session, ~90%+).
  3. Run /compact and let it finish successfully — the thread continues with a compacted transcript.
  4. Watch the context meter in the composer.

Expected behavior

Right after a successful /compact, the context meter drops to the post-compaction size (the summary plus the unsummarized tail — a small fraction of the window).

Actual behavior

The meter stays pinned at its pre-compaction value (still red/full) even though the compaction demonstrably succeeded — the agent kept working from a summarized transcript.

It stays wrong across at least one full completed turn after the compaction, and then, at some later and not-obviously-related moment, snaps down to the correct low value on its own with no user action. In my session the jump happened several minutes later, in the middle of an unrelated turn. So this is a lurching meter rather than a permanently frozen one — the same shape as the "stuck at 5%, then suddenly 100%" report in #2034, just in the other direction.

This looks like the same symptom as #2807 / #2034, but #2034 was closed as completed on 2026-06-20 and this is reproducing on the 2026-07-27 nightly, so it is either a regression or a remaining gap in that fix. Filing separately rather than reopening, because the code path below is a different one from the cumulative-vs-active accounting that #2034 fixed.

Root cause (read out of the shipped bundle, t3@0.0.29-nightly.20260727.915, dist/bin.mjs)

The post-compaction reset has exactly one trigger, and it is conditional:

case "compact_boundary":
  yield* emitThreadTokenUsage(
    context,
    compactBoundaryTokenUsageSnapshot(message, context.lastKnownContextWindow, context.lastKnownTotalProcessedTokens),
    { rawMethod: "claude/system/compact_boundary", rawPayload: message }
  );

compactBoundaryTokenUsageSnapshot bails out unless the SDK supplied post_tokens:

function compactBoundaryTokenUsageSnapshot(message, contextWindow, totalProcessedTokens) {
  const metadata = message.compact_metadata;
  if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return;
  const compactMetadata = metadata;
  const postTokens = finiteNonNegativeInteger(compactMetadata.post_tokens);
  if (postTokens === void 0 || postTokens <= 0) return;   // <-- silent no-op
  ...
}

…and the emitter drops an absent snapshot on the floor:

const emitThreadTokenUsage = Effect.fn("emitThreadTokenUsage")(function* (context, usage, options) {
  if (!usage) return;   // <-- no thread.token-usage.updated is ever sent
  ...
});

So when compact_metadata.post_tokens is missing or 0, no thread.token-usage.updated event is emitted at all and the UI simply keeps rendering the last pre-compaction reading. There is no error, no fallback, and nothing in the transcript that hints the meter is stale.

There is an authoritative pull that would answer this correctly — queryCurrentContextUsage, which calls context.query.getContextUsage() and also refreshes context.lastKnownContextWindow:

const queryCurrentContextUsage = Effect.fn("queryCurrentContextUsage")(function* (context, totalProcessedTokens) {
  if (!context.query.getContextUsage) return;
  const usage = yield* Effect.promise(async () => { try { return await context.query.getContextUsage?.(); } catch { return; } });
  if (!usage) return;
  context.lastKnownContextWindow = usage.maxTokens;
  return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens);
});

But its only call site in the bundle is the turn-completion path (next to maxClaudeContextWindowFromModelUsage(result?.modelUsage)), not the compact boundary. That matches the observed behavior: /compact is a standalone action, so nothing re-queries the real usage at the moment the meter goes stale.

Suggested fix

On compact_boundary, fall back to the authoritative pull instead of returning silently:

case "compact_boundary": {
  const snapshot =
    compactBoundaryTokenUsageSnapshot(message, context.lastKnownContextWindow, context.lastKnownTotalProcessedTokens)
    ?? (yield* queryCurrentContextUsage(context, context.lastKnownTotalProcessedTokens));
  yield* emitThreadTokenUsage(context, snapshot, { rawMethod: "claude/system/compact_boundary", rawPayload: message });
  ...
}

That keeps the cheap metadata path as the fast case and only pays for the query when the SDK omitted post_tokens. If neither source yields a value, emitting an explicitly unknown state would still beat rendering a confidently wrong one.

Scope of what I verified

  • ✓ Verified by reading dist/bin.mjs of the installed nightly: the three code paths quoted above, and that queryCurrentContextUsage has no compact_boundary call site.
  • 🔬 Not verified: whether the Claude Code SDK actually omits compact_metadata.post_tokens in this case, or sends 0. I could not capture the raw compact_boundary payload from the running session. Either input reaches the same silent no-op, so the branch is worth hardening regardless — but a maintainer with payload logging can confirm which one it is.
  • 🔬 Not verified: which event eventually corrects the meter. I expected the turn-completion path (the only queryCurrentContextUsage call site I found), but the reading was still stale after the first turn completed post-compaction and only corrected later. Either there is a second update path I did not find in the bundle, or that call site is reached less often than I assumed. This does not affect the compact_boundary no-op above, which is independently readable in the source — but it does mean the delay is not fully explained by it.
  • I checked the server side only; I did not inspect how apps/web renders the meter once the event does arrive.

Impact

Minor bug or occasional failure

Version or commit

t3@0.0.29-nightly.20260727.915 (resolved via npx t3; package.json points at pingdotgg/t3code, directory apps/server)

Environment

Linux (WSL2, kernel 6.6.87.2-microsoft-standard-WSL2) · backend: claude-code · model: Claude Opus 5, context mode High · 1M · local checkout

Workaround

None that makes the meter trustworthy. Ignore it for a while after a /compact — it does eventually correct itself without intervention. I initially assumed the next completed turn would fix it (that is where queryCurrentContextUsage runs), but in my session it was still stale after that turn finished, so I cannot name the event that actually releases it. There is no in-app way to distinguish a stale reading from a real one in the meantime.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions