feat(coding-agent): added snapcompact savings estimation to context reporting

- Added optional snapcompact savings calculation in context breakdown with caller opt-in and setting gate.
- Added /context command integration to surface snapcompact savings and related skip reasons.
- Added context report sections for vision capability, tool-result imaging counts, and next-request token totals.
- Added shared inline-swap planning/savings estimation and transformer use for planned frame swaps.
This commit is contained in:
can1357
2026-06-12 04:59:05 +02:00
parent 4f0360e77a
commit 2630faca1f
8 changed files with 679 additions and 62 deletions
+2
View File
@@ -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 <path>` 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
@@ -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;
@@ -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;
}
@@ -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<string, { index: number; message: ToolResultMessage; text: string }>();
const liveToolCallIds = new Set<string>();
if (this.options.renderToolResults) {
const toolResultIndices: number[] = [];
const liveToolCallIds = new Set<string>();
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;
@@ -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();
@@ -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);
@@ -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");
});
});
@@ -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);
});
});