Skip to content

Context pressure is adapter-composed derived data on Chat, not a capability: ReadChat widens to a ReadSpec carrying the spawn-time model, and each adapter fills ContextUsage {Used, Limit} from token info it already parses past

Decision from the grill session behind issue #243, which asked for a context-pressure meter in the chat header. The transcripts already carry the numbers and both adapters already throw them away: claude-code's chat_types.go decodes only role+content off an assistant line, so the usage object (input_tokens, cache_read_input_tokens, cache_creation_input_tokens) is dropped at decode by construction; codex's fold skips token_count event_msg records silently (internal/provider/codex/chat.go ~255, documented as skipped at chat_types.go:25), even though each carries its own token totals and a self-reported model_context_window. The meter is not a new signal to plumb — it is data the parse discards.

What blocks it is the seam, not the transcript. The meter's denominator is the model's context window, and the window is a function of the run's spawn-time model — but ADR-0038's ReadChat(runID, runtimeDir, transcriptPath string) carries three plain strings and no model. This ADR widens that read-side input once, the SpawnSpec precedent applied to reads: the three-string arg list becomes provider.ReadSpec{RunID, RuntimeDir, TranscriptPath, Model string}, so new read-side inputs never churn the interface again. Context pressure then rides the existing tailer → API → web transport as adapter-composed data on Chat (ADR-0038's adapter-owned composition), computed inside ReadChat where the transcript is already being parsed. It is derived data, not a behavior — so it is a field, not a capability.

The decisions, pinned:

  • Occupancy, not cumulative session tokens. The meter shows what the next request will roughly carry: the prompt-side token count of the latest assistant turn over the model's context limit. It is not a running sum of everything the session has spent.
  • Raw context limit as v1 denominator. Limit is the model's raw window, not the auto-compact-adjusted usable window. The computation is adapter-owned, so refining the denominator later touches neither the seam nor the UI.
  • New optional field on Chat, no pre-computed fraction. internal/provider/provider.go gains type ContextUsage struct { Used, Limit int64 } and Chat.ContextUsage *ContextUsage (json context_usage,omitempty), nil when unknown or unsupported. Raw counts only — the UI does the division and formats. No fraction, no percentage crosses the seam.
  • ReadChat takes a ReadSpec. ReadChat(runID, runtimeDir, transcriptPath string) becomes ReadChat(spec ReadSpec) with ReadSpec{RunID, RuntimeDir, TranscriptPath, Model string}. Model is the run's spawn-time model catalog value, already persisted on runs.model. One-time signature churn across both adapters and the conformance fakes; never again for the next read-side input.
  • Spawn-time model only. A mid-session /model switch is knowingly not reflected — matching the existing spawn-time-truth convention at web/src/routes/runchat/ChatHeader.tsx:131. The claude-code adapter can later detect switches from the transcript; out of scope here.
  • claude-code fills it from the usage object. chat_types.go starts decoding usage on assistant lines. Used = input_tokens + cache_read_input_tokens + cache_creation_input_tokens of the latest assistant message. Limit comes from an adapter-internal map keyed on the spawn-time model catalog value: opus 200k, opus[1m] 1M, sonnet 200k, fable 1M, haiku 200k — explicit entries only, no name heuristics (fable is 1M with no name hint). An unknown model value yields nil and the meter hides. The map stays inside the adapter (provider.go:6-8: model catalogs are provider-owned; nothing outside a provider hardcodes model values). The shared ModelOption type is not extended.
  • codex fills it from token_count. The fold stops skipping token_count event_msg records. Used comes from the event's own token totals; Limit from its self-reported model_context_window. The ReadSpec.Model input is ignored — codex knows its own window. Events lacking the info yield nil.
  • Conformance stays optional-by-type. The field is optional (ADR-0036): adapters may return nil. The Tier-1 suite pins only that idle/empty reads and unknown-model reads yield nil ContextUsage; real values are pinned by each concrete adapter's own fixture tests, not the shared suite.
  • UI extends the existing model·effort chip, no new chip. web/src/routes/runchat/ChatHeader.tsx renders Opus · high · 62%; the percentage tints amber at ≥80% and red at ≥95%, and the title reads 127k of 200k tokens. Nil hides the meter entirely. Sub-640px the model chip is already hidden and the meter goes with it. Chat-header only for v1 — no runs-rail indicator, no InstanceView/list-API change, no /metrics gauge.
  • Edge semantics. No assistant message yet → nil → hidden. /clear rotates transcript identity (the existing mechanism) so the meter resets on the next turn automatically. /compact leaves the value stale until the next assistant message, then it drops — accepted for v1, no eager compaction detection. Ended runs stay readable: the persisted runs.model feeds ReadSpec.Model.

Status

Accepted. Resolves issue #243. Amends ADR-0038 (the ReadChat signature it defined: three string args become provider.ReadSpec) and extends ADR-0036's conformance obligations (the new optional field's nil contract joins Tier 1). Relates to ADR-0016 (the universal chat schema Chat now carries one more optional, adapter-composed field) and ADR-0017 (optional capabilities are for behaviors; this deliberately is not one).

Considered options

  • An optional capability interface for context usage. Rejected: ADR-0017 capabilities are optional behaviors discovered by type assertion. Context usage is derived data computed inside a read the tailer already performs — a field on Chat riding the existing transport, per ADR-0038's adapter-owned composition. A capability would add a discovery seam for a value that has no behavior to invoke.
  • A pre-computed fraction or percentage on the seam. Rejected: the UI formats. Shipping raw Used/Limit counts keeps the seam future-proof for a different denominator (the auto-compact-adjusted window is a known v2), and lets the header render both the percentage and the 127k of 200k title from the same two numbers. A fraction on the wire would have to be recomputed the moment the denominator changes.
  • Passing richer run or store types through the seam to carry the model. Rejected in ADR-0038 already: the seam takes plain strings, never core's store shapes. ReadSpec adds one more string (Model), not a run row or store handle — packages that must stay core-free stay core-free.
  • Name heuristics to infer 1M-window models. Rejected: fable is a 1M model with no [1m] or other name hint, so any heuristic is wrong for it. The adapter-internal map has explicit entries only; an unrecognized value hides the meter rather than guessing.
  • A /metrics per-run context-pressure gauge. Rejected: a per-run label violates the bounded-cardinality rule (internal/metrics/lab.go:6-8 — label sets are a fixed vocabulary, never run ids). The meter is a live UI affordance, not a fleet time-series.
  • Extending the shared ModelOption with a context-window field. Rejected: provider.go:6-8 — model catalogs are provider-owned, and the window is adapter-private knowledge (claude-code's map, codex's self-reported model_context_window). Widening the cross-provider ModelOption would leak one adapter's window facts into the shared type.

Consequences

  • internal/provider/provider.go: adds ContextUsage {Used, Limit int64}, Chat.ContextUsage *ContextUsage (json context_usage,omitempty), and ReadSpec{RunID, RuntimeDir, TranscriptPath, Model string}; AgentProvider.ReadChat takes a ReadSpec — the one interface churn.
  • internal/provider/claudecode/{chat.go,chat_types.go}: chat_types.go decodes the assistant usage object; chat.go sums the three token fields for the latest assistant message and looks Limit up in the adapter-private model→window map, nil on unknown model.
  • internal/provider/codex/{chat.go,chat_types.go}: the fold stops skipping token_count; chat.go reads Used from the event and Limit from model_context_window, ignoring ReadSpec.Model; the chat_types.go:25 "skipped" note is updated.
  • internal/provider/providertest/{fake.go,conformance.go}: the fake's ReadChat takes a ReadSpec and records ReadCall.Model; conformance.go gains the nil-pinning obligations for idle/empty and unknown-model reads.
  • internal/chat/{chat.go,tailer.go}: Read and the tailer build a ReadSpec, passing run.Model alongside the existing three inputs.
  • internal/httpapi/chat.go: messagesResponse gains context_usage, forwarded from Chat.ContextUsage.
  • web/src/api/chat.ts: the response type gains optional context_usage; web/src/routes/runchat/ChatHeader.tsx extends the model·effort chip with the tinted percentage and the N of M tokens title, hidden when absent and below the existing 640px model-chip gate.
  • Edge behavior falls out of the above: /clear resets via transcript-identity rotation, /compact stays stale until the next assistant turn, and ended runs read ContextUsage because the persisted runs.model still feeds the denominator lookup.