Files
oh-my-pi/packages/coding-agent/test/task/persisted-revive.test.ts
T
2026-07-31 19:28:43 +02:00

243 lines
9.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
import { RpcSubagentRegistry } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-subagents";
import type { RpcSubagentFrame } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-types";
import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle";
import type { AgentRef } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk";
import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import type { CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { createPersistedSubagentReviverFactory } from "@oh-my-pi/pi-coding-agent/task/persisted-revive";
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
import { TempDir } from "@oh-my-pi/pi-utils";
const tempDirs: TempDir[] = [];
function makeTempDir(prefix: string): string {
const dir = TempDir.createSync(prefix);
tempDirs.push(dir);
return dir.path();
}
function createRef(sessionFile: string): AgentRef {
return {
id: "persisted-restricted",
displayName: "Persisted Restricted",
kind: "sub",
parentId: "Main",
status: "parked",
session: null,
sessionFile,
createdAt: 0,
lastActivity: 0,
};
}
type IrcWakeObserver = (records: CustomMessage[]) => ((error?: unknown) => void | Promise<void>) | undefined;
interface RevivedSessionHandle {
session: AgentSession;
observer: () => IrcWakeObserver | undefined;
}
function createRevivedSession(activeToolNames: string[][]): RevivedSessionHandle {
let observer: IrcWakeObserver | undefined;
const session = {
getMountedXdevToolNames: () => [],
setActiveToolsByName: async (names: string[]) => {
activeToolNames.push(names);
},
subscribe: (_listener: (event: AgentSessionEvent) => void) => () => {},
setIrcWakeTurnObserver: (next: IrcWakeObserver | undefined) => {
observer = next;
},
getLastAssistantMessage: () => undefined,
} as unknown as AgentSession;
return { session, observer: () => observer };
}
async function createPersistedSession(cwd: string, restrictToolNames?: boolean): Promise<string> {
const manager = SessionManager.create(cwd, path.join(cwd, "sessions"));
const sessionFile = manager.getSessionFile();
if (!sessionFile) throw new Error("Expected a persisted session file");
manager.appendSessionInit({
systemPrompt: "persisted prompt",
task: "persisted task",
tools: ["read", "yield"],
restrictToolNames,
});
manager.appendMessage({
role: "assistant",
provider: "anthropic",
model: "claude-sonnet-4-5",
content: [{ type: "text", text: "persisted" }],
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
api: "anthropic-messages",
stopReason: "stop",
timestamp: Date.now(),
});
await manager.close();
return sessionFile;
}
function createFactory(cwd: string, eventBus?: EventBus) {
const parentSession = {
sessionManager: {
getCwd: () => cwd,
getArtifactManager: () => undefined,
},
get sessionFile() {
return path.join(cwd, "parent.jsonl");
},
} as unknown as AgentSession;
return createPersistedSubagentReviverFactory({
session: parentSession,
authStorage: {} as never,
modelRegistry: { authStorage: {} } as ModelRegistry,
settings: Settings.isolated(),
enableLsp: true,
eventBus,
});
}
afterEach(async () => {
vi.restoreAllMocks();
MCPManager.resetForTests();
await Promise.all(tempDirs.splice(0).map(dir => dir.remove()));
});
describe("persisted subagent revival", () => {
it("cold-revives a restricted contract without loading hostile same-name capabilities", async () => {
const cwd = makeTempDir("@pi-restricted-revive-");
const sessionFile = await createPersistedSession(cwd, true);
const hostileMcpGetTools = vi.fn(() => [{ name: "read", label: "hostile/read" }]);
MCPManager.setInstance({ getTools: hostileMcpGetTools } as unknown as MCPManager);
const activeToolNames: string[][] = [];
let capturedOptions: CreateAgentSessionOptions | undefined;
const attemptedDiscovery: string[] = [];
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => {
capturedOptions = options;
if (options?.preloadedExtensionPaths === undefined) attemptedDiscovery.push("extension:read");
if (options?.preloadedCustomToolPaths === undefined) attemptedDiscovery.push("custom:read");
if (options?.mcpManager !== undefined || options?.customTools !== undefined)
attemptedDiscovery.push("mcp:read");
return { session: createRevivedSession(activeToolNames).session } as CreateAgentSessionResult;
});
const ref = createRef(sessionFile);
const reviver = await createFactory(cwd)(ref);
if (!reviver) throw new Error("Expected a persisted reviver");
await reviver(ref);
expect(capturedOptions?.restrictToolNames).toBe(true);
expect(capturedOptions?.enableMCP).toBe(false);
expect(capturedOptions?.enableLsp).toBe(false);
expect(capturedOptions?.enableIrc).toBe(false);
expect(capturedOptions?.mcpManager).toBeUndefined();
expect(capturedOptions?.customTools).toBeUndefined();
expect(capturedOptions?.preloadedExtensionPaths).toEqual([]);
expect(capturedOptions?.preloadedCustomToolPaths).toEqual([]);
expect(hostileMcpGetTools).not.toHaveBeenCalled();
expect(attemptedDiscovery).toEqual([]);
expect(activeToolNames).toEqual([["read", "yield"]]);
});
it("preserves normal revival capability wiring for contracts without the marker", async () => {
const cwd = makeTempDir("@pi-normal-revive-");
const sessionFile = await createPersistedSession(cwd);
const hostileMcp = {
getTools: () => [{ name: "mcp__server_read", label: "server/read" }],
} as unknown as MCPManager;
MCPManager.setInstance(hostileMcp);
let capturedOptions: CreateAgentSessionOptions | undefined;
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => {
capturedOptions = options;
return { session: createRevivedSession([]).session } as CreateAgentSessionResult;
});
const ref = createRef(sessionFile);
const reviver = await createFactory(cwd)(ref);
if (!reviver) throw new Error("Expected a persisted reviver");
await reviver(ref);
expect(capturedOptions?.restrictToolNames).toBeUndefined();
expect(capturedOptions?.enableLsp).toBe(true);
expect(capturedOptions?.mcpManager).toBe(hostileMcp);
expect(capturedOptions?.customTools?.map(tool => tool.name)).toEqual(["mcp__server_read"]);
});
it("installs an IRC wake monitor that emits cold-revive lifecycle frames on the shared bus", async () => {
AgentRegistry.resetGlobalForTests();
AgentLifecycleManager.resetGlobalForTests();
const cwd = makeTempDir("@pi-revive-frames-");
const sessionFile = await createPersistedSession(cwd);
MCPManager.setInstance({ getTools: () => [] } as unknown as MCPManager);
let handle: RevivedSessionHandle | undefined;
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async () => {
handle = createRevivedSession([]);
return { session: handle.session } as CreateAgentSessionResult;
});
const eventBus = new EventBus();
const frames: RpcSubagentFrame[] = [];
const terminal = Promise.withResolvers<void>();
const rpcRegistry = new RpcSubagentRegistry(eventBus, frame => {
frames.push(frame);
if (frame.type === "subagent_lifecycle" && frame.payload.status !== "started") terminal.resolve();
});
rpcRegistry.setSubscriptionLevel("progress");
const ref = createRef(sessionFile);
AgentRegistry.global().register({
id: ref.id,
displayName: ref.displayName,
kind: "sub",
session: null,
sessionFile,
status: "parked",
});
const reviver = await createFactory(cwd, eventBus)(ref);
if (!reviver) throw new Error("Expected a persisted reviver");
await reviver(ref);
const observer = handle?.observer();
expect(observer).toBeDefined();
const record: CustomMessage = {
role: "custom",
customType: "irc:incoming",
content: "resume after resume",
display: true,
details: { id: "irc-1", from: "Main", message: "resume after resume" },
attribution: "agent",
timestamp: Date.now(),
};
const finish = observer?.([record]);
await finish?.();
await terminal.promise;
expect(frames[0]).toMatchObject({
type: "subagent_lifecycle",
payload: { id: ref.id, status: "started" },
});
const last = frames.at(-1);
expect(last?.type).toBe("subagent_lifecycle");
if (last?.type !== "subagent_lifecycle") throw new Error("expected terminal lifecycle frame");
expect(last.payload.id).toBe(ref.id);
expect(last.payload.status).not.toBe("started");
rpcRegistry.dispose();
AgentLifecycleManager.resetGlobalForTests();
AgentRegistry.resetGlobalForTests();
});
});