bc69fd207d
- Added ModelManager API with createModelManager() factory for managing bundled and dynamically discovered models with configurable refresh strategies. - Exported discovery utilities for fetching models from Antigravity, Codex, Cursor, Gemini, and OpenAI-compatible endpoints with provider-specific model manager configuration helpers. - Renamed public API functions for clarity: getModel() -> getBundledModel(), getModels() -> getBundledModels(), getProviders() -> getBundledProviders(). - Added on-disk model caching with TTL-based invalidation and resolveProviderModels() function for runtime model resolution with source precedence. - Refactored model discovery script to dynamically fetch models from Codex, Cursor, and Antigravity using OAuth credentials instead of hardcoded lists.
240 lines
8.1 KiB
TypeScript
240 lines
8.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
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 { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
|
|
import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
|
|
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";
|
|
import { getProjectAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
|
|
|
const runtimeSignalStoreKey = "__ompRuntimeSignals";
|
|
|
|
type RuntimeSignalGlobal = typeof globalThis & { [runtimeSignalStoreKey]?: string[] };
|
|
|
|
function getRuntimeSignals(): string[] {
|
|
const globalWithSignals = globalThis as RuntimeSignalGlobal;
|
|
if (!globalWithSignals[runtimeSignalStoreKey]) {
|
|
globalWithSignals[runtimeSignalStoreKey] = [];
|
|
}
|
|
return globalWithSignals[runtimeSignalStoreKey];
|
|
}
|
|
|
|
/**
|
|
* Regression test: auto-compaction completion should resume the agent loop when
|
|
* there are queued agent-level messages (follow-up/steering/custom).
|
|
*/
|
|
describe("AgentSession auto-compaction queue resume", () => {
|
|
let tempDir: TempDir;
|
|
let session: AgentSession;
|
|
let sessionManager: SessionManager;
|
|
let authStorage: AuthStorage;
|
|
let modelRegistry: ModelRegistry;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = TempDir.createSync("@pi-auto-compaction-queue-");
|
|
vi.useFakeTimers();
|
|
|
|
// Provide an extension that short-circuits compaction so the test doesn't
|
|
// make any LLM calls.
|
|
const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions");
|
|
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts");
|
|
fs.writeFileSync(
|
|
extensionPath,
|
|
[
|
|
"export default function(pi) {",
|
|
'\tpi.on("session_before_compact", async (event) => {',
|
|
"\t\treturn {",
|
|
"\t\t\tcompaction: {",
|
|
'\t\t\t\tsummary: "compacted",',
|
|
"\t\t\t\tshortSummary: undefined,",
|
|
"\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,",
|
|
"\t\t\t\ttokensBefore: event.preparation.tokensBefore,",
|
|
"\t\t\t\tdetails: {},",
|
|
"\t\t\t},",
|
|
"\t\t};",
|
|
"\t});",
|
|
'\tpi.on("auto_compaction_start", async (event) => {',
|
|
`\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`,
|
|
'\t\tsignals.push("compaction:start:" + event.reason);',
|
|
"\t});",
|
|
'\tpi.on("auto_compaction_end", async (event) => {',
|
|
`\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`,
|
|
'\t\tsignals.push("compaction:end:" + (event.aborted ? "aborted" : "ok"));',
|
|
"\t});",
|
|
'\tpi.on("todo_reminder", async (event) => {',
|
|
`\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`,
|
|
'\t\tsignals.push("todo:" + event.attempt + "/" + event.maxAttempts);',
|
|
"\t});",
|
|
"}",
|
|
].join("\n"),
|
|
);
|
|
|
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
|
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
|
modelRegistry = new ModelRegistry(authStorage);
|
|
sessionManager = SessionManager.create(tempDir.path());
|
|
getRuntimeSignals().length = 0;
|
|
|
|
const extensionsResult = await loadExtensions([extensionPath], tempDir.path());
|
|
const extensionRunner = new ExtensionRunner(
|
|
extensionsResult.extensions,
|
|
extensionsResult.runtime,
|
|
tempDir.path(),
|
|
sessionManager,
|
|
modelRegistry,
|
|
);
|
|
|
|
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!model) {
|
|
throw new Error("Expected built-in anthropic model to exist");
|
|
}
|
|
|
|
const agent = new Agent({
|
|
initialState: {
|
|
model,
|
|
systemPrompt: "Test",
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
});
|
|
|
|
// Seed a minimal session branch so prepareCompaction() returns a preparation.
|
|
sessionManager.appendMessage({
|
|
role: "user",
|
|
content: "hello",
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager,
|
|
settings: Settings.isolated({
|
|
"compaction.autoContinue": false,
|
|
"todo.reminders": true,
|
|
"todo.reminders.max": 3,
|
|
}),
|
|
modelRegistry,
|
|
extensionRunner,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await session.dispose();
|
|
tempDir.removeSync();
|
|
vi.useRealTimers();
|
|
getRuntimeSignals().length = 0;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("resumes after threshold compaction when only agent-level queued messages exist", async () => {
|
|
session.agent.followUp({
|
|
role: "custom",
|
|
customType: "test",
|
|
content: [{ type: "text", text: "Queued custom" }],
|
|
display: false,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
expect(session.agent.hasQueuedMessages()).toBe(true);
|
|
|
|
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
|
|
|
|
// Wait for auto_compaction_end event to know when the async handler is done
|
|
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
|
|
session.subscribe(event => {
|
|
if (event.type === "auto_compaction_end") onCompactionDone();
|
|
});
|
|
|
|
// Build a fake AssistantMessage with high token usage to trigger threshold
|
|
// compaction (contextWindow=200000, threshold ~80%).
|
|
const assistantMsg = {
|
|
role: "assistant" as const,
|
|
content: [],
|
|
api: "anthropic-messages" as const,
|
|
provider: "anthropic" as const,
|
|
model: "claude-sonnet-4-5",
|
|
stopReason: "stop" as const,
|
|
usage: {
|
|
input: 190000,
|
|
output: 1000,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 191000,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
// Drive auto-compaction through the event flow:
|
|
// message_end → stores #lastAssistantMessage
|
|
// agent_end → #checkCompaction → shouldCompact → #runAutoCompaction
|
|
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
|
|
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
|
|
|
|
// Wait for the async compaction handler to finish, then advance past setTimeout(100)
|
|
await compactionDone;
|
|
await Promise.resolve();
|
|
vi.advanceTimersByTime(200);
|
|
|
|
expect(continueSpy).toHaveBeenCalledTimes(1);
|
|
const runtimeSignals = getRuntimeSignals();
|
|
expect(runtimeSignals).toContain("compaction:start:threshold");
|
|
expect(runtimeSignals.some(signal => signal.startsWith("compaction:end:"))).toBe(true);
|
|
});
|
|
|
|
it("forwards todo reminder lifecycle signals to extensions", async () => {
|
|
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
|
|
|
|
const sessionFile = sessionManager.getSessionFile();
|
|
if (!sessionFile) {
|
|
throw new Error("Expected session file to exist");
|
|
}
|
|
const todoPath = `${sessionFile.slice(0, -6)}/todos.json`;
|
|
await Bun.write(
|
|
todoPath,
|
|
JSON.stringify({
|
|
todos: [{ id: "todo-1", content: "Finish pending task", status: "in_progress" }],
|
|
}),
|
|
);
|
|
|
|
const { promise: reminderDone, resolve: onReminderDone } = Promise.withResolvers<void>();
|
|
session.subscribe(event => {
|
|
if (event.type === "todo_reminder") onReminderDone();
|
|
});
|
|
|
|
const assistantMsg = {
|
|
role: "assistant" as const,
|
|
content: [],
|
|
api: "anthropic-messages" as const,
|
|
provider: "anthropic" as const,
|
|
model: "claude-sonnet-4-5",
|
|
stopReason: "stop" as const,
|
|
usage: {
|
|
input: 100,
|
|
output: 20,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 120,
|
|
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] });
|
|
|
|
await reminderDone;
|
|
await Promise.resolve();
|
|
|
|
expect(getRuntimeSignals()).toContain("todo:1/3");
|
|
expect(continueSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|