Files
oh-my-pi/packages/coding-agent/test/snapcompact-inline.test.ts
T
can1357 a82d68ef49 feat(coding-agent): added experimental snapcompact inline imaging for system prompt and tool results
- Added `renderSnapcompactFrames()` and `snapcompactFrameCount()` to @oh-my-pi/snapcompact for paging arbitrary text into PNG image blocks without dim-marker bookkeeping.
- Widened the agent loop's `transformProviderContext` hook to `(context, model) => Context` so per-request transforms can gate on the dispatch model's capabilities.
- Added `SnapcompactInlineTransformer` rendering the system prompt and large historical tool results as snapcompact frames on vision models: vision gate, per-provider image budgets, 3k-token floor, savings-margin gate, skip-last rule, and hash-keyed render caches swept to live tool calls.
- Added default-off `snapcompact.systemPrompt` and `snapcompact.toolResults` settings under a new Context → Experimental group, composed after secret obfuscation in `sdk.ts` so frames are built per-request and never persisted to session.jsonl.
- Added prompt stubs (`snapcompact-system-stub.md`, `snapcompact-system-frames-note.md`, `snapcompact-toolresult-note.md`) and unit tests covering frame paging, no-mutate guarantees, budget caps, gates, and render caching.
2026-06-12 03:27:50 +02:00

228 lines
9.0 KiB
TypeScript

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 * as snapcompact from "@oh-my-pi/snapcompact";
/**
* Token-dense deterministic word salad. 3000 words ≈ 20.6k normalized chars
* → 2 anthropic-shape frames (capacity 19208) whose ~6600 estimated image
* tokens clear the savings gate against ~8900 text tokens.
*/
function denseText(words: number): string {
return Array.from({ length: words }, (_, i) => `w${(i * 7919) % 100000}`).join(" ");
}
const LARGE = denseText(3000);
const SMALL = "12 lines OK";
function toolResult(id: string, text: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId: id,
toolName: "read",
content: [{ type: "text", text }],
isError: false,
timestamp: 0,
};
}
function userMessage(text: string): Message {
return { role: "user", content: text, timestamp: 0 };
}
function makeModel(
overrides: {
provider?: string;
input?: ("text" | "image")[];
api?: "anthropic-messages" | "google-generative-ai";
} = {},
) {
return buildModel({
id: "test-model",
name: "Test Model",
api: overrides.api ?? "anthropic-messages",
provider: overrides.provider ?? "anthropic",
baseUrl: "https://example.invalid",
reasoning: false,
input: overrides.input ?? ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
});
}
function makeContext(): Context {
return {
systemPrompt: ["You are a coding agent.", "Follow the rules."],
messages: [
userMessage("first user prompt"),
toolResult("call_1", LARGE),
toolResult("call_2", SMALL),
toolResult("call_3", LARGE),
],
};
}
function imageCount(context: Context): number {
let count = 0;
for (const message of context.messages) {
if (typeof message.content === "string") continue;
for (const block of message.content) if (block.type === "image") count++;
}
return count;
}
describe("SnapcompactInlineTransformer", () => {
it("is a no-op for text-only models", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: true, renderToolResults: true });
const context = makeContext();
expect(transformer.transform(context, makeModel({ input: ["text"] }))).toBe(context);
});
it("images large historical tool results, keeping small and most-recent ones as text", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: false, renderToolResults: true });
const context = makeContext();
const result = transformer.transform(context, makeModel());
// Large historical result → leading text note + image frames.
const imaged = result.messages[1] as ToolResultMessage;
expect(imaged.content[0].type).toBe("text");
expect(imaged.content.length).toBeGreaterThan(1);
expect(imaged.content.slice(1).every(block => block.type === "image")).toBe(true);
for (const block of imaged.content.slice(1) as ImageContent[]) {
expect(block.mimeType).toBe("image/png");
expect(block.data.length).toBeGreaterThan(0);
}
// Small result fails the savings gate; the most-recent stays crisp text.
expect(result.messages[2]).toBe(context.messages[2]);
expect(result.messages[3]).toBe(context.messages[3]);
expect((result.messages[3] as ToolResultMessage).content[0]).toEqual({ type: "text", text: LARGE });
// System prompt untouched when only tool results are enabled.
expect(result.systemPrompt).toBe(context.systemPrompt);
});
it("never mutates the input context (persisted history shares these references)", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: true, renderToolResults: true });
const context = makeContext();
const originalMessages = context.messages;
const originalSystemPrompt = context.systemPrompt;
const original = context.messages[1] as ToolResultMessage;
const originalContent = original.content;
const result = transformer.transform(context, makeModel());
expect(result).not.toBe(context);
expect(context.messages).toBe(originalMessages);
expect(context.systemPrompt).toBe(originalSystemPrompt);
expect(context.systemPrompt).toEqual(["You are a coding agent.", "Follow the rules."]);
expect(original.content).toBe(originalContent);
expect(originalContent).toEqual([{ type: "text", text: LARGE }]);
expect((context.messages[0] as { content: string }).content).toBe("first user prompt");
});
it("leaves tool results that already carry images untouched", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: false, renderToolResults: true });
const withImage: ToolResultMessage = {
...toolResult("call_img", LARGE),
content: [
{ type: "text", text: LARGE },
{ type: "image", data: "aGk=", mimeType: "image/png" },
],
};
const context: Context = {
messages: [userMessage("hi"), withImage, toolResult("call_tail", LARGE)],
};
const result = transformer.transform(context, makeModel());
expect(result.messages[1]).toBe(withImage);
});
it("replaces a large system prompt with a stub and rides frames on the first user message", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: true, renderToolResults: false });
const longPrompt = denseText(3000);
const context: Context = {
systemPrompt: [longPrompt],
messages: [userMessage("do the thing"), toolResult("call_1", SMALL)],
};
const result = transformer.transform(context, makeModel());
expect(result.systemPrompt).toHaveLength(1);
expect(result.systemPrompt![0]).not.toBe(longPrompt);
expect(result.systemPrompt![0].length).toBeLessThan(500);
const carrier = result.messages[0] as { content: (TextContent | ImageContent)[] };
expect(carrier.content[0].type).toBe("text");
const images = carrier.content.filter(block => block.type === "image");
expect(images.length).toBeGreaterThan(0);
// Original user text survives at the tail.
expect(carrier.content[carrier.content.length - 1]).toEqual({ type: "text", text: "do the thing" });
});
it("keeps a small system prompt as text and skips when no user message exists", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: true, renderToolResults: false });
const small: Context = { systemPrompt: ["Be terse."], messages: [userMessage("hi")] };
expect(transformer.transform(small, makeModel())).toBe(small);
const noUser: Context = { systemPrompt: [denseText(3000)], messages: [toolResult("call_1", SMALL)] };
expect(transformer.transform(noUser, makeModel())).toBe(noUser);
});
it("never rasterizes tool results under the 3k-token floor, even when frames are cheaper", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: false, renderToolResults: true });
// ~1.5k tokens: the google shape estimates 1 frame ≈ 1100 tokens, so the
// savings gate alone would rasterize this — the floor must keep it text.
const midsize = denseText(500);
const context: Context = {
messages: [userMessage("go"), toolResult("call_1", midsize), toolResult("call_2", LARGE)],
};
const result = transformer.transform(context, makeModel({ api: "google-generative-ai", provider: "google" }));
expect(result).toBe(context);
});
it("respects the per-provider image budget for unknown providers", () => {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: false, renderToolResults: true });
const context: Context = {
messages: [
userMessage("go"),
toolResult("call_1", LARGE),
toolResult("call_2", LARGE),
toolResult("call_3", LARGE),
toolResult("call_4", LARGE),
],
};
// Unknown provider → default budget 5. Each LARGE needs 2 frames:
// call_1 (2) + call_2 (2) fit, call_3 needs 2 > 1 remaining → text.
const result = transformer.transform(context, makeModel({ provider: "groq" }));
expect(imageCount(result)).toBeLessThanOrEqual(5);
expect(result.messages[3]).toBe(context.messages[3]);
expect(result.messages[4]).toBe(context.messages[4]);
});
it("caches renders across turns: identical input does not re-rasterize", () => {
const spy = spyOn(snapcompact, "renderSnapcompactFrames");
try {
const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: true, renderToolResults: true });
const context = makeContext();
const model = makeModel();
const first = transformer.transform(context, model);
const callsAfterFirst = spy.mock.calls.length;
expect(callsAfterFirst).toBeGreaterThan(0);
const second = transformer.transform(context, model);
expect(spy.mock.calls.length).toBe(callsAfterFirst);
const firstFrames = (first.messages[1] as ToolResultMessage).content.slice(1);
const secondFrames = (second.messages[1] as ToolResultMessage).content.slice(1);
expect(secondFrames.length).toBe(firstFrames.length);
for (let i = 0; i < firstFrames.length; i++) {
expect(secondFrames[i]).toBe(firstFrames[i]);
}
} finally {
spy.mockRestore();
}
});
});