Files
oh-my-pi/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts
T
can1357 0298a88601 feat(coding-agent): added in-memory todo phase management to ToolSession API
- Added getTodoPhases() and setTodoPhases() methods to ToolSession API for in-memory todo phase management.
- Added getLatestTodoPhasesFromEntries() export to retrieve todo phases from session history entries.
- Changed todo state management from file-based (todos.json) to in-memory session cache with automatic persistence.
- Changed todo phases to sync from session branch history during branching and rewriting operations.
- Removed file-based todo loading logic and replaced with session-based todo phase retrieval throughout codebase.
2026-02-22 19:11:24 +01:00

236 lines
8.0 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, withTimeout } 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();
session.setTodoPhases([
{
id: "phase-1",
name: "Execution",
tasks: [{ id: "task-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 withTimeout(reminderDone, 1000, "Todo reminder timed out");
await Promise.resolve();
expect(getRuntimeSignals()).toContain("todo:1/3");
expect(continueSpy).toHaveBeenCalledTimes(1);
});
});