Files
oh-my-pi/packages/coding-agent/test/task/persisted-revive.test.ts
T
roboomp 3acc57de8c fix(task): initialize extension runtime on subagent revival
Both subagent revivers rebuilt the session but never wired the extension
runtime, leaving it pre-init where every action method throws
ExtensionRuntimeNotInitializedError. An extension with a tool_call handler
touching a runtime action then tripped the fail-closed gate in emitToolCall
and blocked every tool, including the hidden yield, so the revived agent
could neither finish nor exit and looped until killed.

Both the warm lifecycle reviver (executor.ts) and the cold persisted
reviver (persisted-revive.ts) now call the shared initializeExtensions
helper on the rebuilt session, restoring runtime actions, onError, and the
session_start event.

Fixes #8824
2026-08-19 08:44:55 +00:00

337 lines
13 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[][], extensionRunner?: unknown): 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;
},
subscribeRunState: () => () => {},
getLastAssistantMessage: () => undefined,
extensionRunner,
} as unknown as AgentSession;
return { session, observer: () => observer };
}
async function createPersistedSession(
cwd: string,
restrictToolNames?: boolean,
modelRole?: string,
advisor?: string,
): 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,
modelRole,
resolvedModel: modelRole ? "anthropic/claude-sonnet-4-5" : undefined,
advisor,
});
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("initializes the extension runtime on cold revival so tool_call handlers are not fail-closed blocked", async () => {
const cwd = makeTempDir("@pi-revive-ext-init-");
const sessionFile = await createPersistedSession(cwd);
MCPManager.setInstance({ getTools: () => [] } as unknown as MCPManager);
const initialize = vi.fn();
const onError = vi.fn();
const emit = vi.fn(async () => undefined);
const extensionRunner = { initialize, onError, emit };
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(
async () => ({ session: createRevivedSession([], extensionRunner).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(initialize).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith({ type: "session_start" });
});
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("restores the persisted per-agent advisor opt-in on cold revival", async () => {
const cwd = makeTempDir("@pi-advisor-revive-");
const advisedFile = await createPersistedSession(cwd, undefined, undefined, "moonshot/k3");
const roleAdvisedFile = await createPersistedSession(cwd, undefined, undefined, "on");
const unadvisedFile = await createPersistedSession(cwd);
const captured: Settings[] = [];
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => {
if (options?.settings) captured.push(options.settings);
return { session: createRevivedSession([]).session } as CreateAgentSessionResult;
});
const factory = createFactory(cwd);
for (const sessionFile of [advisedFile, roleAdvisedFile, unadvisedFile]) {
const ref = createRef(sessionFile);
const reviver = await factory(ref);
if (!reviver) throw new Error("Expected a persisted reviver");
await reviver(ref);
}
const [advised, roleAdvised, unadvised] = captured;
expect(advised.get("advisor.enabled")).toBe(true);
expect(advised.getModelRole("advisor")).toBe("moonshot/k3");
expect(roleAdvised.get("advisor.enabled")).toBe(true);
expect(roleAdvised.getModelRole("advisor")).toBeUndefined();
expect(unadvised.get("advisor.enabled")).toBe(false);
});
it("restores the persisted custom model role before reopening the session", async () => {
const cwd = makeTempDir("@pi-custom-role-revive-");
const sessionFile = await createPersistedSession(cwd, false, "review-fast");
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?.modelPattern).toEqual(["@review-fast", "anthropic/claude-sonnet-4-5"]);
expect(capturedOptions?.modelPatternAuthFallback).toBe("anthropic/claude-sonnet-4-5");
});
it("pins the persisted concrete model when the default role is revived", async () => {
const cwd = makeTempDir("@pi-default-role-revive-");
const sessionFile = await createPersistedSession(cwd, false, "default");
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?.modelPattern).toBe("anthropic/claude-sonnet-4-5");
expect(capturedOptions?.modelPatternAuthFallback).toBe("anthropic/claude-sonnet-4-5");
});
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();
});
});