diff --git a/devlog/2026-09-08_reasoning-in-context-estimate/REQ.md b/devlog/2026-09-08_reasoning-in-context-estimate/REQ.md new file mode 100644 index 00000000..1819d671 --- /dev/null +++ b/devlog/2026-09-08_reasoning-in-context-estimate/REQ.md @@ -0,0 +1,66 @@ +# REQ - Count reasoning tokens in context-usage display estimates + +- Task ID: `2026-09-08_reasoning-in-context-estimate` +- Home Repo: `opencode-acp` +- Created: 2026-09-08 +- Status: InProgress +- Priority: P1 +- Owner: ework-daemon +- References: https://github.com/ranxianglei/opencode-acp/issues/371 (source: #368 secondary finding A), related PR #370 + +## 1. Background & Problem Statement + +- **Context**: ACP displays context-usage percentages in two places derived from message *content*: the nudge breakdown (`Breakdown: ...` line appended to nudges) and the `acp_status` overview (`CONTEXT BREAKDOWN` line). The decision path (when to nudge) uses API-reported tokens, which include reasoning. +- **Current behavior (symptom)**: Both content-based estimators count only `text` + `tool` parts and skip `reasoning` parts entirely: + - `estimateContextComposition` (`lib/messages/inject/utils.ts:586`, per-part loop :623-665) — powers the nudge breakdown. + - `collectVisibleMessages` + `renderOverview` (`lib/compress/status.ts:125/:185`) — powers the `acp_status` overview. (Note: `acp_status` does NOT call `estimateContextComposition`; the import at `status.ts:9` is dead.) + - Real usage formula includes reasoning: `lib/token-utils.ts:44` (`input + cacheRead + cacheWrite + output + reasoning`). + - Result: displayed percentages systematically undercount real usage, and the largest residual component (reasoning — see #368) is invisible in the display. + - Not affected: `/acp context` command (`lib/commands/context.ts:119`) — its TOTAL comes from API-reported tokens and already includes reasoning. +- **Expected behavior**: Both display estimators count `reasoning` parts (same `len/4` heuristic as text parts) and show reasoning as its own breakdown category, so displayed totals align with the real-usage formula. +- **Impact**: Users cannot perceive the reasoning floor; displayed % disagrees with billing/limit accounting. + +## 2. Reproduction (if applicable) + +- **Environment**: any session with a thinking/reasoning model (assistant messages carry `reasoning` parts). +- **Minimal reproduction steps**: + 1) Run a session with a reasoning model until context usage crosses the nudge threshold. + 2) Compare the `Breakdown:` line total (and `/acp status` overview total) against the API-reported usage — the display total is lower by roughly the reasoning token count. +- **Relevant configuration**: none (display-only paths). + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Backward compatibility: display-only change; no persisted-state, internal-tag, config, or API changes. Breakdown lines gain a category (tests assert only `includes("Breakdown:")` / `includes("CONTEXT BREAKDOWN")` — verified safe). + - Performance: one extra branch in two existing per-part loops; negligible. + - Keep the `len/4` heuristic consistent with existing parts (no tokenizer introduction). +- **Non-Goals** (explicitly out of scope): + - Stripping reasoning at request time (that is PR #370). + - Changing `buildCompressibleRanges` range-token semantics (ranges = compressible amounts; reasoning on protected messages is the incompressible floor). + - Changing the decision path (`getCurrentTokenUsage` already includes reasoning). + - Making `acp_status` aware of PR #370's request-time stripping (known interaction, noted in PR description). + - `countAllMessageTokens` fallback (first-turn only; no reasoning present at that point). + +## 4. Acceptance Criteria (must be testable) + +- **Correctness**: + - [ ] `estimateContextComposition` returns a `reasoningTokens` field; `total` = system + tool + summary + message + reasoning. + - [ ] Reasoning on protected messages is included in `protectedTokens`. + - [ ] Nudge breakdown line shows a `reasoning (Q%)` category. + - [ ] `acp_status` overview `CONTEXT BREAKDOWN` line shows a `reasoning (Q%)` category and includes it in the total. + - [ ] Per-message drilldown token counts include reasoning (consistent with overview total). +- **Performance / Stability**: + - [ ] No change to nudge decision behavior (decision path untouched). +- **Regression**: + - [ ] New/modified test cases added to test suite and passing (unit tests for both estimators + overview rendering; full suite green). + +## 5. Proposed Approach (optional) + +- **Affected modules & entry files**: + - `lib/messages/inject/utils.ts` — `ContextComposition` interface + `estimateContextComposition` per-part loop + total. + - `lib/messages/inject/inject.ts` — nudge breakdown line. + - `lib/compress/status.ts` — `VisibleMessageInfo` (+`reasoning` field), `collectVisibleMessages`, `renderOverview` (total + breakdown line), drilldown totals. + - `lib/prompts/system.ts` — CONTEXT BREAKDOWN example line + category bullets. + - `tests/inject-utils-pure.test.ts`, `tests/acp-status.test.ts` (+ possibly `tests/protection-aware-stats.test.ts`). +- **Risks**: low — additive field + display line; verified no exact-format test assertions. +- **Rollback strategy**: revert the single commit. diff --git a/devlog/2026-09-08_reasoning-in-context-estimate/WORKLOG.md b/devlog/2026-09-08_reasoning-in-context-estimate/WORKLOG.md new file mode 100644 index 00000000..c4856b81 --- /dev/null +++ b/devlog/2026-09-08_reasoning-in-context-estimate/WORKLOG.md @@ -0,0 +1,60 @@ +# WORKLOG - Count reasoning tokens in context-usage display estimates + +- Task ID: `2026-09-08_reasoning-in-context-estimate` +- Branch: `2026-09-08_reasoning-in-context-estimate` +- Base: `origin/master` @ `9b7adfd` (v1.14.27) + +## Changes + +### `lib/messages/inject/utils.ts` +- `ContextComposition`: added `reasoningTokens: number`. +- `estimateContextComposition` per-part loop: new `part.type === "reasoning"` branch — `Math.round(text.length / 4)` (same heuristic as text parts), added to `msgTotal` and `reasoningTokens` (NOT to `messageTokens`, so text/code classification stays clean). +- `total` now = `systemTokens + toolTokens + summaryTokens + messageTokens + reasoningTokens`. +- Consequences (intended): reasoning on protected messages flows into `protectedTokens`; reasoning-only / reasoning-heavy messages appear in `largestRanges` with full footprint. + +### `lib/messages/inject/inject.ts` +- Nudge breakdown line (:604): added `| N reasoning (Q%)` category (always shown, consistent with other zero-capable categories). + +### `lib/compress/status.ts` +- `VisibleMessageInfo`: added `reasoning: number` (tracked separately from `tokens` = text+tool to avoid double counting in the overview total). +- `collectVisibleMessages`: counts reasoning parts per message; inclusion gate widened to `tokens > 0 || reasoning > 0` (reasoning-only messages now appear in the visible listing). +- `renderOverview`: `totalReasoning` aggregate; `total` includes it; `CONTEXT BREAKDOWN` line gains `| N reasoning (Q%)`. +- `renderUncompressedDrilldown`: `sizeOf(m) = m.tokens + m.reasoning` used for size/tool sorting, header totals, and per-message line tokens (full footprint). + +### `lib/prompts/system.ts` +- CONTEXT BREAKDOWN example line + category bullets: added reasoning. + +### Tests +- `tests/inject-utils-pure.test.ts`: +4 — reasoning counted in `reasoningTokens`/`total`; total formula includes reasoning; mixed message (msgTotal vs messageTokens + largestRanges footprint); no-reasoning regression guard. +- `tests/protection-aware-stats.test.ts`: +1 — reasoning on a protected message counted in `protectedTokens` (exact `reasoningTokens === 200`). +- `tests/acp-status.test.ts`: +3 — overview reasoning category (100 text/33% + 200 reasoning/67% of 300); reasoning-only message visible (overview 100% + drilldown line); drilldown per-message footprint includes reasoning. +- `tests/inject.test.ts`: +1 — rendered nudge breakdown line shows `2.0K reasoning (Q%)` (added per test review). +- `tests/acp-status.test.ts` (pre-existing fixes): 2 vacuous tests (partial mocks dropped by `filterMessages`) given complete mocks + real listing-line assertions (per both reviews). + +## Dual-agent review (AGENTS.md §5.3 + §5.6) + +Both reviewers: **APPROVE**. Findings addressed in this branch: + +- **Test reviewer finding (minor)**: rendered nudge breakdown line was untested → added `tests/inject.test.ts` "E2E: nudge breakdown line shows reasoning category with token count (#371)" (asserts `2.0K reasoning (\d+%)` in the injected nudge). +- **Both reviewers (minor)**: pre-existing vacuous tests `tests/acp-status.test.ts:286-305` / `:307-328` used partial mocks (missing `info.sessionID`/`info.time.created`) that `filterMessages` drops → tests passed with ZERO visible messages. Fixed: complete mocks + assertions on the actual `m00001 (...) text|bash` listing lines. +- **Test reviewer (nit)**: tightened `comp.reasoningTokens >= 200` → `assert.equal(comp.reasoningTokens, 200)`. +- **Code reviewer (nit)**: system prompt example percentages now sum to 100% (were 121% pre-existing, 131% after first edit). + +Not addressed (documented, non-blocking): +- Composition-vs-range divergence (code reviewer minor): `buildCompressibleRanges` range tokens still exclude reasoning (intentional — ranges = compressible amounts; the pipeline's min-size check `countMessageCharacters` also excludes reasoning, so adding it there risks phantom "Range too small" rejections per #37). Consequence: "Effective compressible: ~X" (nudge) and overview totals now include reasoning while per-range lines don't. Documented in PR description; candidate follow-up issue (source-tagged). +- Reasoning-only messages render with `text` label in the drilldown (`toolName || "text"`); `classifyMessageType` would say `reasoning` — label-semantics change, out of scope. +- Per-message `dcp-message-id` token annotation (`countMessageCharacters`, token-utils.ts) still excludes reasoning — pre-existing, out of scope, candidate follow-up. + +## Verification + +- `npm run typecheck` — clean. +- `npm run test` — **1086/1086 pass** (was 1077 on master; +9 new). +- `npm run build` — clean. +- `npm run format:check` — repo-wide pre-existing Prettier drift (423 files fail on clean master, incl. all 7 touched files); CI does not run format checks; no reformat to keep the diff minimal. +- Test-input fidelity note: `filterMessages` (`lib/messages/shape.ts:14-24`) drops messages lacking `info.sessionID`/`info.time.created` — all acp_status tests (new + 2 pre-existing fixed per review) use complete mocks. + +## Open items / known interactions + +- PR #370 (`stripProtectedReasoning`, open): its pass runs BEFORE `injectCompressNudges` in `lib/hooks.ts`, so post-merge the nudge-path estimator naturally matches sent content. `acp_status` reads raw DB messages and will still show request-time-stripped reasoning — #370-side concern, noted in PR description only. +- `buildCompressibleRanges` range tokens still exclude reasoning (ranges = compressible amounts; protected-msg reasoning is the incompressible floor per #368) — intentional non-goal. +- `countAllMessageTokens` fallback (token-utils.ts) still excludes reasoning — first-turn only, no reasoning present at that point — intentional non-goal. diff --git a/lib/compress/status.ts b/lib/compress/status.ts index 9ddce308..645731f2 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -115,6 +115,9 @@ interface VisibleMessageInfo { tokens: number tool: string index: number + // Reasoning (thinking) part tokens, tracked separately from `tokens` + // (text+tool) so the overview can show it as its own category (#371). + reasoning: number } export interface StatusRenderContext { @@ -153,6 +156,7 @@ function collectVisibleMessages( if (!ref) return let tokens = 0 + let reasoning = 0 let toolName = "" for (const part of msg.parts || []) { @@ -164,11 +168,13 @@ function collectVisibleMessages( if (!toolName) { toolName = (part as any)?.tool || "unknown" } + } else if (part.type === "reasoning" && typeof (part as any).text === "string") { + reasoning += Math.round(((part as any).text as string).length / 4) } } - if (tokens > 0) { - result.push({ ref, tokens, tool: toolName || "text", index: idx }) + if (tokens > 0 || reasoning > 0) { + result.push({ ref, tokens, tool: toolName || "text", index: idx, reasoning }) } }) @@ -209,16 +215,18 @@ function renderOverview( const totalText = visibleMessages .filter((m) => m.tool === "text") .reduce((s, m) => s + m.tokens, 0) - const total = systemTokens + totalTool + totalText + summaryTokens + const totalReasoning = visibleMessages.reduce((s, m) => s + m.reasoning, 0) + const total = systemTokens + totalTool + totalText + summaryTokens + totalReasoning const sysPct = pct(systemTokens, total) const toolPct = pct(totalTool, total) const textPct = pct(totalText, total) const summaryPct = pct(summaryTokens, total) + const reasoningPct = pct(totalReasoning, total) lines.push("CONTEXT BREAKDOWN") lines.push( - ` ${formatTokens(systemTokens)} system (${sysPct}%) | ${formatTokens(totalTool)} tool (${toolPct}%) | ${formatTokens(totalText)} text (${textPct}%) | ${formatTokens(summaryTokens)} summaries (${summaryPct}%)`, + ` ${formatTokens(systemTokens)} system (${sysPct}%) | ${formatTokens(totalTool)} tool (${toolPct}%) | ${formatTokens(totalText)} text (${textPct}%) | ${formatTokens(summaryTokens)} summaries (${summaryPct}%) | ${formatTokens(totalReasoning)} reasoning (${reasoningPct}%)`, ) const topTypes = Array.from(toolTypeMap.entries()) @@ -357,16 +365,18 @@ function renderUncompressedDrilldown( filtered = filtered.filter((m) => m.tool === toolFilter) } + const sizeOf = (m: VisibleMessageInfo) => m.tokens + m.reasoning + if (sort === "time") { filtered.sort((a, b) => a.index - b.index) } else if (sort === "tool") { - filtered.sort((a, b) => a.tool.localeCompare(b.tool) || b.tokens - a.tokens) + filtered.sort((a, b) => a.tool.localeCompare(b.tool) || sizeOf(b) - sizeOf(a)) } else { - filtered.sort((a, b) => b.tokens - a.tokens) + filtered.sort((a, b) => sizeOf(b) - sizeOf(a)) } - const totalTokens = filtered.reduce((s, m) => s + m.tokens, 0) - const allTokens = visibleMessages.reduce((s, m) => s + m.tokens, 0) + const totalTokens = filtered.reduce((s, m) => s + sizeOf(m), 0) + const allTokens = visibleMessages.reduce((s, m) => s + sizeOf(m), 0) const header = toolFilter ? `UNCOMPRESSED — ${toolFilter}: ${formatTokens(totalTokens)} | ${filtered.length} msgs | ${pct(totalTokens, allTokens)}% of visible` @@ -378,7 +388,7 @@ function renderUncompressedDrilldown( const shown = filtered.slice(0, limit) for (const m of shown) { - lines.push(` ${m.ref} (${formatTokens(m.tokens)}) ${m.tool}`) + lines.push(` ${m.ref} (${formatTokens(sizeOf(m))}) ${m.tool}`) } if (filtered.length > shown.length) { diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 35f96a12..e84d5938 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -601,7 +601,7 @@ export const injectCompressNudges = ( const sysPart = composition.systemTokens > 0 ? `${fmt(composition.systemTokens)} system (${pct(composition.systemTokens)}%) | ` : "" - let breakdown = `${efficiencyNote}\nBreakdown: ${sysPart}${fmt(composition.toolTokens)} tool (${pct(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct(plainTextTokens)}%)${growthStr}` + let breakdown = `${efficiencyNote}\nBreakdown: ${sysPart}${fmt(composition.toolTokens)} tool (${pct(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct(plainTextTokens)}%) | ${fmt(composition.reasoningTokens)} reasoning (${pct(composition.reasoningTokens)}%)${growthStr}` const compressibleTokens = composition.total - diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index b5a7e07f..a0a4079b 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -561,6 +561,7 @@ export interface ContextComposition { textTokens: number systemTokens: number protectedTokens: number + reasoningTokens: number total: number largestRanges: { ref: string; tokens: number }[] largestToolRanges: { ref: string; tokens: number; tool?: string }[] @@ -594,6 +595,7 @@ export function estimateContextComposition( let summaryTokens = 0 let messageTokens = 0 let protectedTokens = 0 + let reasoningTokens = 0 const perMessage: { ref: string; tokens: number }[] = [] const perTool: { ref: string; tokens: number; tool?: string }[] = [] const perCode: { ref: string; tokens: number }[] = [] @@ -662,6 +664,12 @@ export function estimateContextComposition( summaryTokens += summaryPartTokens toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + toolPartTokens) if (!msgToolName) msgToolName = toolName + } else if (part.type === "reasoning" && typeof (part as any).text === "string") { + // Real usage includes reasoning (token-utils.ts) — track as its own + // category so display totals align with the usage formula (#371). + const tokens = Math.round(((part as any).text as string).length / 4) + msgTotal += tokens + reasoningTokens += tokens } } @@ -700,7 +708,8 @@ export function estimateContextComposition( textTokens: Math.max(0, messageTokens - codeTokens), systemTokens, protectedTokens, - total: systemTokens + toolTokens + summaryTokens + messageTokens, + reasoningTokens, + total: systemTokens + toolTokens + summaryTokens + messageTokens + reasoningTokens, largestRanges: perMessage.slice(0, 15), largestToolRanges: perTool.slice(0, 15), largestCodeRanges: perCode.slice(0, 5), diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index 8e9b48a2..b01b5a4d 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -73,13 +73,14 @@ CONTEXT BREAKDOWN When context usage passes a threshold, the system appends a breakdown showing where your context tokens are spent: -Breakdown: 5.2K system (21%) | 12.3K tool (40%) | 3.1K summaries (10%) | 8.5K code (28%) | 6.5K text (22%) +Breakdown: 4.2K system (21%) | 8.0K tool (40%) | 2.0K summaries (10%) | 2.6K code (13%) | 2.2K text (11%) | 1.0K reasoning (5%) - "system" = system prompt tokens (AGENTS.md, tool definitions — not compressible) - "tool" = tool call outputs (largest category — compress first when consumed) - "summaries" = existing compression block summaries (already compressed; do not re-compress standalone) - "code" = messages containing code blocks - "text" = plain text messages +- "reasoning" = model thinking blocks (counted to match real API usage; freed when their message is compressed) Below the breakdown, the system lists compressible ranges grouped by conversation turn. All listed ranges should be compressed to summary format — the only exceptions are protected content, content the current step is actively using, or critical content you cannot reconstruct. Compress the largest ranges first when the current step no longer needs them. diff --git a/tests/acp-status.test.ts b/tests/acp-status.test.ts index 276cca95..0b8e17d7 100644 --- a/tests/acp-status.test.ts +++ b/tests/acp-status.test.ts @@ -285,7 +285,10 @@ test("acp_status: scope=uncompressed defaults to ranges view", async () => { test("acp_status: scope=uncompressed view=messages shows per-message listing", async () => { const mockMsgs = [ - { info: { id: "raw-1", role: "assistant" }, parts: [{ type: "text", text: "hello world" }] }, + { + info: { id: "raw-1", role: "assistant", sessionID: SID, time: { created: 1 } }, + parts: [{ type: "text", text: "hello world" }], + }, ] const mockClient = makeMockClient(mockMsgs) const state = makeState([], new Map()) @@ -302,12 +305,13 @@ test("acp_status: scope=uncompressed view=messages shows per-message listing", a assert.match(result, /UNCOMPRESSED/) assert.match(result, /Sorted by/) + assert.match(result, /m00001 \(\d+\) text/, "per-message listing must include the visible message") }) test("acp_status: scope=uncompressed view=messages with tool filter shows filter in header", async () => { const mockMsgs = [ { - info: { id: "raw-1", role: "assistant" }, + info: { id: "raw-1", role: "assistant", sessionID: SID, time: { created: 1 } }, parts: [{ type: "tool", tool: "bash", state: { input: { command: "ls" } } }], }, ] @@ -325,6 +329,7 @@ test("acp_status: scope=uncompressed view=messages with tool filter shows filter const result = await statusTool.execute({ scope: "uncompressed", view: "messages", tool: "bash" } as any, { sessionID: SID } as any) assert.match(result, /UNCOMPRESSED — bash:/) + assert.match(result, /m00001 \(\d+\) bash/, "filtered listing must include the bash message") }) test("acp_status: invalid scope falls back to overview", async () => { @@ -438,3 +443,91 @@ test("acp_status: overview prefers cached systemPromptTokens over degraded visib assert.match(result, /10\.0K system/) assert.doesNotMatch(result, /200\.0K system/) }) + +test("acp_status: overview counts reasoning as its own category (#371)", async () => { + const mockMsgs = [ + { + info: { id: "raw-1", role: "assistant", sessionID: SID, time: { created: 1 } }, + parts: [ + { type: "text", text: "x".repeat(400) }, + { type: "reasoning", text: "y".repeat(800) }, + ], + }, + ] + const mockClient = makeMockClient(mockMsgs) + const state = makeState([], new Map()) + state.messageIds.byRawId.set("raw-1", "m00001") + const ctx: ToolFactoryContext = { + client: mockClient, + registry: singletonRegistry(state), + logger: { enabled: false } as any, + config: {} as any, + prompts: { reload: () => {} } as any, + } + const statusTool = createAcpStatusTool(ctx) + const result = await statusTool.execute({} as any, { sessionID: SID } as any) + + // text=100, reasoning=200, system=0, tool=0, summaries=0 → total=300 + assert.match(result, /CONTEXT BREAKDOWN/) + assert.match(result, /100 text \(33%\)/) + assert.match(result, /200 reasoning \(67%\)/) +}) + +test("acp_status: reasoning-only message appears in visible listing (#371)", async () => { + const mockMsgs = [ + { + info: { id: "raw-1", role: "assistant", sessionID: SID, time: { created: 1 } }, + parts: [{ type: "reasoning", text: "y".repeat(800) }], + }, + ] + const mockClient = makeMockClient(mockMsgs) + const state = makeState([], new Map()) + state.messageIds.byRawId.set("raw-1", "m00001") + const ctx: ToolFactoryContext = { + client: mockClient, + registry: singletonRegistry(state), + logger: { enabled: false } as any, + config: {} as any, + prompts: { reload: () => {} } as any, + } + const statusTool = createAcpStatusTool(ctx) + const overview = await statusTool.execute({} as any, { sessionID: SID } as any) + assert.match(overview, /200 reasoning \(100%\)/) + + const drilldown = await statusTool.execute( + { scope: "uncompressed", view: "messages" } as any, + { sessionID: SID } as any, + ) + assert.match(drilldown, /m00001 \(200\) text/) +}) + +test("acp_status: per-message drilldown includes reasoning tokens (#371)", async () => { + const mockMsgs = [ + { + info: { id: "raw-1", role: "assistant", sessionID: SID, time: { created: 1 } }, + parts: [ + { type: "text", text: "x".repeat(400) }, + { type: "reasoning", text: "y".repeat(800) }, + ], + }, + ] + const mockClient = makeMockClient(mockMsgs) + const state = makeState([], new Map()) + state.messageIds.byRawId.set("raw-1", "m00001") + const ctx: ToolFactoryContext = { + client: mockClient, + registry: singletonRegistry(state), + logger: { enabled: false } as any, + config: {} as any, + prompts: { reload: () => {} } as any, + } + const statusTool = createAcpStatusTool(ctx) + const result = await statusTool.execute( + { scope: "uncompressed", view: "messages" } as any, + { sessionID: SID } as any, + ) + + // text=100 + reasoning=200 → per-message footprint 300 + assert.match(result, /m00001 \(300\) text/) + assert.match(result, /UNCOMPRESSED — 300/) +}) diff --git a/tests/inject-utils-pure.test.ts b/tests/inject-utils-pure.test.ts index 773b2eef..3db39845 100644 --- a/tests/inject-utils-pure.test.ts +++ b/tests/inject-utils-pure.test.ts @@ -487,3 +487,55 @@ test("cacheSystemPromptTokens: keeps undefined when no reliable assistant token cacheSystemPromptTokens(state, [mkText("u1", "no assistant yet")]) assert.equal(state.systemPromptTokens, undefined) }) + +function mkReasoning(id: string, text: string): WithParts { + return { + info: { id } as any, + parts: [{ type: "reasoning", text, id: `${id}-p`, sessionID: "s", messageID: id }] as any, + } +} + +test("estimateContextComposition: reasoning part counted in reasoningTokens and total (#371)", () => { + const msg = mkReasoning("m1", "x".repeat(800)) + const c = estimateContextComposition([msg]) + assert.equal(c.reasoningTokens, 200) + assert.equal(c.messageTokens, 0, "reasoning is not message/text") + assert.equal(c.toolTokens, 0) + assert.equal(c.total, 200) +}) + +test("estimateContextComposition: total = system + tool + summary + message + reasoning (#371)", () => { + const msgs = [ + mkText("m1", "hello world"), + mkTool("m2", '{"a":1}'), + mkSummary("b0", "recap text"), + mkReasoning("m3", "z".repeat(800)), + ] + const c = estimateContextComposition(msgs) + assert.equal( + c.total, + c.systemTokens + c.toolTokens + c.summaryTokens + c.messageTokens + c.reasoningTokens, + ) + assert.equal(c.reasoningTokens, 200) +}) + +test("estimateContextComposition: reasoning on mixed message adds to msgTotal but not messageTokens (#371)", () => { + const msg = { + info: { id: "m1" } as any, + parts: [ + { type: "text", text: "x".repeat(2400) }, + { type: "reasoning", text: "y".repeat(800) }, + ] as any, + } + const c = estimateContextComposition([msg]) + assert.equal(c.messageTokens, 600) + assert.equal(c.reasoningTokens, 200) + assert.equal(c.total, 800) + assert.equal(c.largestRanges.length, 1) + assert.equal(c.largestRanges[0].tokens, 800, "per-message range reflects full footprint") +}) + +test("estimateContextComposition: no reasoning parts → reasoningTokens 0 (#371)", () => { + const c = estimateContextComposition([mkText("m1", "hello")]) + assert.equal(c.reasoningTokens, 0) +}) diff --git a/tests/inject.test.ts b/tests/inject.test.ts index 46aae64b..05e8586d 100644 --- a/tests/inject.test.ts +++ b/tests/inject.test.ts @@ -646,6 +646,37 @@ test("E2E: nudge recommendation content includes composition breakdown and compr ) }) +test("E2E: nudge breakdown line shows reasoning category with token count (#371)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 200_000 + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minContextLimit = 200_000 + + const messages: WithParts[] = [ + userMsg("u1", "hello"), + { + info: { + id: "a1", role: "assistant", sessionID: SID, agent: "a", time: { created: 2 }, + tokens: { input: 200_000, output: 55_000 }, + } as WithParts["info"], + parts: [ + { id: "a1-r", messageID: "a1", sessionID: SID, type: "reasoning" as const, text: "z".repeat(8_000) }, + textPart("a1", "done"), + ], + }, + ] + injectCompressNudges(state, config, logger, messages, {} as any) + + assert.equal(state.nudges.shouldInjectThisTurn, true, "should nudge (55K growth >= 50K threshold)") + + const injected = suffixText(messages) + assert.ok(injected.includes("Breakdown:"), "nudge must include composition breakdown") + // 8_000 chars of reasoning / 4 = 2_000 tokens + assert.match(injected, /2\.0K reasoning \(\d+%\)/, "breakdown must show reasoning category with its token count") +}) + test("growth floor: nudge suppressed when growth below floor (issue #27 anti-thrashing)", () => { // 1M model: growthFloor = max(5000, 0.45×50000) = 22500 // Growth of 5K < 22500 → no nudge output at all diff --git a/tests/protection-aware-stats.test.ts b/tests/protection-aware-stats.test.ts index 55910e52..7147be58 100644 --- a/tests/protection-aware-stats.test.ts +++ b/tests/protection-aware-stats.test.ts @@ -205,3 +205,24 @@ test("formatCompressibleRanges shows PROTECTED-only line when entire area is pro assert.ok(formatted.includes("skill"), "shows protected tool name") assert.ok(formatted.includes("task"), "shows protected tool name") }) + +test("estimateContextComposition: reasoning on protected message counted in protectedTokens (#371)", () => { + const state = createSessionState() + const protectedMsg: WithParts = { + info: { id: "m2", role: "assistant", sessionID: SID, agent: "a", time: { created: 1 } } as any, + parts: [ + { type: "text", text: "protected skill output" }, + toolPart("t1", "skill"), + { type: "reasoning", text: "z".repeat(800) }, + ], + } + const messages = [makeMsg("m1", "user", "hello"), protectedMsg] + setupRefs(state, messages) + + const comp = estimateContextComposition(messages, state, ["skill"]) + assert.equal(comp.reasoningTokens, 200, "reasoning tokens counted exactly (800 chars / 4)") + assert.ok( + comp.protectedTokens >= comp.reasoningTokens, + "protected tokens include the protected message's reasoning", + ) +})