From 184058f11a812e03c159c614402eaa55f5b83a1f Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 06:36:44 +0200 Subject: [PATCH] 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. --- .../test/compaction-speculation.test.ts | 54 ++++++++++++++-- .../test/extensions-runner.test.ts | 1 + .../test/interactive-mode-todo-clear.test.ts | 18 +++--- .../components/composer-shape-preview.test.ts | 48 ++++++++------ .../sdk-credential-disabled-bridge.test.ts | 1 + .../test/status-line-context-cache.test.ts | 63 +++++++++++++++++-- .../test/status-line-overflow.test.ts | 32 ++++++++++ .../coding-agent/test/tools/yield.test.ts | 11 +--- 8 files changed, 182 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/test/compaction-speculation.test.ts b/packages/coding-agent/test/compaction-speculation.test.ts index 9d26dc5ac..d90eb25c0 100644 --- a/packages/coding-agent/test/compaction-speculation.test.ts +++ b/packages/coding-agent/test/compaction-speculation.test.ts @@ -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", diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 86751fe31..f72b8e126 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -3856,6 +3856,7 @@ describe("ExtensionRunner", () => { fileWriteFallbackHandlers: [], fileDeleteFallbackHandlers: [], messageRenderers: new Map(), + composerShapes: new Map(), commands: new Map(), flags: new Map(), shortcuts: new Map(), diff --git a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts index 7a5f877ae..c235fe770 100644 --- a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts +++ b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts b/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts index 0d6117f73..57017c02f 100644 --- a/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts +++ b/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts @@ -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 } }); diff --git a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts index 6504aa9a3..607632f20 100644 --- a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts +++ b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts @@ -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(), diff --git a/packages/coding-agent/test/status-line-context-cache.test.ts b/packages/coding-agent/test/status-line-context-cache.test.ts index c3b5d0a29..6b9c24952 100644 --- a/packages/coding-agent/test/status-line-context-cache.test.ts +++ b/packages/coding-agent/test/status-line-context-cache.test.ts @@ -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")], diff --git a/packages/coding-agent/test/status-line-overflow.test.ts b/packages/coding-agent/test/status-line-overflow.test.ts index 269b050ac..9f9eeab45 100644 --- a/packages/coding-agent/test/status-line-overflow.test.ts +++ b/packages/coding-agent/test/status-line-overflow.test.ts @@ -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")); diff --git a/packages/coding-agent/test/tools/yield.test.ts b/packages/coding-agent/test/tools/yield.test.ts index e0b234667..6e33a3252 100644 --- a/packages/coding-agent/test/tools/yield.test.ts +++ b/packages/coding-agent/test/tools/yield.test.ts @@ -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", () => {