424458e99d
Reverted branch-side edits to spawn-policy prompts/tests, settings tab groups, mermaid cache typing, prewalk todo gating, and packages/ai test churn back to merge-base content; trimmed their changelog entries. These repaired stale CI against an older main and are stale or conflicting against current main.
181 lines
7.4 KiB
TypeScript
181 lines
7.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import * as path from "node:path";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
|
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
import { type GeneratedProvider, 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 } 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 UNRENDERABLE_SNAPCOMPACT_TEXT = "\uE000\uE001\uE002\uE003\uE004\uE005\uE006\uE007\uE008\uE009";
|
|
|
|
interface Harness {
|
|
session: AgentSession;
|
|
sessionManager: SessionManager;
|
|
notices: string[];
|
|
awaitCompactionEnd: () => Promise<{ action: string; errorMessage?: string }>;
|
|
triggerThreshold: () => void;
|
|
}
|
|
|
|
interface HarnessOptions {
|
|
activeModel: { provider: GeneratedProvider; id: string };
|
|
seedMessages?: Message[];
|
|
}
|
|
|
|
async function createHarness(tempDir: TempDir, authStorage: AuthStorage, options: HarnessOptions): Promise<Harness> {
|
|
const activeModel = getBundledModel(options.activeModel.provider, options.activeModel.id);
|
|
if (!activeModel) throw new Error(`Missing bundled model ${options.activeModel.provider}/${options.activeModel.id}`);
|
|
authStorage.setRuntimeApiKey(options.activeModel.provider, "test-key");
|
|
|
|
const modelRegistry = new ModelRegistry(authStorage);
|
|
const agent = new Agent({
|
|
initialState: { model: activeModel, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
});
|
|
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
|
|
const seed = options.seedMessages ?? [{ role: "user", content: "hello", timestamp: Date.now() }];
|
|
for (const message of seed) sessionManager.appendMessage(message);
|
|
const firstKeptEntryId = sessionManager.getBranch()[0]?.id;
|
|
if (!firstKeptEntryId) throw new Error("Expected seeded branch entry");
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.strategy": "snapcompact",
|
|
// Force a 1-token recent window so the post-turn cut always splits off the
|
|
// last turn and summarizes the seeded unrenderable history. With the default
|
|
// 20k window the cut keeps both tiny messages, leaving nothing for
|
|
// snapcompact's renderability preflight to scan.
|
|
"compaction.keepRecentTokens": 1,
|
|
modelRoles: { vision: "aimlapi/claude-sonnet-4-5-20250929" },
|
|
});
|
|
const session = new AgentSession({
|
|
agent,
|
|
sessionManager,
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
vi.spyOn(compactionModule, "compact").mockResolvedValue({
|
|
summary: "compacted",
|
|
shortSummary: undefined,
|
|
firstKeptEntryId,
|
|
tokensBefore: 123,
|
|
details: {},
|
|
});
|
|
|
|
const end = Promise.withResolvers<{ action: string; errorMessage?: string }>();
|
|
const notices: string[] = [];
|
|
session.subscribe(event => {
|
|
if (event.type === "notice" && event.source === "compaction") notices.push(event.message);
|
|
if (event.type === "auto_compaction_end") {
|
|
end.resolve({ action: event.action, errorMessage: event.errorMessage });
|
|
}
|
|
});
|
|
|
|
const triggerThreshold = () => {
|
|
// Prompt tokens above the auto-compaction threshold but below the model's
|
|
// context window: post-turn maintenance must run a threshold compaction,
|
|
// NOT the overflow recovery path (which drops the just-ended turn before
|
|
// snapcompact's renderability preflight can scan it, leaving nothing to
|
|
// summarize). Derived from the live window so the fixture survives model
|
|
// metadata changes (claude-sonnet-4-5's 200k window is narrower than the
|
|
// vision-role qwen's, so a fixed count would overflow one of them).
|
|
const contextWindow = activeModel.contextWindow ?? 0;
|
|
const thresholdTokens = compactionModule.resolveThresholdTokens(contextWindow, settings.getGroup("compaction"));
|
|
const promptTokens = contextWindow > 0 ? Math.floor((thresholdTokens + contextWindow) / 2) : 246_000;
|
|
const assistantMsg = {
|
|
role: "assistant" as const,
|
|
content: [{ type: "text" as const, text: "Done." }],
|
|
api: activeModel.api,
|
|
provider: activeModel.provider,
|
|
model: activeModel.id,
|
|
stopReason: "stop" as const,
|
|
usage: {
|
|
input: promptTokens,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: promptTokens,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
|
|
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
|
|
};
|
|
|
|
return { session, sessionManager, notices, awaitCompactionEnd: () => end.promise, triggerThreshold };
|
|
}
|
|
|
|
describe("AgentSession auto-snapcompact local-blocker fallback", () => {
|
|
let session: AgentSession | undefined;
|
|
let authStorage: AuthStorage | undefined;
|
|
let tempDir: TempDir | undefined;
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await session?.dispose();
|
|
} finally {
|
|
authStorage?.close();
|
|
await tempDir?.remove();
|
|
vi.restoreAllMocks();
|
|
session = undefined;
|
|
authStorage = undefined;
|
|
tempDir = undefined;
|
|
}
|
|
});
|
|
|
|
it("downgrades to context-full when the active model cannot read snapcompact frames", async () => {
|
|
tempDir = TempDir.createSync("@pi-snapcompact-text-only-");
|
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
|
|
const harness = await createHarness(tempDir, authStorage, {
|
|
activeModel: { provider: "aimlapi", id: "alibaba/qwen3-coder-480b-a35b-instruct" },
|
|
});
|
|
session = harness.session;
|
|
harness.triggerThreshold();
|
|
|
|
const result = await harness.awaitCompactionEnd();
|
|
expect(result).toEqual({ action: "context-full", errorMessage: undefined });
|
|
expect(compactionModule.compact).toHaveBeenCalled();
|
|
expect(harness.notices).toContain(
|
|
"snapcompact needs a vision-capable active model (alibaba/qwen3-coder-480b-a35b-instruct is text-only); using context-full auto-compaction instead.",
|
|
);
|
|
expect(harness.sessionManager.getBranch().find(entry => entry.type === "compaction")).toMatchObject({
|
|
type: "compaction",
|
|
summary: "compacted",
|
|
});
|
|
});
|
|
|
|
it("downgrades to context-full when unsupported glyphs make snapcompact unsafe", async () => {
|
|
tempDir = TempDir.createSync("@pi-snapcompact-unsupported-glyphs-");
|
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
|
|
const harness = await createHarness(tempDir, authStorage, {
|
|
activeModel: { provider: "aimlapi", id: "claude-sonnet-4-5-20250929" },
|
|
seedMessages: [
|
|
{
|
|
role: "user",
|
|
content: UNRENDERABLE_SNAPCOMPACT_TEXT.repeat(10),
|
|
timestamp: Date.now(),
|
|
},
|
|
],
|
|
});
|
|
session = harness.session;
|
|
harness.triggerThreshold();
|
|
|
|
const result = await harness.awaitCompactionEnd();
|
|
expect(result.action).toBe("context-full");
|
|
expect(result.errorMessage).toBeUndefined();
|
|
expect(compactionModule.compact).toHaveBeenCalled();
|
|
const unsupportedGlyphNotice = harness.notices.find(message =>
|
|
message.startsWith("snapcompact disabled: unsupported characters for selected snapcompact font"),
|
|
);
|
|
expect(unsupportedGlyphNotice).toBeDefined();
|
|
expect(unsupportedGlyphNotice).toContain("using context-full auto-compaction instead.");
|
|
expect(harness.sessionManager.getBranch().find(entry => entry.type === "compaction")).toMatchObject({
|
|
type: "compaction",
|
|
summary: "compacted",
|
|
});
|
|
});
|
|
});
|