3acc57de8c
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
337 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|