#1846·cc-connect

[Bug] auto_compress still falls back to the text heuristic for providers that report no usage (MiniMax-M3) — root cause + fix in #1845

Author: YongmaoLuoCreated Sep 14, 2026Updated Sep 14, 2026

Summary

Follow-up to #1764. That issue reported that the auto_compress trigger was fed by estimateTokensWithPendingAssistant, a text-only heuristic that misses 70-85% of the real prompt. #1761 fixed the primary half of it by preferring the exact API-reported number from ContextUsageReporter.GetContextUsage().

This issue is the remaining half: for providers that report no usable usage at all, there is still no exact number, and every turn silently falls back to that same heuristic.

Root cause

1. The provider sends an empty usage block. Measured on MiniMax-M3: every stream-json assistant event carries

json
"usage": {"input_tokens": 0, "output_tokens": 0,
          "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}

handleAssistant's if used > 0 guard then never writes lastUsage, so GetContextUsage() returns nil for the entire lifetime of the session. The exact-usage path introduced in #1761 is correct but has nothing to report, so every single turn falls through to the heuristic. lastUsage is never written — not "written small".

2. The result event's aggregate cannot stand in for it. Its cache_read_input_tokens sums every sub-call in the turn (measured: in+cc+cr walking 38377 → 38538 → 38671 within one turn), so it overstates the final prompt by a factor that grows with the sub-call count — roughly 2x on a short turn, worse on long agentic turns. Using it would trade an underestimate for an overestimate.

3. The heuristic itself is badly wrong. It counts only cc-connect's own history text, ignoring tool_use/tool_result blocks and the fixed system-prompt + tools + skills overhead. On our test instance it read 12,887 for a turn whose real prompt was 37,184 — 2.9x low, and the ratio moves with how much the agent read.

Where the exact number actually lives

The Claude Code JSONL transcript records one self-contained record per API call, and that record's in + cache_creation + cache_read is exactly that call's prompt size. Unlike the result aggregate it does not accumulate (verified live: cr 38802in+cr 38948 → next cr 38948). It is the same number behind Claude Code's own ctx N% indicator.

Reading it is cheap as a bounded tail read rather than a scan. Measured across 11,061 real transcripts (2.5 GB total, largest 137 MB):

percentile last-line bytes
min 82
p50 478
p90 668
p99 1452
max 32510

So a 4 KiB initial window settles 99.7% of sessions, and doubling only when the record is out of reach covers the rest. A full rescan would be O(n²) — one of our transcripts is 136 MiB, and auto-compress consults this on every turn.

Reproduction

  1. Any project whose agent is claudecode behind an Anthropic-compatible gateway that reports zero usage — MiniMax-M3 is the one we measured.
  2. auto_compress.enabled = true, any max_tokens.
  3. Watch the logs: GetContextUsage() stays nil, and the trigger value comes from the text-length heuristic no matter how large the real prompt gets. (Before the logging added in the PR, this was invisible — "the agent reported a small prompt" and "the agent reported nothing and we guessed from text" looked identical.)

Proposed fix — PR #1845

  • Recover the exact per-call prompt size from the transcript tail: in handleResult when the in-process snapshot is absent, and once at attach time so a --resumed process does not spend its first turn heuristic-blind. The result aggregate is used only for the EventResult billing fields.
  • Coerce non-float64 numbers in parseClaudeUsage, so a CLI build emitting token counts as JSON strings cannot silently zero them out either.
  • Add UsageSourceReporter + estimate_source / usage_source on every decision log, so this ambiguity is visible next time.
  • Add auto_compress.allow_heuristic, default false: a turn with no exact usage makes no decision and leaves lastAutoCompressAt untouched, so the next turn (which does have exact usage) decides on real data. The legacy fallback is preserved behind the opt-in.

Verified on an isolated instance against MiniMax-M3 over a multi-turn session: 9/9 decisions report estimate_source=exact with usage_source=transcript, zero heuristic fallbacks, zero skips; lastUsage goes from never-written to written once per turn and tracks real context growth (41656 → 41707 → … → jumps 42,597 → 58,489 when the turn loads a large file). The one turn that crossed the threshold genuinely fired /compact on real data, after which the next decision read 37,102.

Refs #1115