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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user