# Issue 1 — claude-cli `context_size` is severely understated

**Severity:** HIGH (silently breaks auto-compaction; misleads UI context-pressure indicator).
**Engine scope:** claude-cli only. Openai engine is correct.
**Discovery:** user-reported — sessions on cc-* agents show 0–5% context usage even after many turns.

---

## Context

`sessions.context_size` is documented (and used in code) as **prompt + completion tokens of the last LLM turn** — i.e. how full the model's context window is right now. It's the input to:

- UI context-usage bar (`GET /sessions/:id`).
- Auto-compaction threshold check: `maybeAutoCompact` in [core/loop.js:32-65](core/loop.js#L32) fires `runDefaultCompaction` when `context_size / context_size_limit × 100 ≥ compact_auto_threshold`.

If `context_size` is tiny, the % stays near zero and auto-compaction NEVER fires — leading to runaway context that overflows the model.

---

## Root cause

[engines/claude-engine.js:571-584](engines/claude-engine.js#L571) is the only place that writes `sessions.context_size` for claude-cli sessions. It calls `mapper.extractUsage(sdkMsg)` (which reads the SDK `result` message's top-level `usage`) and writes:

```js
contextSize: usage.input + usage.output
```

The Claude SDK's `BetaUsage` shape (per `@anthropic-ai/sdk/resources/beta/messages/messages.d.ts:1005-1006`) explicitly states:

> "Total input tokens in a request is the summation of `input_tokens`, `cache_creation_input_tokens`, and `cache_read_input_tokens`."

Once the prompt cache warms after turn 1, `input_tokens` collapses to just the **new (uncached) user-turn tokens** (often a few hundred), while the bulk of the prompt is reported under `cache_read_input_tokens`. The mapper at [engines/claude-event-mapper.js:125-136](engines/claude-event-mapper.js#L125) already extracts `cache_read_input_tokens` (as `cache`), but **never extracts `cache_creation_input_tokens`**, and the engine's `db.updateSession` call ignores the `cache` field for `contextSize`.

Net effect on long sessions: `context_size ≈ new user tokens + assistant output ≈ a few hundred` against a 200k limit → 0–5% forever, auto-compaction never triggers.

The openai engine ([core/loop.js:507](core/loop.js#L507)) is correct because OpenAI's `prompt_tokens` is the **full prompt size** (cached portion is reported separately as an informational subset, not subtracted).

---

## Fix

### Change 1 — [engines/claude-event-mapper.js:125-136](engines/claude-event-mapper.js#L125)

Extend `extractUsage` to surface both cache fields and sum them in the existing `cache` field for backward-compat.

```js
function extractUsage(resultMessage) {
  const usage = (resultMessage && resultMessage.usage) || {};
  return {
    input:          usage.input_tokens || 0,
    output:         usage.output_tokens || 0,
    cacheRead:      usage.cache_read_input_tokens || 0,
    cacheCreation:  usage.cache_creation_input_tokens || 0,
    cache:          (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0),
    thinkingTokens: usage.completion_tokens_details?.reasoning_tokens || 0,
    cost:           (resultMessage && resultMessage.total_cost_usd) || 0,
  };
}
```

`cache` is now the **sum** of both cache fields so existing callers (`totalTokens.cache`) record the entire cached pool. `cacheRead` / `cacheCreation` are exposed individually for any future consumer.

### Change 2 — [engines/claude-engine.js:571-584](engines/claude-engine.js#L571)

Write the **full prompt-window size** to `context_size`. Replace the `db.updateSession` call's `contextSize` line with:

```js
contextSize: usage.input + usage.cacheRead + usage.cacheCreation + usage.output,
```

(i.e. `input_tokens + cache_read + cache_creation + output_tokens` = the entire context window the model just saw on this turn.)

Keep the `totalTokens.cache += usage.cache` accumulator — with Change 1 it now correctly aggregates both cache subfields.

### Change 3 (defer until #1+#2 verified) — [engines/claude-engine.js:435-441](engines/claude-engine.js#L435)

Optional event-shape parity: the assistant-message `tokenUsage` yield should mirror what `core/loop.js:530` yields on the openai path. Map the assistant `message.usage` (same BetaUsage):

```js
const tokenUsage = rawUsage ? {
  input:  rawUsage.input_tokens || 0,
  output: rawUsage.output_tokens || 0,
  cache:  (rawUsage.cache_read_input_tokens || 0) + (rawUsage.cache_creation_input_tokens || 0),
  cost:   0,
} : null;
```

Pure event-shape consistency; doesn't affect `context_size` (only `result` writes that). Audit any downstream consumer of yielded `tokenUsage.cache` before shipping (cost dashboards summing it separately from `cost` could double-count).

**No DB-schema migration needed** — `context_size INTEGER NOT NULL DEFAULT 0` already accommodates the corrected magnitude.

---

## Test design

New file: `test/e2e/scenario-context-size-claude.js`. Runs against the live server at `http://localhost:5050`, agent: `cc-haiku` (cheapest cc/* model).

1. **Setup.** `POST /agents/cc-haiku/chat` with `{ message: "Reply with the single word 'ok'.", sse: true }`. Capture `sessionId` from the `done` SSE event.

2. **Baseline (turn 1).** `GET /sessions/:id`, read `context_size` → record as `ctx1`. Should be a few hundred to low-thousands (system prompt + user msg + reply).

3. **Drive history.** Send 4 more turns to the same session, each with "Reply 'ok' again.". After each turn, `GET /sessions/:id` and record `ctx2..ctx5`.

4. **Pre-fix expectation (proves the bug).** With the bug, `ctx2..ctx5` STAY at the same low magnitude as `ctx1` (or drop, because most prompt now lands under `cache_read_input_tokens`). Assert `ctx5 / context_size_limit < 0.01` — this is the **failing** assertion before the fix.

5. **Post-fix expectation.** Assert `ctx5 > ctx1` strictly, AND monotonic: `ctx5 ≥ ctx4 ≥ ctx3 ≥ ctx2 ≥ ctx1`. Also assert `ctx5 ≥ 1.5 × ctx1` — confirms the cached-prompt portion is being counted.

6. **Cross-engine parity.** Same loop against `assistant` (openai engine). After 5 turns, assert both engines produce monotonic-growing `context_size`. Coarse magnitude parity: `0.3 ≤ ctxClaude / ctxOpenai ≤ 3` (different tokenizers / system-prompt sizes, but same order of magnitude).

7. **Auto-compaction smoke.** `PATCH /sessions/:id` with `{ compact_auto_threshold: 1, compact_enabled: true }`, then send one more chat turn. With the fix, `maybeAutoCompact` sees `pct ≥ 1%` and fires `runDefaultCompaction`. Assert `compact_size > 0` and `compact_summary` non-null after the turn. (Pre-fix this never fires.)

---

## Risks / edge cases

- **Turn 1 with no cache hit.** Both `cache_read_input_tokens` and `cache_creation_input_tokens` are 0 on the very first API call against an unwarmed session. Fix is correct (sum is just `input + output`). No regression.

- **Resumed/forked sessions.** When `claudeSessionId` is passed ([engines/claude-engine.js:301-308](engines/claude-engine.js#L301)) the SDK warms cache on first call after resume and reports a large `cache_read_input_tokens`. The fix correctly captures this; pre-fix this is exactly when underreporting is worst.

- **`SDKResultError` shape.** SDK types confirm the error result still carries `usage: NonNullableUsage`, so `extractUsage` returns valid numbers. No NaN risk — every field has `|| 0`.

- **Better-sqlite3 sync writes.** No race on concurrent `result` messages — chat mode keeps the loop alive ([claude-engine.js:591-602](engines/claude-engine.js#L591)) but `db.updateSession` is synchronous. `context_size` is **overwritten** each turn with the latest snapshot (correct: it's "current window pressure," not "cumulative"). `totalTokens.cache` accumulates across turns (correct: it's the running cumulative cache cost).

- **Field-name change in `extractUsage` return.** Single caller in `claude-engine.js:572`. `grep -rn "extractUsage" engines/` confirms no external module imports it. Safe.

- **`context_size_limit` accuracy.** Comes from `getContextLimitMerged(model, …)`. If the cc-haiku model entry's context window is wrong (should be 200k for Haiku 3.5/4.x), threshold percentages will be off by a constant factor. Orthogonal to this fix but worth a sanity check.

- **Change 3 deferral risk.** If a cost-tracker downstream sums `tokenUsage.cache` as billable separate from `cost`, Change 3 introduces double-counting. Inventory consumers before shipping #3. Ship #1 + #2 first; they fully resolve the reported bug.

---

## Implementation workflow

1. Apply Change 1 + Change 2 to the two files.
2. Write `test/e2e/scenario-context-size-claude.js` per the design above.
3. Run the test. Pre-fix it should fail step 4. Post-fix it should pass steps 5 + 6.
4. Run criticizer subagent on the diff + test results.
5. Apply criticizer-driven fixes if any.
6. Notify user with `notify-send` + ringtone.

## Files touched

| File | Change |
|------|--------|
| [engines/claude-event-mapper.js](engines/claude-event-mapper.js) | Extend `extractUsage` (Change 1) |
| [engines/claude-engine.js](engines/claude-engine.js) | Fix `contextSize` formula (Change 2); optional `tokenUsage` parity (Change 3, deferred) |
| `test/e2e/scenario-context-size-claude.js` | NEW — test design above |

---

## Criticizer-driven addenda (post-review v2)

**Two `extractUsage` functions exist.** One in [engines/claude-event-mapper.js:125](engines/claude-event-mapper.js#L125) (the one we're modifying), another in [llm/client.js:239](llm/client.js#L239) for the openai engine. Different modules, no conflict — but be explicit during implementation that we're touching ONLY the mapper's version.

**`cc-haiku` agent existence verified.** Available as a global agent at `/home/ixi/.veil/agents/cc-haiku` with model `cc/haiku`. Test design cleared.

**Concurrent-runChat-on-same-session race (low risk, document).** better-sqlite3 is synchronous per-statement, so each `db.updateSession` call is row-atomic. If two concurrent runChats on the same session happen to both write `context_size`, last-writer-wins — which is correct semantics for a "current window pressure" snapshot. No additional locking needed for this fix. The runtime serializes via `_runChatLoopLocks` anyway, so this scenario is rare to begin with.

**Pre-fix assertion sharpening.** Test step 4 asserts `ctx5 / context_size_limit < 0.01` to prove the bug. To make this more robust, also assert `ctx5 < ctx1 × 1.2` (i.e. context_size barely grew despite 5 turns of accumulating history). If both fail pre-fix, the bug is reliably reproduced.
