import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-ai/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 { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; const usage = { input: 16, output: 8, cacheRead: 0, cacheWrite: 0, totalTokens: 24, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; describe("AgentSession shake", () => { let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let events: AgentSessionEvent[]; let apiInfo: { api: AssistantMessage["api"]; provider: AssistantMessage["provider"]; model: string }; beforeEach(async () => { tempDir = TempDir.createSync("@pi-shake-"); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected built-in anthropic model to exist"); apiInfo = { api: model.api, provider: model.provider, model: model.id }; const agent = new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false }), modelRegistry, }); session.subscribe(event => events.push(event)); }); afterEach(async () => { if (session) await session.dispose(); authStorage.close(); try { await tempDir.remove(); } catch {} vi.restoreAllMocks(); }); /** Seed a user → assistant(toolCall) → toolResult turn carrying a heavy bash result. */ function seedHeavyToolResult(text: string, toolName = "bash"): void { const toolCallId = `call_${toolName}_${Math.random().toString(36).slice(2)}`; sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "do it" }], timestamp: Date.now() - 3, }); sessionManager.appendMessage({ role: "assistant", content: [ { type: "text", text: "working" }, { type: "toolCall", id: toolCallId, name: toolName, arguments: { command: "ls" } }, ], ...apiInfo, stopReason: "toolUse", usage, timestamp: Date.now() - 2, }); sessionManager.appendMessage({ role: "toolResult", toolCallId, toolName, content: [{ type: "text", text }], isError: false, timestamp: Date.now() - 1, }); } function branchToolResults(): ToolResultMessage[] { return sessionManager .getBranch() .filter(e => e.type === "message" && (e.message as { role?: string }).role === "toolResult") .map(e => (e as { message: ToolResultMessage }).message); } describe("elide", () => { it("drops the tool result, offloads to an artifact, and embeds the recovery link", async () => { seedHeavyToolResult("X".repeat(4000)); const replaceSpy = vi.spyOn(session.agent, "replaceMessages"); const result = await session.shake("elide"); expect(result.mode).toBe("elide"); expect(result.toolResultsDropped).toBe(1); expect(result.tokensFreed).toBeGreaterThan(0); expect(result.artifactId).toBeDefined(); expect(replaceSpy).toHaveBeenCalled(); const [tr] = branchToolResults(); expect(tr.prunedAt).toBeGreaterThan(0); const text = tr.content.map(b => (b.type === "text" ? b.text : "")).join(""); expect(text).toContain(`artifact://${result.artifactId}`); expect(text).toContain("shaken"); }); it("returns zero counts for an empty branch", async () => { const result = await session.shake("elide"); expect(result.toolResultsDropped).toBe(0); expect(result.blocksDropped).toBe(0); expect(result.tokensFreed).toBe(0); }); }); describe("images", () => { it("mirrors dropImages and reports the removed image count", async () => { const png: ImageContent = { type: "image", data: "iVBORw0KGgo", mimeType: "image/png" }; sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "look" }, png], timestamp: Date.now(), }); const result = await session.shake("images"); expect(result.mode).toBe("images"); expect(result.imagesDropped).toBe(1); const branch = sessionManager.getBranch(); const userMsg = branch.find(e => e.type === "message" && (e.message as { role?: string }).role === "user"); const content = (userMsg as { message: { content: unknown } }).message.content as Array<{ type: string }>; expect(content.some(b => b.type === "image")).toBe(false); }); }); describe("protected tools", () => { it("never shakes skill results", async () => { seedHeavyToolResult("S".repeat(4000), "skill"); const result = await session.shake("elide"); expect(result.toolResultsDropped).toBe(0); }); }); describe("auto-shake strategy", () => { it("dispatches the elide path and emits a shake action for threshold maintenance", async () => { session.settings.set("compaction.strategy", "shake"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); const shakeSpy = vi .spyOn(session, "shake") .mockResolvedValue({ mode: "elide", toolResultsDropped: 1, blocksDropped: 0, tokensFreed: 500 }); const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "trigger" }], ...apiInfo, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await Bun.sleep(20); expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); const start = events.filter(e => e.type === "auto_compaction_start"); expect(start).toHaveLength(1); expect(start[0]).toMatchObject({ type: "auto_compaction_start", reason: "threshold", action: "shake" }); const end = events.filter(e => e.type === "auto_compaction_end"); expect(end).toHaveLength(1); expect(end[0]).toMatchObject({ type: "auto_compaction_end", action: "shake" }); }); it("falls back to context-full when shake cannot drop context below the threshold (regression #2119)", async () => { session.settings.set("compaction.strategy", "shake"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); // Seed agent state so the post-shake estimate is well above the 1% threshold // (~2K tokens for a 200K window). The mocked shake returns reclaimed=true but // does not modify state, mimicking the dead-loop scenario where shake removes // nothing material yet the threshold check stays positive. session.agent.replaceMessages([ { role: "user", content: [{ type: "text", text: "x".repeat(40000) }], timestamp: Date.now(), } as never, ]); const shakeSpy = vi .spyOn(session, "shake") .mockResolvedValue({ mode: "elide", toolResultsDropped: 1, blocksDropped: 0, tokensFreed: 10 }); const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "trigger" }], ...apiInfo, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await Bun.sleep(50); // Shake fires once. The pre-fix bug auto-continued, which would re-trigger shake // on the next agent_end. The fix replaces that loop with a one-shot fallback. expect(shakeSpy).toHaveBeenCalledTimes(1); const shakeEnd = events.find( e => e.type === "auto_compaction_end" && (e as { action?: string }).action === "shake", ) as { errorMessage?: string; skipped?: boolean } | undefined; expect(shakeEnd).toBeDefined(); expect(shakeEnd?.errorMessage).toMatch(/falling back to context-full/i); // Fallback enters the context-full path so the situation actually resolves. const fullStart = events.find( e => e.type === "auto_compaction_start" && (e as { action?: string }).action === "context-full", ); expect(fullStart).toBeDefined(); }); }); });