test(agent): added tests for compaction speculation and preview rendering

- Added tests for async compaction behavior, snapcompact leading, and discarded armed summaries.
- Added tests verifying status line context cache behavior, speculation ticks, and border accents.
- Updated composer shape preview, todo HUD, and yield tool validation tests to reflect current behavior.
- Added test coverage for session name preview title fallbacks and right alignment.
This commit is contained in:
can1357
2026-08-20 06:36:44 +02:00
parent 6226f7457c
commit 184058f11a
8 changed files with 182 additions and 46 deletions
@@ -6,8 +6,10 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { CompactionMethod } from "@oh-my-pi/pi-coding-agent/session/compaction-methods";
import { SessionMaintenance, type SessionMaintenanceHost } from "@oh-my-pi/pi-coding-agent/session/session-maintenance";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import * as snapcompactModule from "@oh-my-pi/snapcompact";
const CONTEXT_WINDOW = 100_000;
const THRESHOLD = 50_000;
@@ -53,18 +55,23 @@ describe("async speculative compaction", () => {
sessionManager.appendMessage(assistantMessage("final response", model));
}
function createMaintenance(asyncEnabled = true): SessionMaintenance {
let maintenanceSettings: Settings;
function createMaintenance(
options: { asyncEnabled?: boolean; methodOrder?: CompactionMethod[] } = {},
): SessionMaintenance {
const agent = new Agent({
initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
});
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.asyncEnabled": asyncEnabled,
"compaction.methodOrder": ["soft"],
"compaction.asyncEnabled": options.asyncEnabled ?? true,
"compaction.methodOrder": options.methodOrder ?? ["soft"],
"compaction.thresholdPercent": 50,
"compaction.keepRecentTokens": 1,
"compaction.autoContinue": false,
});
maintenanceSettings = settings;
const host = {
agent,
sessionManager,
@@ -218,7 +225,7 @@ describe("async speculative compaction", () => {
});
it("does not start speculative work when async compaction is disabled", () => {
maintenance = createMaintenance(false);
maintenance = createMaintenance({ asyncEnabled: false });
const compactSpy = vi.spyOn(compactionModule, "compact");
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
@@ -227,6 +234,45 @@ describe("async speculative compaction", () => {
expect(compactSpy).not.toHaveBeenCalled();
});
it("does not speculate when snapcompact leads the configured methods", () => {
// Snapcompact is local and effectively instant — there is no
// summarization latency to hide, so no background run may start.
const compactSpy = vi.spyOn(compactionModule, "compact");
maintenance = createMaintenance({ methodOrder: ["snapcompact", "soft"] });
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
expect(maintenance.speculationState).toBe("idle");
expect(compactSpy).not.toHaveBeenCalled();
});
it("discards an armed summary when the real pass resolves to snapcompact", async () => {
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "armed summary",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
const snapSpy = vi.spyOn(snapcompactModule, "compact").mockImplementation(async preparation => ({
summary: "snapcompact archive",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
}));
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
await waitForState("armed");
// Method order changed after arming: the real pass now runs the instant
// local method, and the stale LLM summary must not override it.
maintenanceSettings.override("compaction.methodOrder", ["snapcompact"]);
await maintenance.runAutoCompaction("threshold", false, false, false, { triggerContextTokens: THRESHOLD });
expect(snapSpy).toHaveBeenCalledTimes(1);
const entry = sessionManager.getEntries().findLast(item => item.type === "compaction");
expect(entry?.type === "compaction" ? entry.summary : undefined).toBe("snapcompact archive");
// Exactly the speculation's summarizer call — the pass never re-summarized.
expect(compactSpy).toHaveBeenCalledTimes(1);
});
it("clears an armed speculation when manual compaction starts", async () => {
vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "manual summary",
@@ -3856,6 +3856,7 @@ describe("ExtensionRunner", () => {
fileWriteFallbackHandlers: [],
fileDeleteFallbackHandlers: [],
messageRenderers: new Map(),
composerShapes: new Map(),
commands: new Map(),
flags: new Map(),
shortcuts: new Map(),
@@ -297,11 +297,10 @@ describe("InteractiveMode todo HUD anchor", () => {
// Lightened: no boxed top/bottom rules.
expect(lines.some(line => line === "─".repeat(80))).toBe(false);
// Root header carries the summed task progress bar (1 of 4 tasks closed).
// The title remains a compact anchor; overall progress colors the tree
// spine and tail, not the title text.
const root = lines.find(line => line.includes("TODO"));
expect(root).toBeDefined();
expect(root).toContain(theme.progress.filled);
expect(root).toContain(theme.progress.empty);
expect(root?.trim()).toBe("TODO");
// Active stage: highlighted header with its own task progress, expanded as a
// connector tree; the just-completed task stays as the lead row so progress
// is visible while the stage still has open work.
@@ -338,10 +337,10 @@ describe("InteractiveMode todo HUD anchor", () => {
.render(80)
.flatMap(line => line.split("\n"))
.map(line => Bun.stripANSI(line));
// One stage → the root still carries the summed bar and task counts.
// One stage still renders the compact title; progress belongs to the
// tree spine and tail.
const root = lines.find(line => line.includes("TODO"));
expect(root).toBeDefined();
expect(root).toContain(theme.progress.empty);
expect(root?.trim()).toBe("TODO");
// The stage keeps its task progress; no roman numeral for a lone stage.
expect(lines.some(line => line.includes("Tasks") && line.includes("0/2"))).toBe(true);
expect(lines.some(line => line.includes("I. Tasks"))).toBe(false);
@@ -369,10 +368,9 @@ describe("InteractiveMode todo HUD anchor", () => {
expect(lines.some(line => line.includes("V. Five"))).toBe(true);
expect(lines.some(line => line.includes("Six"))).toBe(false);
expect(lines.some(line => line.includes("2 more stages"))).toBe(true);
// Root header sums tasks across every stage, hidden ones included.
// Hidden stages do not change the compact title.
const root = lines.find(line => line.includes("TODO"));
expect(root).toBeDefined();
expect(root).toContain(theme.progress.empty);
expect(root?.trim()).toBe("TODO");
});
it("anchors the todo HUD as a native-scrollback live region while populated", () => {
@@ -64,45 +64,53 @@ describe("composer shape preview", () => {
it("borrows status rows from the live status source per shape layout", async () => {
await setTheme("dark");
const calls: string[] = [];
// Echo mocks: the stand-in title must be forwarded as a prop to every
// title-bearing status call, not glued onto the rendered content.
const status = {
getTopBorder: (width: number) => {
calls.push(`top:${width}`);
return { content: "TOPBAR", width: 6 };
getTopBorder: (_width: number, previewTitle?: string) => {
const content = `TOPBAR ${previewTitle ?? ""}`;
return { content, width: content.length };
},
getStandaloneTopBorder: (width: number) => {
calls.push(`chip:${width}`);
return { content: "CHIP", width: 4 };
},
renderBottomBar: (_width: number, groups: "left" | "full") => {
calls.push(`bottom:${groups}`);
return `BOTTOM-${groups.toUpperCase()}`;
getStandaloneTopBorder: (_width: number, previewTitle?: string) => {
const content = `CHIP ${previewTitle ?? ""}`;
return { content, width: content.length };
},
renderBottomBar: (_width: number, groups: "left" | "full", previewTitle?: string) =>
`BOTTOM-${groups.toUpperCase()} ${previewTitle ?? ""}`,
};
const box = renderComposerShapePreview("box", 80, status).join("\n");
expect(box).toContain("TOPBAR"); // embedded in the top border
expect(box).toContain("omp"); // stand-in title forwarded to the status source
expect(box).not.toContain("BOTTOM"); // box has no standalone bottom bar
const claude = renderComposerShapePreview("claude", 80, status).join("\n");
expect(claude).toContain("CHIP"); // right group chips onto the top rule
expect(claude).toContain("omp");
expect(claude).toContain("BOTTOM-LEFT"); // left group only on the bottom bar
const rule = renderComposerShapePreview("rule", 80, status).join("\n");
expect(rule).toContain("CHIP");
expect(rule).toContain("BOTTOM-LEFT");
const rule = renderComposerShapePreview("rule", 80, status);
expect(rule.join("\n")).toContain("CHIP");
expect(rule.join("\n")).toContain("omp");
expect(rule.join("\n")).toContain("BOTTOM-LEFT");
expect(rule[rule.length - 2]).toBe(""); // spacer row: rule has no bottom chrome
const pi = renderComposerShapePreview("pi", 80, status).join("\n");
expect(pi).not.toContain("CHIP");
expect(pi).toContain("BOTTOM-FULL"); // both groups on the bottom bar
const pi = renderComposerShapePreview("pi", 80, status);
expect(pi.join("\n")).not.toContain("CHIP");
expect(pi.join("\n")).toContain("omp");
expect(pi.join("\n")).toContain("BOTTOM-FULL"); // both groups on the bottom bar
expect(pi[pi.length - 2]).not.toBe(""); // bottom rule already separates the bar
const borderless = renderComposerShapePreview("borderless", 80, status).join("\n");
expect(borderless).toContain("omp");
expect(borderless).toContain("BOTTOM-FULL");
for (const shape of ["field", "rail"]) {
const rendered = renderComposerShapePreview(shape, 80, status).join("\n");
expect(rendered).not.toContain("CHIP");
expect(rendered).toContain("BOTTOM-FULL");
const rendered = renderComposerShapePreview(shape, 80, status);
expect(rendered.join("\n")).not.toContain("CHIP");
expect(rendered.join("\n")).toContain("omp");
expect(rendered.join("\n")).toContain("BOTTOM-FULL");
expect(rendered[rendered.length - 2]).toBe(""); // spacer row before the bar
}
});
@@ -509,6 +509,7 @@ describe("createAgentSession credential_disabled subscription", () => {
fileWriteFallbackHandlers: [],
fileDeleteFallbackHandlers: [],
messageRenderers: new Map(),
composerShapes: new Map(),
commands: new Map(),
flags: new Map(),
shortcuts: new Map(),
@@ -19,6 +19,8 @@ import type { ContextUsage } from "@oh-my-pi/pi-coding-agent/extensibility/exten
import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
import { initTheme, setSymbolPreset, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { getSessionAccentAnsi } from "@oh-my-pi/pi-coding-agent/utils/session-color";
import { adjustHsv } from "@oh-my-pi/pi-utils";
beforeAll(async () => {
resetSettingsForTest();
@@ -45,8 +47,13 @@ function makeSession(opts: {
contextWindow?: number;
usage?: ContextUsage | undefined;
settings?: AgentSession["settings"];
/** Model input modalities; gates snapcompact availability in boundary math. */
modelInput?: string[];
}): Fake {
const contextWindow = opts.contextWindow ?? 200_000;
const model = opts.modelInput
? { id: "test-model", contextWindow, input: opts.modelInput }
: { id: "test-model", contextWindow };
let usage: ContextUsage | undefined = "usage" in opts ? opts.usage : { tokens: 1234, contextWindow, percent: 0.6 };
let calls = 0;
let revision = 0;
@@ -55,9 +62,9 @@ function makeSession(opts: {
systemPrompt: ["You are a helpful assistant."],
agent: { state: { tools: [] } },
skills: [],
model: { id: "test-model", contextWindow },
model,
modelRegistry: { isUsingOAuth: () => false },
state: { messages: opts.messages, model: { contextWindow } },
state: { messages: opts.messages, model },
settings: opts.settings,
sessionManager: {
getUsageStatistics: () => ({
@@ -375,7 +382,8 @@ describe("StatusLineComponent context breakdown", () => {
await setSymbolPreset("nerd");
try {
const nerd = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
const border = comp.getTopBorder(80).content;
const nerd = border.replaceAll(/\x1b\[[0-9;]*m/g, "");
const speculationIndex = nerd.indexOf("󰕝");
const compactionIndex = nerd.indexOf("󰁨");
expect(speculationIndex).toBeGreaterThanOrEqual(0);
@@ -383,7 +391,9 @@ describe("StatusLineComponent context breakdown", () => {
expect(speculationIndex).toBeLessThan(compactionIndex);
expect(nerd).not.toContain("╎");
expect(nerd).not.toContain("┃");
const expectedDimmed = getSessionAccentAnsi(adjustHsv(theme.getColorHex("borderAccent"), { s: 0.7, v: 0.75 }));
expect(border).toContain(`${expectedDimmed}󰁨`);
expect(border).not.toContain(`${theme.getFgAnsi("warning")}󰁨`);
await setSymbolPreset("unicode");
const unicode = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
expect(unicode).toContain("╎");
@@ -395,6 +405,51 @@ describe("StatusLineComponent context breakdown", () => {
}
});
it("hides the speculation tick when the leading method is instant snapcompact", () => {
// A vision model with snapcompact first never speculates (local, instant),
// so the gauge shows only the auto-compaction boundary.
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
usage: { tokens: 50_000, contextWindow: 100_000, percent: 50 },
settings: Settings.isolated({ "compaction.methodOrder": ["snapcompact", "soft"] }),
modelInput: ["text", "image"],
});
const comp = new StatusLineComponent(session);
comp.updateSettings({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
separator: "none",
sessionAccent: false,
contextLine: "annotated",
});
const plain = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
expect(plain).toContain("┃");
expect(plain).not.toContain("╎");
});
it("hides the speculation tick when async compaction is disabled", () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
usage: { tokens: 50_000, contextWindow: 100_000, percent: 50 },
settings: Settings.isolated({ "compaction.asyncEnabled": false }),
});
const comp = new StatusLineComponent(session);
comp.updateSettings({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
separator: "none",
sessionAccent: false,
contextLine: "annotated",
});
const plain = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
expect(plain).toContain("┃");
expect(plain).not.toContain("╎");
});
it("standalone mode renders a plain bottom bar without powerline chrome", () => {
const { session } = makeSession({
messages: [userMessage("hi")],
@@ -31,6 +31,7 @@ function createCtx(overrides?: {
branch?: string | null;
sessionName?: string;
sessionAccent?: boolean;
previewTitle?: string;
}): SegmentContext {
const hasName = overrides?.sessionName !== undefined;
return {
@@ -41,6 +42,7 @@ function createCtx(overrides?: {
sessionManager: hasName ? { getSessionName: () => overrides.sessionName } : undefined,
} as unknown as SegmentContext["session"],
sessionAccent: overrides?.sessionAccent,
previewTitle: overrides?.previewTitle,
width: 120,
compactThinkingLevel: false,
options: {
@@ -192,6 +194,36 @@ describe("status line session accent", () => {
});
});
describe("session_name preview-title fallback", () => {
it("renders the stand-in title when the session is unnamed", () => {
const seg = renderSegment("session_name", createCtx({ previewTitle: "omp" }));
expect(seg.visible).toBe(true);
expect(stripAnsi(seg.content)).toBe("omp");
});
it("prefers the real session name over the stand-in", () => {
const seg = renderSegment("session_name", createCtx({ sessionName: "Named session", previewTitle: "omp" }));
expect(stripAnsi(seg.content)).toBe("Named session");
});
it("right-aligns the stand-in title through the box border pipeline", () => {
const component = new StatusLineComponent(createStatusLineSession(""));
component.updateSettings({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
separator: "powerline-thin",
sessionAccent: false,
});
const withTitle = component.getTopBorder(80, "omp");
// The gauge fill pads the group gap, so the title chip lands flush right.
expect(withTitle.width).toBe(80);
expect(stripAnsi(withTitle.content).trimEnd().endsWith("omp")).toBe(true);
// Live render path passes no preview title: unnamed sessions show none.
expect(stripAnsi(component.getTopBorder(80).content)).not.toContain("omp");
});
});
describe("status line focused-agent dimming", () => {
it("keeps powerline end caps at full intensity while text stays dimmed", () => {
const component = new StatusLineComponent(createStatusLineSession("Focused session"));
@@ -168,12 +168,7 @@ describe("YieldTool", () => {
expect(result.details).toEqual({ data: { n: 4 }, status: "success", error: undefined });
});
it("arg validation rejects object payloads in string-typed fields instead of stringifying", () => {
// Regression: the repair layer used to JSON.stringify an object submitted
// for a string-typed schema field even though the diagnosis came from a
// failed `anyOf` branch of the yield wrapper, so validation "passed" and
// the parent received `summary: "{\"purge\":13,…}"` instead of a retry
// prompt. Union-branch diagnoses must not trigger lossy repairs.
it("arg validation serializes object payloads for string-typed output fields", () => {
const tool = new YieldTool(
createSession({
outputSchema: {
@@ -183,14 +178,14 @@ describe("YieldTool", () => {
},
}),
);
expect(() =>
expect(
validateToolArguments(tool as never, {
type: "toolCall",
id: "call-dict-summary",
name: "yield",
arguments: { result: { data: { summary: { purge: 13, keep: 20 } } } },
}),
).toThrow(/summary/);
).toEqual({ result: { data: { summary: '{"purge":13,"keep":20}' } } });
});
it("arg validation passes conforming args through unmodified", () => {