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.
Limitis 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.gogainstype ContextUsage struct { Used, Limit int64 }andChat.ContextUsage *ContextUsage(jsoncontext_usage,omitempty), nil when unknown or unsupported. Raw counts only — the UI does the division and formats. No fraction, no percentage crosses the seam. ReadChattakes aReadSpec.ReadChat(runID, runtimeDir, transcriptPath string)becomesReadChat(spec ReadSpec)withReadSpec{RunID, RuntimeDir, TranscriptPath, Model string}.Modelis the run's spawn-time model catalog value, already persisted onruns.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
/modelswitch is knowingly not reflected — matching the existing spawn-time-truth convention atweb/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
usageobject.chat_types.gostarts decodingusageon assistant lines.Used = input_tokens + cache_read_input_tokens + cache_creation_input_tokensof the latest assistant message.Limitcomes from an adapter-internal map keyed on the spawn-time model catalog value:opus200k,opus[1m]1M,sonnet200k,fable1M,haiku200k — 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 sharedModelOptiontype is not extended. - codex fills it from
token_count. The fold stops skippingtoken_countevent_msgrecords.Usedcomes from the event's own token totals;Limitfrom its self-reportedmodel_context_window. TheReadSpec.Modelinput 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.tsxrendersOpus · high · 62%; the percentage tints amber at ≥80% and red at ≥95%, and thetitlereads127k 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, noInstanceView/list-API change, no/metricsgauge. - Edge semantics. No assistant message yet → nil → hidden.
/clearrotates transcript identity (the existing mechanism) so the meter resets on the next turn automatically./compactleaves the value stale until the next assistant message, then it drops — accepted for v1, no eager compaction detection. Ended runs stay readable: the persistedruns.modelfeedsReadSpec.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
Chatriding 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/Limitcounts 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 the127k of 200ktitle 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.
ReadSpecadds 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:
fableis 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
/metricsper-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
ModelOptionwith 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-reportedmodel_context_window). Widening the cross-providerModelOptionwould leak one adapter's window facts into the shared type.
Consequences¶
internal/provider/provider.go: addsContextUsage {Used, Limit int64},Chat.ContextUsage *ContextUsage(jsoncontext_usage,omitempty), andReadSpec{RunID, RuntimeDir, TranscriptPath, Model string};AgentProvider.ReadChattakes aReadSpec— the one interface churn.internal/provider/claudecode/{chat.go,chat_types.go}:chat_types.godecodes the assistantusageobject;chat.gosums the three token fields for the latest assistant message and looksLimitup in the adapter-private model→window map, nil on unknown model.internal/provider/codex/{chat.go,chat_types.go}: the fold stops skippingtoken_count;chat.goreadsUsedfrom the event andLimitfrommodel_context_window, ignoringReadSpec.Model; thechat_types.go:25"skipped" note is updated.internal/provider/providertest/{fake.go,conformance.go}: the fake'sReadChattakes aReadSpecand recordsReadCall.Model;conformance.gogains the nil-pinning obligations for idle/empty and unknown-model reads.internal/chat/{chat.go,tailer.go}:Readand the tailer build aReadSpec, passingrun.Modelalongside the existing three inputs.internal/httpapi/chat.go:messagesResponsegainscontext_usage, forwarded fromChat.ContextUsage.web/src/api/chat.ts: the response type gains optionalcontext_usage;web/src/routes/runchat/ChatHeader.tsxextends the model·effort chip with the tinted percentage and theN of M tokenstitle, hidden when absent and below the existing 640px model-chip gate.- Edge behavior falls out of the above:
/clearresets via transcript-identity rotation,/compactstays stale until the next assistant turn, and ended runs readContextUsagebecause the persistedruns.modelstill feeds the denominator lookup.