diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 44b9a9cc2..64fc658dc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `/context` (TUI panel and ACP report) now shows estimated snapcompact wire savings when `snapcompact.systemPrompt` or `snapcompact.toolResults` is enabled — per-feature text → frames token deltas, the reason a swap does not apply (savings margin, image budget, or text-only model), and the estimated size of the next request. The estimate and the live provider-request transform share one planner (`planInlineSwaps`) so displayed numbers cannot drift from wire behavior. - Added `/debug dump-request` and `/debug next-request` as aliases for `/debug dump-next-request` when arming a one-shot AI provider request dump - Added `/debug dump-next-request ` to dump the next AI provider HTTP request JSON to a chosen file. @@ -11,6 +12,7 @@ - Changed `/debug` handling in interactive mode so `/debug` with arguments now executes the requested debug subcommand instead of always opening the debug selector - Changed `/debug dump-next-request` path handling to expand `~` and resolve relative paths against the current working directory +- Changed the task tool's TUI block: the header now shows the task dispatch glyph (`tool.task`) while agents are in flight instead of a spinner (async spawns return immediately, so a spinner misread the call as blocking), and per-agent rows use one static dot for every state — completed rows keep the same dot and settle from accent to the plain foreground color instead of switching to a different status glyph ### Fixed diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 94545e416..bccb2b6a9 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -456,7 +456,7 @@ export class CommandController { } handleContextCommand(): void { - const breakdown = computeContextBreakdown(this.ctx.session); + const breakdown = computeContextBreakdown(this.ctx.session, { snapcompactSavings: true }); if (breakdown.contextWindow <= 0) { this.ctx.showWarning("Context usage is unavailable: no model is selected for this session."); return; diff --git a/packages/coding-agent/src/modes/utils/context-usage.ts b/packages/coding-agent/src/modes/utils/context-usage.ts index ebb22f2ee..7c527348e 100644 --- a/packages/coding-agent/src/modes/utils/context-usage.ts +++ b/packages/coding-agent/src/modes/utils/context-usage.ts @@ -6,6 +6,7 @@ import { countTokens } from "@oh-my-pi/pi-natives"; import { formatNumber } from "@oh-my-pi/pi-utils"; import type { Skill } from "../../extensibility/skills"; import type { AgentSession } from "../../session/agent-session"; +import { estimateInlineSavings, type SnapcompactSavingsEstimate } from "../../session/snapcompact-inline"; import type { Tool } from "../../tools"; import type { theme as Theme } from "../theme/theme"; @@ -36,6 +37,8 @@ export interface ContextBreakdown { usedTokens: number; autoCompactBufferTokens: number; freeTokens: number; + /** Estimated snapcompact wire savings; set when requested and a snapcompact.* setting is enabled. */ + snapcompact?: SnapcompactSavingsEstimate; } const EMPTY_STRING_PARTS: readonly string[] = []; @@ -109,7 +112,10 @@ function computeNonMessageBreakdown(session: AgentSession): { * Compute a breakdown of estimated context usage by category for the active * session and model. */ -export function computeContextBreakdown(session: AgentSession): ContextBreakdown { +export function computeContextBreakdown( + session: AgentSession, + options?: { snapcompactSavings?: boolean }, +): ContextBreakdown { const model = session.model; const contextWindow = model?.contextWindow ?? 0; @@ -169,6 +175,22 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown const freeTokens = Math.max(0, contextWindow - usedTokens - autoCompactBufferTokens); + // Estimated wire savings from snapcompact inline imaging. Opt-in: only the + // /context surfaces need it; other callers skip the extra token counting. + let snapcompactSavings: SnapcompactSavingsEstimate | undefined; + if (options?.snapcompactSavings) { + const renderSystemPrompt = session.settings.get("snapcompact.systemPrompt"); + const renderToolResults = session.settings.get("snapcompact.toolResults"); + if (renderSystemPrompt || renderToolResults) { + snapcompactSavings = estimateInlineSavings({ + options: { renderSystemPrompt, renderToolResults }, + model, + systemPrompt: session.systemPrompt ?? [], + messages: session.messages ?? [], + }); + } + } + return { model, contextWindow, @@ -176,6 +198,7 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown usedTokens, autoCompactBufferTokens, freeTokens, + snapcompact: snapcompactSavings, }; } @@ -298,6 +321,55 @@ function buildLegendLines(breakdown: ContextBreakdown, theme: typeof Theme): str ); } + const snap = breakdown.snapcompact; + if (snap) { + lines.push(""); + if (!snap.visionCapable) { + lines.push(theme.fg("muted", "Snapcompact: inactive (model has no image input)")); + } else { + lines.push(theme.fg("muted", "Snapcompact (estimated wire savings)")); + if (snap.systemPrompt) { + const sp = snap.systemPrompt; + if (sp.applied) { + lines.push( + ` System prompt: saves ${theme.bold(`~${formatNumber(sp.savedTokens)}`)} ` + + theme.fg( + "dim", + `(${formatNumber(sp.textTokens)} text → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${formatNumber(sp.imageTokens)})`, + ), + ); + } else { + const reason = + sp.reason === "budget" + ? "image budget exhausted" + : sp.reason === "empty" + ? "nothing to image" + : "frames would not save tokens"; + lines.push(` System prompt: ${theme.fg("dim", `stays text (${reason})`)}`); + } + } + if (snap.toolResults) { + const tr = snap.toolResults; + if (tr.swapped > 0) { + lines.push( + ` Tool results: saves ${theme.bold(`~${formatNumber(tr.savedTokens)}`)} ` + + theme.fg( + "dim", + `(${tr.swapped}/${tr.total} imaged, ${formatNumber(tr.textTokens)} text → ${tr.frames} frames ≈ ${formatNumber(tr.imageTokens)})`, + ), + ); + } else { + lines.push(` Tool results: ${theme.fg("dim", `none imaged (${tr.total} in history)`)}`); + } + } + if (snap.savedTokens > 0) { + lines.push( + ` Next request: ${theme.bold(`~${formatNumber(Math.max(0, usedTokens - snap.savedTokens))}`)} ${theme.fg("dim", "tokens on the wire")}`, + ); + } + } + } + return lines; } diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index cde31519b..6c43a138c 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -9,6 +9,10 @@ * input context shares `content` array references with the persisted * `SessionMessageEntry` messages, so mutation would leak rendered images * into session.jsonl. + * + * The swap policy (budget, savings gate, skip rules) lives in + * `planInlineSwaps`, shared by the transform and the `/context` savings + * estimate (`estimateInlineSavings`) so the two can never disagree. */ import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai"; import { countTokens } from "@oh-my-pi/pi-natives"; @@ -65,6 +69,262 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens: return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN; } +// ============================================================================ +// Swap planning (shared by the live transform and /context estimation) +// ============================================================================ + +/** Tool-result swap candidate, in context order. */ +export interface InlineToolResultCandidate { + /** toolCallId — stable identity for render caching and application. */ + id: string; + /** Token count of the joined text blocks (0 when empty or image-carrying). */ + textTokens: number; + /** Frames needed to render the text (0 = empty or below the token floor). */ + frames: number; + /** Already carries an image (screenshot etc.) — never re-imaged. */ + hasImage: boolean; +} + +export interface InlineSystemPromptCandidate { + textTokens: number; + frames: number; +} + +export interface InlinePlanInput { + options: SnapcompactInlineOptions; + shape: snapcompact.Shape; + /** Provider image-count budget minus images already present in the context. */ + budget: number; + /** All tool results in context order, INCLUDING the most recent one. */ + toolResults: readonly InlineToolResultCandidate[]; + /** Joined system prompt; undefined when absent or system-prompt imaging is off. */ + systemPrompt: InlineSystemPromptCandidate | undefined; + /** Whether a user message exists to carry the system-prompt frames. */ + hasUserMessage: boolean; +} + +export interface InlineSwapPlan { + /** Tool results to swap, oldest first. */ + toolResults: Array<{ id: string; textTokens: number; frames: number }>; + /** Set when the system prompt should swap to frames (uses leftover budget). */ + systemPrompt: InlineSystemPromptCandidate | undefined; +} + +/** + * Decide which content gets swapped for frames. Pure — the same rules drive + * the provider-request transform and the /context savings estimate. + */ +export function planInlineSwaps(input: InlinePlanInput): InlineSwapPlan { + let budget = input.budget; + + const toolResults: InlineSwapPlan["toolResults"] = []; + if (input.options.renderToolResults) { + // Oldest-first for cache-stable bytes; skip the LAST tool result so the + // freshest output stays crisp text. A candidate too big for the + // remaining budget is skipped, not a stop — later smaller ones may fit. + for (let k = 0; k < input.toolResults.length - 1 && budget > 0; k++) { + const candidate = input.toolResults[k]; + if (candidate.hasImage) continue; + if (candidate.textTokens < MIN_TOOL_RESULT_TOKENS) continue; + if (candidate.frames === 0 || candidate.frames > budget) continue; + if (!passesSavingsGate(candidate.frames, input.shape, candidate.textTokens)) continue; + toolResults.push({ id: candidate.id, textTokens: candidate.textTokens, frames: candidate.frames }); + budget -= candidate.frames; + } + } + + let systemPrompt: InlineSystemPromptCandidate | undefined; + if ( + input.options.renderSystemPrompt && + input.systemPrompt && + budget > 0 && + input.systemPrompt.frames > 0 && + input.systemPrompt.frames <= Math.min(budget, MAX_SYSTEM_PROMPT_FRAMES) && + passesSavingsGate(input.systemPrompt.frames, input.shape, input.systemPrompt.textTokens) && + // No user message to carry the frames → leave the prompt as text. + input.hasUserMessage + ) { + systemPrompt = input.systemPrompt; + } + + return { toolResults, systemPrompt }; +} + +// ============================================================================ +// /context savings estimation +// ============================================================================ + +/** + * Minimal structural view of a history message — both pi-ai `Message`s (the + * outgoing context) and agent-core `AgentMessage`s (the live session) satisfy + * it, so the estimator can read session state without conversion. + */ +export interface InlineMessageView { + role: string; + toolCallId?: string; + content?: unknown; +} + +export interface SnapcompactSavingsEstimate { + /** Frames only ship on models that accept image input. */ + visionCapable: boolean; + /** Present iff system-prompt imaging is enabled. */ + systemPrompt?: { + applied: boolean; + /** Why the prompt stays text when `applied` is false. */ + reason?: "empty" | "margin" | "budget"; + textTokens: number; + frames: number; + /** Estimated billed tokens for the frames (0 when there are none). */ + imageTokens: number; + savedTokens: number; + }; + /** Present iff tool-result imaging is enabled. */ + toolResults?: { + /** Tool results currently in history. */ + total: number; + swapped: number; + /** Text tokens of the swapped results only. */ + textTokens: number; + frames: number; + imageTokens: number; + savedTokens: number; + }; + /** Net estimated wire savings for the next request. */ + savedTokens: number; +} + +/** Loose block-array view of unknown message content. */ +type BlockViews = ReadonlyArray<{ type?: unknown; text?: unknown }>; + +/** + * Estimate what `SnapcompactInlineTransformer.transform` would save on the + * NEXT request, given the session's live system prompt and message history. + * + * Mirrors the transform exactly via `planInlineSwaps`, with one deliberate + * difference: `hasUserMessage` is assumed true, because the request being + * estimated is always triggered by a user prompt — even when the current + * history is still empty. + */ +export function estimateInlineSavings(input: { + options: SnapcompactInlineOptions; + model: Model | undefined; + systemPrompt: readonly string[]; + messages: readonly InlineMessageView[]; +}): SnapcompactSavingsEstimate { + const { options, model } = input; + if (!model?.input.includes("image")) { + return { visionCapable: false, savedTokens: 0 }; + } + + const shape = snapcompact.resolveShape(model.api); + let existingImages = 0; + for (const message of input.messages) { + if (!Array.isArray(message.content)) continue; + for (const block of message.content as BlockViews) { + if (block.type === "image") existingImages++; + } + } + const budget = (INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - existingImages; + + const candidates: InlineToolResultCandidate[] = []; + if (options.renderToolResults) { + for (const message of input.messages) { + if (message.role !== "toolResult" || typeof message.toolCallId !== "string") continue; + const blocks: BlockViews = Array.isArray(message.content) ? (message.content as BlockViews) : []; + const hasImage = blocks.some(block => block.type === "image"); + const text = hasImage + ? "" + : blocks + .filter(block => block.type === "text" && typeof block.text === "string") + .map(block => block.text as string) + .join("\n"); + const textTokens = text.length > 0 ? countTokens(text) : 0; + candidates.push({ + id: message.toolCallId, + textTokens, + frames: textTokens >= MIN_TOOL_RESULT_TOKENS ? snapcompact.frames(text, { shape }) : 0, + hasImage, + }); + } + } + + let systemPromptCandidate: InlineSystemPromptCandidate | undefined; + if (options.renderSystemPrompt && input.systemPrompt.length > 0) { + const joined = input.systemPrompt.join("\n\n"); + systemPromptCandidate = { + textTokens: countTokens(joined), + frames: snapcompact.frames(joined, { shape }), + }; + } + + const plan = planInlineSwaps({ + options, + shape, + budget, + toolResults: candidates, + systemPrompt: systemPromptCandidate, + hasUserMessage: true, + }); + + let savedTokens = 0; + let systemPromptEstimate: SnapcompactSavingsEstimate["systemPrompt"]; + if (options.renderSystemPrompt) { + const candidate = systemPromptCandidate ?? { textTokens: 0, frames: 0 }; + const applied = plan.systemPrompt !== undefined; + const imageTokens = candidate.frames * shape.frameTokenEstimate; + const saved = applied ? Math.max(0, candidate.textTokens - imageTokens) : 0; + let reason: "empty" | "margin" | "budget" | undefined; + if (!applied) { + const leftover = budget - plan.toolResults.reduce((sum, swap) => sum + swap.frames, 0); + if (candidate.frames === 0) reason = "empty"; + else if (candidate.frames > Math.min(leftover, MAX_SYSTEM_PROMPT_FRAMES)) reason = "budget"; + else reason = "margin"; + } + systemPromptEstimate = { + applied, + ...(reason ? { reason } : {}), + textTokens: candidate.textTokens, + frames: candidate.frames, + imageTokens, + savedTokens: saved, + }; + savedTokens += saved; + } + + let toolResultsEstimate: SnapcompactSavingsEstimate["toolResults"]; + if (options.renderToolResults) { + let textTokens = 0; + let frames = 0; + for (const swap of plan.toolResults) { + textTokens += swap.textTokens; + frames += swap.frames; + } + const imageTokens = frames * shape.frameTokenEstimate; + const saved = Math.max(0, textTokens - imageTokens); + toolResultsEstimate = { + total: candidates.length, + swapped: plan.toolResults.length, + textTokens, + frames, + imageTokens, + savedTokens: saved, + }; + savedTokens += saved; + } + + return { + visionCapable: true, + ...(systemPromptEstimate ? { systemPrompt: systemPromptEstimate } : {}), + ...(toolResultsEstimate ? { toolResults: toolResultsEstimate } : {}), + savedTokens, + }; +} + +// ============================================================================ +// Provider-request transform +// ============================================================================ + interface FrameCacheEntry { hash: number | bigint; frames: ImageContent[]; @@ -88,43 +348,70 @@ export class SnapcompactInlineTransformer { if (!model.input.includes("image")) return context; const shape = snapcompact.resolveShape(model.api); - let budget = + const budget = (INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - countContextImages(context); if (budget <= 0) return context; const messages = [...context.messages]; - let changed = false; + // Collect tool-result candidates (in order) for the planner, plus the + // text/index needed to apply swaps and the live ids for cache eviction. + const candidates: InlineToolResultCandidate[] = []; + const targets = new Map(); + const liveToolCallIds = new Set(); if (this.options.renderToolResults) { - const toolResultIndices: number[] = []; - const liveToolCallIds = new Set(); for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (message.role !== "toolResult") continue; - toolResultIndices.push(i); liveToolCallIds.add(message.toolCallId); - } - // Oldest-first for cache-stable bytes; skip the LAST tool result so - // the freshest output stays crisp text. - for (let k = 0; k < toolResultIndices.length - 1 && budget > 0; k++) { - const index = toolResultIndices[k]; - const message = messages[index] as ToolResultMessage; // Don't re-image results that already carry images (screenshots etc.). - if (message.content.some(block => block.type === "image")) continue; - const text = message.content - .filter(isTextContent) - .map(block => block.text) - .join("\n"); - const textTokens = countTokens(text); - if (textTokens < MIN_TOOL_RESULT_TOKENS) continue; - const needed = snapcompact.frames(text, { shape }); - if (needed === 0 || needed > budget) continue; - if (!passesSavingsGate(needed, shape, textTokens)) continue; - const frames = this.#framesFor(this.#toolCache, message.toolCallId, text, shape); - messages[index] = { ...message, content: [{ type: "text", text: toolResultNote }, ...frames] }; - budget -= frames.length; - changed = true; + const hasImage = message.content.some(block => block.type === "image"); + const text = hasImage + ? "" + : message.content + .filter(isTextContent) + .map(block => block.text) + .join("\n"); + const textTokens = text.length > 0 ? countTokens(text) : 0; + candidates.push({ + id: message.toolCallId, + textTokens, + frames: textTokens >= MIN_TOOL_RESULT_TOKENS ? snapcompact.frames(text, { shape }) : 0, + hasImage, + }); + targets.set(message.toolCallId, { index: i, message, text }); } + } + + let systemPromptCandidate: InlineSystemPromptCandidate | undefined; + let joinedSystemPrompt = ""; + if (this.options.renderSystemPrompt && context.systemPrompt?.length) { + joinedSystemPrompt = context.systemPrompt.join("\n\n"); + systemPromptCandidate = { + textTokens: countTokens(joinedSystemPrompt), + frames: snapcompact.frames(joinedSystemPrompt, { shape }), + }; + } + + const userIndex = messages.findIndex(message => message.role === "user"); + const plan = planInlineSwaps({ + options: this.options, + shape, + budget, + toolResults: candidates, + systemPrompt: systemPromptCandidate, + hasUserMessage: userIndex >= 0, + }); + + let changed = false; + for (const swap of plan.toolResults) { + const target = targets.get(swap.id); + if (!target) continue; + const frames = this.#framesFor(this.#toolCache, swap.id, target.text, shape); + messages[target.index] = { ...target.message, content: [{ type: "text", text: toolResultNote }, ...frames] }; + changed = true; + } + if (this.options.renderToolResults) { // Drop cache entries for tool calls no longer in the context // (compacted away) so the cache stays bounded by live history. for (const key of this.#toolCache.keys()) { @@ -133,38 +420,26 @@ export class SnapcompactInlineTransformer { } let systemPrompt = context.systemPrompt; - if (this.options.renderSystemPrompt && context.systemPrompt?.length && budget > 0) { - const joined = context.systemPrompt.join("\n\n"); - const needed = snapcompact.frames(joined, { shape }); - const userIndex = messages.findIndex(message => message.role === "user"); - if ( - needed > 0 && - needed <= Math.min(budget, MAX_SYSTEM_PROMPT_FRAMES) && - passesSavingsGate(needed, shape, countTokens(joined)) && - // No user message to carry the frames → leave the prompt as text. - userIndex >= 0 - ) { - const hash = Bun.hash(joined); - let cached = this.#systemCache; - if (!cached || cached.hash !== hash) { - cached = { - hash, - frames: snapcompact.renderMany(joined, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }), - }; - this.#systemCache = cached; - } - const frames = cached.frames; - const original = messages[userIndex] as UserMessage; - const originalContent: (TextContent | ImageContent)[] = - typeof original.content === "string" ? [{ type: "text", text: original.content }] : original.content; - messages[userIndex] = { - ...original, - content: [{ type: "text", text: systemFramesNote }, ...frames, ...originalContent], + if (plan.systemPrompt && userIndex >= 0) { + const hash = Bun.hash(joinedSystemPrompt); + let cached = this.#systemCache; + if (!cached || cached.hash !== hash) { + cached = { + hash, + frames: snapcompact.renderMany(joinedSystemPrompt, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }), }; - systemPrompt = [systemStub]; - budget -= frames.length; - changed = true; + this.#systemCache = cached; } + const frames = cached.frames; + const original = messages[userIndex] as UserMessage; + const originalContent: (TextContent | ImageContent)[] = + typeof original.content === "string" ? [{ type: "text", text: original.content }] : original.content; + messages[userIndex] = { + ...original, + content: [{ type: "text", text: systemFramesNote }, ...frames, ...originalContent], + }; + systemPrompt = [systemStub]; + changed = true; } if (!changed) return context; diff --git a/packages/coding-agent/src/slash-commands/helpers/context-report.ts b/packages/coding-agent/src/slash-commands/helpers/context-report.ts index 0b4708b0b..3d139f8c4 100644 --- a/packages/coding-agent/src/slash-commands/helpers/context-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/context-report.ts @@ -9,7 +9,7 @@ import { renderAsciiBar } from "./format"; */ export function buildContextReportText(runtime: SlashCommandRuntime): string { try { - const breakdown = computeContextBreakdown(runtime.session); + const breakdown = computeContextBreakdown(runtime.session, { snapcompactSavings: true }); if (breakdown.contextWindow <= 0) { return "Context usage is unavailable: no model is selected for this session."; } @@ -30,6 +30,33 @@ export function buildContextReportText(runtime: SlashCommandRuntime): string { const fraction = breakdown.freeTokens / breakdown.contextWindow; lines.push(` ${"Free".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.freeTokens} tokens`); } + const snap = breakdown.snapcompact; + if (snap) { + if (!snap.visionCapable) { + lines.push("Snapcompact: inactive (model has no image input)"); + } else { + lines.push("Snapcompact (estimated wire savings):"); + if (snap.systemPrompt) { + const sp = snap.systemPrompt; + lines.push( + sp.applied + ? ` System prompt: ${sp.textTokens} text tokens → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${sp.imageTokens} tokens (saves ~${sp.savedTokens})` + : " System prompt: stays text (no net savings)", + ); + } + if (snap.toolResults) { + const tr = snap.toolResults; + lines.push( + tr.swapped > 0 + ? ` Tool results: ${tr.swapped} of ${tr.total} imaged, ${tr.textTokens} text tokens → ${tr.frames} frames ≈ ${tr.imageTokens} tokens (saves ~${tr.savedTokens})` + : ` Tool results: none imaged (${tr.total} in history)`, + ); + } + if (snap.savedTokens > 0) { + lines.push(` Estimated next request: ~${breakdown.usedTokens - snap.savedTokens} tokens on the wire`); + } + } + } return lines.join("\n"); } catch { const fallback = runtime.session.getContextUsage(); diff --git a/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts b/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts index e5726be7d..e135b3e6d 100644 --- a/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts +++ b/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts @@ -128,7 +128,11 @@ describe("SettingsSelectorComponent memory tab", () => { comp.handleInput("b"); const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, ""); const searching = comp.render(120).map(strip).join("\n"); - const banner = comp.render(120).map(strip).find(line => /\d+ match/.test(line)) ?? ""; + const banner = + comp + .render(120) + .map(strip) + .find(line => /\d+ match/.test(line)) ?? ""; expect(banner).toContain(" b "); expect(searching).toMatch(/\d+ match/); @@ -162,7 +166,11 @@ describe("SettingsSelectorComponent memory tab", () => { it("supports editor hotkeys in the global search bar", () => { const comp = createSelector(); const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, ""); - const banner = (): string => comp.render(120).map(strip).find(line => /\d+ match/.test(line)) ?? ""; + const banner = (): string => + comp + .render(120) + .map(strip) + .find(line => /\d+ match/.test(line)) ?? ""; // alt+backspace deletes the trailing word from the query. for (const ch of "image provider") comp.handleInput(ch); diff --git a/packages/coding-agent/test/modes/context-usage.test.ts b/packages/coding-agent/test/modes/context-usage.test.ts index d785d55cd..6de2bcb64 100644 --- a/packages/coding-agent/test/modes/context-usage.test.ts +++ b/packages/coding-agent/test/modes/context-usage.test.ts @@ -7,7 +7,11 @@ */ import { describe, expect, it } from "bun:test"; import { zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; -import { estimateToolSchemaTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; +import { + type ContextBreakdown, + estimateToolSchemaTokens, + renderContextUsage, +} from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; import { z } from "zod/v4"; describe("estimateToolSchemaTokens", () => { @@ -25,3 +29,53 @@ describe("estimateToolSchemaTokens", () => { expect(zodEstimate).toBe(wireEstimate); }); }); + +/** + * Contract: the /context panel surfaces estimated snapcompact wire savings — + * applied swaps show "saves" figures, inactive states say why. + */ +describe("renderContextUsage snapcompact section", () => { + const themeStub = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as never; + + function breakdownWith(snapcompact: ContextBreakdown["snapcompact"]): ContextBreakdown { + return { + model: { id: "test-model", name: "Test Model", contextWindow: 200000 } as never, + contextWindow: 200000, + categories: [], + usedTokens: 27929, + autoCompactBufferTokens: 0, + freeTokens: 172071, + snapcompact, + }; + } + + it("renders savings, skip reasons, and the wire total", () => { + const output = renderContextUsage( + breakdownWith({ + visionCapable: true, + systemPrompt: { applied: true, textTokens: 9768, frames: 2, imageTokens: 6600, savedTokens: 3168 }, + toolResults: { total: 3, swapped: 0, textTokens: 0, frames: 0, imageTokens: 0, savedTokens: 0 }, + savedTokens: 3168, + }), + themeStub, + ); + expect(output).toContain("Snapcompact (estimated wire savings)"); + expect(output).toContain("System prompt: saves ~3.2K (9.8K text → 2 frames ≈ 6.6K)"); + expect(output).toContain("Tool results: none imaged (3 in history)"); + // 27929 logical − 3168 saved ≈ 25K on the wire. + expect(output).toContain("Next request: ~25K tokens on the wire"); + }); + + it("reports text-only models as inactive", () => { + const output = renderContextUsage(breakdownWith({ visionCapable: false, savedTokens: 0 }), themeStub); + expect(output).toContain("Snapcompact: inactive (model has no image input)"); + }); + + it("omits the section entirely when no snapcompact setting is on", () => { + const output = renderContextUsage(breakdownWith(undefined), themeStub); + expect(output).not.toContain("Snapcompact"); + }); +}); diff --git a/packages/coding-agent/test/snapcompact-inline.test.ts b/packages/coding-agent/test/snapcompact-inline.test.ts index 8ab259345..7a5eabd39 100644 --- a/packages/coding-agent/test/snapcompact-inline.test.ts +++ b/packages/coding-agent/test/snapcompact-inline.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, spyOn } from "bun:test"; import type { Context, ImageContent, Message, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { SnapcompactInlineTransformer } from "@oh-my-pi/pi-coding-agent/session/snapcompact-inline"; +import { + estimateInlineSavings, + planInlineSwaps, + SnapcompactInlineTransformer, +} from "@oh-my-pi/pi-coding-agent/session/snapcompact-inline"; import * as snapcompact from "@oh-my-pi/snapcompact"; /** @@ -225,3 +229,178 @@ describe("SnapcompactInlineTransformer", () => { } }); }); + +describe("planInlineSwaps", () => { + const shape = snapcompact.resolveShape("anthropic-messages"); + const toolOnly = { renderSystemPrompt: false, renderToolResults: true }; + const promptOnly = { renderSystemPrompt: true, renderToolResults: false }; + + it("never swaps the most recent tool result", () => { + const plan = planInlineSwaps({ + options: toolOnly, + shape, + budget: 90, + toolResults: [ + { id: "a", textTokens: 10000, frames: 2, hasImage: false }, + { id: "z", textTokens: 10000, frames: 2, hasImage: false }, + ], + systemPrompt: undefined, + hasUserMessage: true, + }); + expect(plan.toolResults.map(swap => swap.id)).toEqual(["a"]); + }); + + it("skips image-carrying, below-floor, and below-margin candidates", () => { + const plan = planInlineSwaps({ + options: toolOnly, + shape, + budget: 90, + toolResults: [ + { id: "img", textTokens: 0, frames: 0, hasImage: true }, + { id: "small", textTokens: 2999, frames: 1, hasImage: false }, + // 2 frames ≈ 6600 image tokens > 7000 * 0.9 — margin gate rejects. + { id: "margin", textTokens: 7000, frames: 2, hasImage: false }, + { id: "ok", textTokens: 10000, frames: 2, hasImage: false }, + { id: "last", textTokens: 10000, frames: 2, hasImage: false }, + ], + systemPrompt: undefined, + hasUserMessage: true, + }); + expect(plan.toolResults.map(swap => swap.id)).toEqual(["ok"]); + }); + + it("skips candidates over the remaining budget but keeps trying smaller ones", () => { + const plan = planInlineSwaps({ + options: toolOnly, + shape, + budget: 3, + toolResults: [ + { id: "a", textTokens: 10000, frames: 2, hasImage: false }, + { id: "b", textTokens: 10000, frames: 2, hasImage: false }, + { id: "c", textTokens: 5000, frames: 1, hasImage: false }, + { id: "last", textTokens: 10000, frames: 2, hasImage: false }, + ], + systemPrompt: undefined, + hasUserMessage: true, + }); + expect(plan.toolResults.map(swap => swap.id)).toEqual(["a", "c"]); + }); + + it("gives the system prompt only the budget tool results left over", () => { + const input = { + options: { renderSystemPrompt: true, renderToolResults: true }, + shape, + budget: 2, + toolResults: [ + { id: "a", textTokens: 10000, frames: 2, hasImage: false }, + { id: "last", textTokens: 10000, frames: 2, hasImage: false }, + ], + systemPrompt: { textTokens: 10000, frames: 2 }, + hasUserMessage: true, + }; + const contested = planInlineSwaps(input); + expect(contested.toolResults.map(swap => swap.id)).toEqual(["a"]); + expect(contested.systemPrompt).toBeUndefined(); + + const uncontested = planInlineSwaps({ ...input, options: promptOnly }); + expect(uncontested.toolResults).toEqual([]); + expect(uncontested.systemPrompt).toEqual({ textTokens: 10000, frames: 2 }); + }); + + it("gates the system prompt on frame cap, savings margin, and a carrier user message", () => { + const base = { + options: promptOnly, + shape, + budget: 90, + toolResults: [], + hasUserMessage: true, + }; + // 7 frames exceeds the 6-frame system prompt cap. + expect( + planInlineSwaps({ ...base, systemPrompt: { textTokens: 100000, frames: 7 } }).systemPrompt, + ).toBeUndefined(); + // 6 frames ≈ 19800 ≤ 30000 * 0.9 — fits. + expect(planInlineSwaps({ ...base, systemPrompt: { textTokens: 30000, frames: 6 } }).systemPrompt).toBeDefined(); + // 2 frames ≈ 6600 > 7000 * 0.9 — margin gate rejects. + expect(planInlineSwaps({ ...base, systemPrompt: { textTokens: 7000, frames: 2 } }).systemPrompt).toBeUndefined(); + // No user message to carry the frames. + expect( + planInlineSwaps({ ...base, hasUserMessage: false, systemPrompt: { textTokens: 30000, frames: 6 } }) + .systemPrompt, + ).toBeUndefined(); + }); +}); + +describe("estimateInlineSavings", () => { + it("reports vision-incapable models as inactive with zero savings", () => { + const estimate = estimateInlineSavings({ + options: { renderSystemPrompt: true, renderToolResults: true }, + model: makeModel({ input: ["text"] }), + systemPrompt: [LARGE], + messages: [], + }); + expect(estimate.visionCapable).toBe(false); + expect(estimate.savedTokens).toBe(0); + expect(estimate.systemPrompt).toBeUndefined(); + expect(estimate.toolResults).toBeUndefined(); + }); + + it("assumes the next request carries a user message even with empty history", () => { + const estimate = estimateInlineSavings({ + options: { renderSystemPrompt: true, renderToolResults: false }, + model: makeModel(), + systemPrompt: [LARGE], + messages: [], + }); + expect(estimate.visionCapable).toBe(true); + expect(estimate.systemPrompt?.applied).toBe(true); + expect(estimate.systemPrompt?.frames).toBe(2); + expect(estimate.systemPrompt?.imageTokens).toBe(2 * 3300); + expect(estimate.systemPrompt?.savedTokens).toBe( + estimate.systemPrompt!.textTokens - estimate.systemPrompt!.imageTokens, + ); + expect(estimate.savedTokens).toBe(estimate.systemPrompt!.savedTokens); + expect(estimate.savedTokens).toBeGreaterThan(0); + expect(estimate.toolResults).toBeUndefined(); + }); + + it("explains why a small system prompt stays text", () => { + const estimate = estimateInlineSavings({ + options: { renderSystemPrompt: true, renderToolResults: false }, + model: makeModel(), + systemPrompt: ["Be terse."], + messages: [], + }); + expect(estimate.systemPrompt?.applied).toBe(false); + expect(estimate.systemPrompt?.reason).toBe("margin"); + expect(estimate.savedTokens).toBe(0); + }); + + it("matches what the transform actually swaps on the same context", () => { + const options = { renderSystemPrompt: true, renderToolResults: true }; + const context = makeContext(); + const model = makeModel(); + + const estimate = estimateInlineSavings({ + options, + model, + systemPrompt: context.systemPrompt!, + messages: context.messages, + }); + const result = new SnapcompactInlineTransformer(options).transform(context, model); + + let imaged = 0; + for (const message of result.messages) { + if (message.role !== "toolResult") continue; + if (message.content.some(block => block.type === "image")) imaged++; + } + expect(estimate.toolResults?.total).toBe(3); + expect(estimate.toolResults?.swapped).toBe(imaged); + expect(estimate.toolResults!.savedTokens).toBe( + estimate.toolResults!.textTokens - estimate.toolResults!.imageTokens, + ); + // The tiny two-part system prompt stays text in both paths. + expect(estimate.systemPrompt?.applied).toBe(false); + expect(result.systemPrompt).toBe(context.systemPrompt); + }); +});