Files
oh-my-pi/packages/coding-agent/test/shake.test.ts
T
can1357 1b9d9d0851 refactor(catalog)!: split model catalog from pi-ai
Move bundled models, model cache/manager, thinking metadata, effort helpers,
provider descriptors/discovery, wire constants, and model identity utilities
into the new @oh-my-pi/pi-catalog package.

Update pi-ai to keep provider runtime/auth concerns, move catalog provider
metadata into CATALOG_PROVIDERS, and migrate coding-agent, agent, stats, docs,
and tests to import catalog values from pi-catalog.

Split coding-agent model registry helpers into discovery, roles, and models
config modules while preserving registry orchestration.

BREAKING CHANGE: @oh-my-pi/pi-ai no longer exports catalog subpaths such as
/models, /model-cache, /model-manager, /model-thinking, /effort,
/provider-models*, discovery helpers, and provider wire constants; use the
matching @oh-my-pi/pi-catalog subpaths instead.
2026-06-10 04:06:57 +02:00

250 lines
9.2 KiB
TypeScript

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-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 { 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();
});
});
});