import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { createAgentSession, defaultResourceLoaderCtor, loaderExtensionsRef, getAgentDir, sessionManagerInMemory, sessionManagerCreate, sessionManagerOpen, settingsManagerCreate, settingsManagerGetSessionDir, } = vi.hoisted(() => ({ createAgentSession: vi.fn(), defaultResourceLoaderCtor: vi.fn(), loaderExtensionsRef: { current: { extensions: [], errors: [], runtime: {} } as { extensions: Array<{ path: string; tools: Map }>; errors: Array<{ path: string; error: string }>; runtime: Record; }, }, getAgentDir: vi.fn(() => "/mock/agent-dir"), sessionManagerInMemory: vi.fn(() => ({ kind: "memory-session-manager" })), sessionManagerCreate: vi.fn(() => ({ kind: "persistent-session-manager" })), sessionManagerOpen: vi.fn(() => ({ kind: "reopened-session-manager" })), settingsManagerGetSessionDir: vi.fn(() => undefined as string | undefined), settingsManagerCreate: vi.fn(() => ({ kind: "settings-manager", getSessionDir: settingsManagerGetSessionDir })), })); vi.mock("@earendil-works/pi-coding-agent", () => ({ createAgentSession, // Mock loader simulates pi-mono: reload() applies additionalExtensionPaths // (an unknown path becomes an error row, mirroring a failed load) and then // runs extensionsOverride over the result. DefaultResourceLoader: class { opts: any; constructor(options: any) { this.opts = options; defaultResourceLoaderCtor(options); } async reload() { // Mirror the real loader: `noExtensions: true` zeros out the discovered set // entirely. Otherwise tests pre-register the extensions a path should // resolve to; an unregistered path simply yields no extension (a failed load). if (this.opts.noExtensions) { loaderExtensionsRef.current = { extensions: [], errors: [], runtime: {} }; return; } if (this.opts.extensionsOverride) { loaderExtensionsRef.current = this.opts.extensionsOverride(loaderExtensionsRef.current); } } getExtensions() { return loaderExtensionsRef.current; } }, getAgentDir, SessionManager: { inMemory: sessionManagerInMemory, create: sessionManagerCreate, open: sessionManagerOpen }, SettingsManager: { create: settingsManagerCreate }, })); vi.mock("../src/agent-types.js", () => ({ BUILTIN_TOOL_NAMES: ["read", "bash", "edit", "write", "grep", "find", "ls"], getConfig: vi.fn(() => ({ displayName: "Explore", description: "Explore", builtinToolNames: ["read"], extensions: false, skills: false, promptMode: "replace", })), getAgentConfig: vi.fn(() => ({ name: "Explore", description: "Explore", builtinToolNames: ["read"], extensions: false, skills: false, systemPrompt: "You are Explore.", promptMode: "replace", inheritContext: false, runInBackground: false, isolated: false, })), getMemoryToolNames: vi.fn(() => []), getReadOnlyMemoryToolNames: vi.fn(() => []), getToolNamesForType: vi.fn(() => ["read"]), })); vi.mock("../src/env.js", () => ({ detectEnv: vi.fn(async () => ({ isGitRepo: false, branch: "", platform: "linux" })), })); vi.mock("../src/prompts.js", () => ({ buildAgentPrompt: vi.fn(() => "system prompt"), })); vi.mock("../src/memory.js", () => ({ buildMemoryBlock: vi.fn(() => ""), buildReadOnlyMemoryBlock: vi.fn(() => ""), })); vi.mock("../src/skill-loader.js", () => ({ preloadSkills: vi.fn(() => []), })); vi.mock("../src/nested-tools.js", () => ({ getMaxSubagentDepth: vi.fn(() => 2), createNestedSubagentTools: vi.fn(() => [ { name: "Agent" }, { name: "get_subagent_result" }, { name: "steer_subagent" }, ]), })); import { extensionCanonicalName, extensionCanonicalNames, getAgentConversation, getDefaultMaxTurns, getGraceTurns, parseExtensionsSpec, parseExtSelectors, resolveDefaultModel, resolveEffectiveMaxTurns, resumeAgent, runAgent, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, } from "../src/agent-runner.js"; /** The most recent session built by `createSession` — read by `lastToolsPassed()`. */ let lastSession: ReturnType["session"] | undefined; function createSession(finalText: string) { const listeners: Array<(event: any) => void> = []; // pi activates only these four by default when no allowlist is given // (agent-session.js `defaultActiveToolNames`). let activeToolNames: string[] = ["read", "bash", "edit", "write"]; const session = { messages: [] as any[], subscribe: vi.fn((listener: (event: any) => void) => { listeners.push(listener); return () => {}; }), prompt: vi.fn(async () => { session.messages.push({ role: "assistant", content: [{ type: "text", text: finalText }], }); }), abort: vi.fn(), steer: vi.fn(), // Stateful, so the active set reflects what the scope installer actually did // and `renarrow`'s no-op guard behaves as it does against real pi. getActiveToolNames: vi.fn(() => activeToolNames), setActiveToolsByName: vi.fn((names: string[]) => { activeToolNames = [...names]; }), // pi's tool REGISTRY (`_toolDefinitions`), read live so tests can simulate an // extension registering after bind by mutating `loaderExtensionsRef`. getAllTools: vi.fn(() => { const opts = createAgentSession.mock.calls[0]?.[0]; return opts ? mockRegistry(opts).map((name) => ({ name })) : []; }), // pi's Agent; `beforeToolCall` is an optional, assignable hook the scope // installer wraps to block out-of-scope calls on turn 1. agent: { beforeToolCall: undefined } as { beforeToolCall?: (context: any, signal?: any) => Promise; }, setSessionName: vi.fn(), bindExtensions: vi.fn(async () => {}), }; lastSession = session; return { session, listeners }; } const ctx = { cwd: "/tmp", model: undefined, modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, getSystemPrompt: vi.fn(() => "parent prompt"), sessionManager: { getBranch: vi.fn(() => []), getSessionFile: vi.fn(() => "/sessions/parent.jsonl"), }, } as any; const pi = {} as any; beforeEach(() => { createAgentSession.mockReset(); defaultResourceLoaderCtor.mockClear(); getAgentDir.mockClear(); sessionManagerInMemory.mockClear(); sessionManagerCreate.mockClear(); sessionManagerOpen.mockClear(); // The setting is process-global; a test that flips it must not leak the // flip into the next one. setRememberAgents(true); settingsManagerGetSessionDir.mockReset(); settingsManagerGetSessionDir.mockReturnValue(undefined); settingsManagerCreate.mockClear(); vi.mocked(createNestedSubagentTools).mockClear(); loaderExtensionsRef.current = { extensions: [], errors: [], runtime: {} }; lastSession = undefined; }); describe("agent-runner final output capture", () => { it("returns the final assistant text even when no text_delta events were streamed", async () => { const { session } = createSession("LOCKED"); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "Say LOCKED", { pi }); expect(result.responseText).toBe("LOCKED"); }); it("binds extensions before prompting", async () => { const { session } = createSession("BOUND"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "Say BOUND", { pi }); expect(session.bindExtensions).toHaveBeenCalledTimes(1); expect(session.bindExtensions).toHaveBeenCalledWith( expect.objectContaining({ onError: expect.any(Function) }), ); const bindOrder = session.bindExtensions.mock.invocationCallOrder[0]; const promptOrder = session.prompt.mock.invocationCallOrder[0]; expect(bindOrder).toBeLessThan(promptOrder); }); it("passes effective cwd and agentDir to the loader and settings manager", async () => { const { session } = createSession("CONFIGURED"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "Say CONFIGURED", { pi, cwd: "/tmp/worktree" }); expect(getAgentDir).toHaveBeenCalledTimes(1); expect(defaultResourceLoaderCtor).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/worktree", agentDir: "/mock/agent-dir", })); expect(settingsManagerCreate).toHaveBeenCalledWith("/tmp/worktree", "/mock/agent-dir"); // Same claim as before `rememberAgents` flipped the default — the effective // cwd reaches the session manager — now via the persistent constructor. expect(sessionManagerCreate).toHaveBeenCalledWith("/tmp/worktree", undefined, expect.anything()); expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/worktree", agentDir: "/mock/agent-dir", })); }); it("forwards worktreeBase to the prompt builder, and omits it otherwise", async () => { const { buildAgentPrompt } = await import("../src/prompts.js"); const { session } = createSession("ISOLATED"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "Say ISOLATED", { pi, cwd: "/wt/copy", worktreeBase: "/repo" }); expect(vi.mocked(buildAgentPrompt).mock.lastCall![4]).toMatchObject({ worktreeBase: "/repo" }); await runAgent(ctx, "Explore", "Say ISOLATED", { pi }); expect(vi.mocked(buildAgentPrompt).mock.lastCall![4]).not.toHaveProperty("worktreeBase"); }); it("passes the parent model runtime while retaining the legacy model registry", async () => { const { session } = createSession("AUTHENTICATED"); createAgentSession.mockResolvedValue({ session }); const modelRuntime = { getAuth: vi.fn(), hasConfiguredAuth: vi.fn() }; const context = { ...ctx, modelRegistry: { ...ctx.modelRegistry, runtime: modelRuntime }, }; await runAgent(context, "Explore", "Say AUTHENTICATED", { pi }); expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ modelRegistry: context.modelRegistry, modelRuntime, })); }); it("omits modelRuntime when the legacy registry does not expose one", async () => { const { session } = createSession("LEGACY"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "Say LEGACY", { pi }); expect(createAgentSession.mock.calls[0][0]).not.toHaveProperty("modelRuntime"); }); it("suppresses AGENTS.md/CLAUDE.md/APPEND_SYSTEM.md for subagents", async () => { const { session } = createSession("ISOLATED"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "Say ISOLATED", { pi }); // noContextFiles skips AGENTS.md/CLAUDE.md at the loader source; // appendSystemPromptOverride suppresses APPEND_SYSTEM.md (no flag equivalent). expect(defaultResourceLoaderCtor).toHaveBeenCalledWith( expect.objectContaining({ noContextFiles: true, appendSystemPromptOverride: expect.any(Function), }), ); // The override returns an empty list so any loaded sources are discarded. const ctorArgs = defaultResourceLoaderCtor.mock.calls[0][0]; expect(ctorArgs.appendSystemPromptOverride(["would-be-loaded"])).toEqual([]); }); it("resumeAgent also falls back to the final assistant message text", async () => { const { session } = createSession("RESUMED"); const result = await resumeAgent(session as any, "Continue"); expect(result.text).toBe("RESUMED"); expect(result.failure).toBeUndefined(); }); it("sets the agent name as session name before binding extensions", async () => { const { session } = createSession("NAMED"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(session.setSessionName).toHaveBeenCalledWith("Explore"); const setOrder = session.setSessionName.mock.invocationCallOrder[0]; const bindOrder = session.bindExtensions.mock.invocationCallOrder[0]; expect(setOrder).toBeLessThan(bindOrder); }); it("registers the child with the root permission authority before binding extensions", async () => { const order: string[] = []; const { session } = createSession("REGISTERED"); Object.assign(session, { sessionManager: { getSessionId: () => "child-session" }, }); session.bindExtensions.mockImplementation(async () => { order.push("bind"); }); createAgentSession.mockResolvedValue({ session }); const bridgePi = { events: { emit: vi.fn((channel: string) => { order.push(channel); }), }, } as any; const rootCtx = { ...ctx, sessionManager: { ...ctx.sessionManager, getSessionId: () => "root-session", }, }; await runAgent(rootCtx, "Explore", "go", { pi: bridgePi }); expect(order.slice(0, 2)).toEqual([ "subagents:child:session-created", "bind", ]); expect(bridgePi.events.emit).toHaveBeenCalledWith( "subagents:child:session-created", { sessionId: "child-session", parentSessionId: "root-session" }, ); const loaderOptions = defaultResourceLoaderCtor.mock.lastCall![0]; expect(loaderOptions.systemPromptOverride()).toContain( '', ); }); it("uses the agent definition filename as the per-agent permission key", async () => { const { getAgentConfig } = await import("../src/agent-types.js"); vi.mocked(getAgentConfig).mockReturnValueOnce({ name: "declared-agent-name", description: "Agent", builtinToolNames: ["read"], extensions: false, skills: false, systemPrompt: "Agent prompt", promptMode: "replace", sourcePath: "/repo/.pi/agents/policy-file-key.md", }); const { session } = createSession("IDENTIFIED"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "declared-agent-name", "go", { pi }); const loaderOptions = defaultResourceLoaderCtor.mock.lastCall![0]; expect(loaderOptions.systemPromptOverride()).toContain( '', ); }); it("closes permission registration when extension binding fails", async () => { const order: string[] = []; const { session } = createSession("FAILED"); Object.assign(session, { sessionManager: { getSessionId: () => "child-session" }, dispose: vi.fn(() => { order.push("session-disposed"); }), }); session.bindExtensions.mockRejectedValue(new Error("bind failed")); createAgentSession.mockResolvedValue({ session }); const bridgePi = { events: { emit: vi.fn((channel: string) => { order.push(channel); }) }, } as any; const rootCtx = { ...ctx, sessionManager: { ...ctx.sessionManager, getSessionId: () => "root-session" }, }; await expect(runAgent(rootCtx, "Explore", "go", { pi: bridgePi })) .rejects.toThrow("bind failed"); expect(order).toEqual([ "subagents:child:session-created", "session-disposed", "subagents:child:disposed", ]); }); it("suffixes the session name with a short agentId so parallel spawns are distinguishable", async () => { const { session } = createSession("NAMED"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, agentId: "a1b2c3d4e5f6" }); expect(session.setSessionName).toHaveBeenCalledWith("Explore#a1b2c3d4"); }); }); // #144 — a failed FINAL assistant turn (stopReason "error") must surface as // `failure`; how the turn STOPPED decides, never whether it produced text. describe("agent-runner failed-final-turn detection (#144)", () => { /** Session whose prompt() appends the given messages to history. */ function sessionEnding(...messages: any[]) { const { session } = createSession(""); session.prompt = vi.fn(async () => { session.messages.push(...messages); }) as any; return session; } const errorFinal = { role: "assistant", content: [], stopReason: "error", errorMessage: "retries exhausted: 529 overloaded", }; it("flags a run whose final turn is an empty provider error", async () => { const session = sessionEnding(errorFinal); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "go", { pi }); expect(result.failure).toBe("retries exhausted: 529 overloaded"); }); it("flags the failure even when an EARLIER turn produced text (no masking)", async () => { const session = sessionEnding( { role: "assistant", content: [{ type: "text", text: "partial progress" }] }, { role: "toolResult", content: [] }, errorFinal, ); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "go", { pi }); expect(result.failure).toBe("retries exhausted: 529 overloaded"); // The earlier text stays available as context — status honesty, not data loss. expect(result.responseText).toBe("partial progress"); }); it("flags a provider error that left partial text in the SAME final message", async () => { const session = sessionEnding({ role: "assistant", content: [{ type: "text", text: "truncated answ" }], stopReason: "error", errorMessage: "stream ended before message_stop", }); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "go", { pi }); expect(result.failure).toBe("stream ended before message_stop"); expect(result.responseText).toBe("truncated answ"); }); it("flags a run whose final turn hit the token limit with no text (#144 residual)", async () => { // stopReason "length" with empty content is a silent max-token death — it // reproduces the #144 "completed with No output." symptom, so it must fail. const session = sessionEnding({ role: "assistant", content: [], stopReason: "length" }); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "go", { pi }); expect(result.failure).toBe("run hit the output token limit before producing any text"); }); it("does NOT flag a length stop that produced text (truncated answer completes)", async () => { const session = sessionEnding({ role: "assistant", content: [{ type: "text", text: "truncated but useful answer" }], stopReason: "length", }); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "go", { pi }); expect(result.failure).toBeUndefined(); expect(result.responseText).toBe("truncated but useful answer"); }); it("does NOT flag an empty final turn that stopped cleanly (no false failures)", async () => { const session = sessionEnding( { role: "assistant", content: [{ type: "text", text: "did the work" }] }, { role: "toolResult", content: [] }, { role: "assistant", content: [], stopReason: "stop" }, ); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "go", { pi }); expect(result.failure).toBeUndefined(); expect(result.responseText).toBe("did the work"); // walk-back fallback preserved }); it("resumeAgent applies the same rule", async () => { const { session } = createSession(""); session.prompt = vi.fn(async () => { session.messages.push(errorFinal); }) as any; const result = await resumeAgent(session as any, "Continue"); expect(result.failure).toBe("retries exhausted: 529 overloaded"); }); it("resume whose new turn fails empty does NOT return the previous turn's answer (#144)", async () => { // The session already carries a completed prior turn; the resume prompt then // fails empty. The walk-back must be bounded to this resume — result "". const { session } = createSession(""); session.messages.push( { role: "user", content: "first question" }, { role: "assistant", content: [{ type: "text", text: "PREVIOUS ANSWER" }], stopReason: "stop" }, ); session.prompt = vi.fn(async () => { session.messages.push({ role: "user", content: "follow-up" }, errorFinal); }) as any; const result = await resumeAgent(session as any, "follow-up"); expect(result.failure).toBe("retries exhausted: 529 overloaded"); expect(result.text).toBe(""); // NOT "PREVIOUS ANSWER" }); it("resume that produces partial text before failing returns only THIS resume's text", async () => { const { session } = createSession(""); session.messages.push( { role: "assistant", content: [{ type: "text", text: "PREVIOUS ANSWER" }], stopReason: "stop" }, ); session.prompt = vi.fn(async () => { session.messages.push( { role: "assistant", content: [{ type: "text", text: "new partial" }] }, { role: "toolResult", content: [] }, errorFinal, ); }) as any; const result = await resumeAgent(session as any, "go"); expect(result.failure).toBe("retries exhausted: 529 overloaded"); expect(result.text).toBe("new partial"); // this resume's progress, not the prior answer }); it("collector: a toolResult/user message_start no longer wipes collected assistant text", async () => { const { session, listeners } = createSession(""); createAgentSession.mockResolvedValue({ session }); session.prompt = vi.fn(async () => { for (const l of listeners) { l({ type: "message_start", message: { role: "assistant" } }); l({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "STREAMED" } }); // pi emits message_start for tool results and queued user messages too. l({ type: "message_start", message: { role: "toolResult" } }); l({ type: "message_start", message: { role: "user" } }); } }) as any; const result = await runAgent(ctx, "Explore", "go", { pi }); expect(result.responseText).toBe("STREAMED"); }); }); // ─── message_end → onAssistantUsage wiring (issue #38) ───────────────── // Both runAgent and resumeAgent dispatch usage to the caller via this // callback. The callback feeds the AgentRecord lifetime accumulator, which // is the source of truth for total tokens (survives compaction). describe("agent-runner usage callback wiring", () => { function emitMessageEnd(listeners: Array<(e: any) => void>, usage: any) { const event = { type: "message_end", message: { role: "assistant", usage } }; for (const l of listeners) l(event); } it("runAgent forwards full usage from message_end events", async () => { const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const seen: Array<{ input: number; output: number; cacheWrite: number; cost?: number }> = []; session.prompt = vi.fn(async () => { // Two assistant messages over the run emitMessageEnd(listeners, { input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: { total: 0.002 } }); emitMessageEnd(listeners, { input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: { total: 0.004 } }); session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); }); await runAgent(ctx, "Explore", "go", { pi, onAssistantUsage: (u) => seen.push(u), }); // cacheRead rides along even though the display total drops it (#38): the // prefix is genuinely re-billed per call, and the parent-session report needs it. expect(seen).toEqual([ { input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.002 }, { input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: 0.004 }, ]); }); it("runAgent normalizes partial usage objects to 0 for missing fields", async () => { const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const seen: any[] = []; session.prompt = vi.fn(async () => { emitMessageEnd(listeners, { input: 50 }); // output, cacheWrite, cacheRead, cost missing session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); }); await runAgent(ctx, "Explore", "go", { pi, onAssistantUsage: (u) => seen.push(u), }); // An unpriced model reports no `cost` object at all — 0, never undefined, // so accumulators never have to special-case it. expect(seen).toEqual([{ input: 50, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }]); }); it("runAgent skips the callback when message_end has no usage field", async () => { const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const cb = vi.fn(); session.prompt = vi.fn(async () => { emitMessageEnd(listeners, undefined); session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); }); await runAgent(ctx, "Explore", "go", { pi, onAssistantUsage: cb }); expect(cb).not.toHaveBeenCalled(); }); it("resumeAgent forwards usage on message_end the same way", async () => { const { session, listeners } = createSession("RESUMED"); const seen: any[] = []; session.prompt = vi.fn(async () => { emitMessageEnd(listeners, { input: 10, output: 20, cacheWrite: 5, cacheRead: 90, cost: { total: 0.001 } }); session.messages.push({ role: "assistant", content: [{ type: "text", text: "RESUMED" }] }); }); await resumeAgent(session as any, "continue", { onAssistantUsage: (u) => seen.push(u), }); expect(seen).toEqual([{ input: 10, output: 20, cacheWrite: 5, cacheRead: 90, cost: 0.001 }]); }); it("forwards compaction_end events to onCompaction (only when not aborted)", async () => { const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const seen: any[] = []; session.prompt = vi.fn(async () => { // Successful compaction — should fire for (const l of listeners) l({ type: "compaction_end", aborted: false, reason: "threshold", result: { tokensBefore: 12345 }, }); // Aborted compaction — should NOT fire for (const l of listeners) l({ type: "compaction_end", aborted: true, reason: "manual", result: { tokensBefore: 99999 }, }); session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); }); await runAgent(ctx, "Explore", "go", { pi, onCompaction: (info) => seen.push(info), }); expect(seen).toEqual([{ reason: "threshold", tokensBefore: 12345 }]); }); }); // getAgentConversation renders the subagent transcript shown in the /agents // inspect overlay. Pure function over session.messages — no mocks needed // beyond a literal-object session. describe("getAgentConversation", () => { function fakeSession(messages: unknown[]) { return { messages } as never; } it("returns an empty string for a session with no messages", () => { expect(getAgentConversation(fakeSession([]))).toBe(""); }); it("formats a user-then-assistant exchange with role-prefixed lines joined by blank lines", () => { const out = getAgentConversation( fakeSession([ { role: "user", content: "hi" }, { role: "assistant", content: [{ type: "text", text: "hello" }] }, ]), ); expect(out).toBe("[User]: hi\n\n[Assistant]: hello"); }); it("accepts user content as content-blocks (not just strings)", () => { const out = getAgentConversation( fakeSession([{ role: "user", content: [{ type: "text", text: "from blocks" }] }]), ); expect(out).toBe("[User]: from blocks"); }); it("emits a [Tool Calls] block listing each toolCall by name or toolName, falling back to 'unknown'", () => { const out = getAgentConversation( fakeSession([ { role: "assistant", content: [ { type: "text", text: "calling tools" }, { type: "toolCall", name: "search" }, { type: "toolCall", toolName: "edit" }, { type: "toolCall" }, ], }, ]), ); expect(out).toContain("[Assistant]: calling tools"); expect(out).toContain("[Tool Calls]:\n Tool: search\n Tool: edit\n Tool: unknown"); }); it("truncates toolResult content beyond 200 chars and tags it with the tool name", () => { const longText = "x".repeat(300); const out = getAgentConversation( fakeSession([ { role: "toolResult", toolName: "bash", content: [{ type: "text", text: longText }], }, ]), ); expect(out.startsWith("[Tool Result (bash)]: ")).toBe(true); expect(out.endsWith("...")).toBe(true); // prefix + 200 chars + "..." expect(out.length).toBe("[Tool Result (bash)]: ".length + 200 + 3); }); it("emits [Tool Calls] but no [Assistant] when the assistant only made tool calls", () => { const out = getAgentConversation( fakeSession([ { role: "user", content: "do it" }, { role: "assistant", content: [{ type: "toolCall", name: "search" }] }, ]), ); expect(out).toContain("[User]: do it"); expect(out).not.toContain("[Assistant]:"); expect(out).toContain("[Tool Calls]:\n Tool: search"); }); }); // ─── tool scoping (issues #47, #125) ───────────────────────────────────── // runAgent scopes a subagent's tools in one of two ways: // • Static allowlist (`tools:`) — ONLY for noExtensions/isolated. Nothing can // register asynchronously there, so pi-mono's `allowedToolNames` gating both // registration and the initial active set is exactly right. // • Live scoping — whenever extensions load. `tools:` is left unset so pi's // live `isAllowedTool` admits tools whenever they register (pi-mcp registers // on session_start, context-mode on before_agent_start); `excludeTools:` // carries the name-stable permanent scope; and `installExtensionToolScope` // narrows the ACTIVE set for `ext:` selectors, re-deriving on every turn_end // so late arrivals are judged too. // `lastToolsPassed()` returns what the LLM can actually call under either shape. import { getAgentConfig, getConfig, getToolNamesForType, } from "../src/agent-types.js"; import { createNestedSubagentTools } from "../src/nested-tools.js"; const BUILTINS_7 = ["read", "bash", "edit", "write", "grep", "find", "ls"]; function makeAgentConfig(overrides: Record = {}) { return { name: "test-agent", description: "Test", builtinToolNames: BUILTINS_7, extensions: true as boolean | string[], skills: false as boolean | string[], systemPrompt: "Test.", promptMode: "replace" as const, inheritContext: false, runInBackground: false, isolated: false, ...overrides, }; } function makeConfig(overrides: Record = {}) { return { displayName: "test-agent", description: "Test", builtinToolNames: BUILTINS_7, extensions: true as boolean | string[], skills: false as boolean | string[], promptMode: "replace" as const, ...overrides, }; } /** Register extensions for the mock loader, keyed by extension path → tool names. */ function withExtensions(spec: Record) { loaderExtensionsRef.current = { extensions: Object.entries(spec).map(([path, tools]) => ({ path, tools: new Map(tools.map((n) => [n, {}])), })), errors: [], runtime: {}, }; } /** * The tool REGISTRY pi would build for a given `createAgentSession` call — * mirroring `_refreshToolRegistry`'s `isAllowedTool`: * - `tools:` set → the allowlist gates the registry (nothing else registers). * - `tools:` unset → every built-in plus every loaded extension tool, minus * `excludeTools`, and it keeps growing as extensions register later. * Read live from `loaderExtensionsRef`, so a test can simulate late registration. */ function mockRegistry(opts: Record): string[] { const excluded = new Set(opts.excludeTools ?? []); // pi registers customTools into the same registry, subject to the same gate. const customNames: string[] = (opts.customTools ?? []).map((t: any) => t.name); const all: string[] = opts.tools ? [...opts.tools, ...customNames] : [ ...BUILTINS_7, ...loaderExtensionsRef.current.extensions.flatMap((e) => [...e.tools.keys()]), ...customNames, ]; return [...new Set(all)].filter((t) => !excluded.has(t)); } /** * What the LLM can actually call. * * Under the static allowlist (`noExtensions`/`isolated`) that is `tools:` verbatim. * Otherwise the registry is scoped by `excludeTools` and then narrowed to the ACTIVE * set by `installExtensionToolScope` — so the active set is the real answer, and * asserting on it means these tests exercise the narrowing rather than a * reimplementation of pi's gate. */ function lastToolsPassed(): string[] { const opts = createAgentSession.mock.calls[0][0]; if (opts.tools) return opts.tools; return lastSession?.getActiveToolNames() ?? []; } function lastLoaderOpts(): Record { return defaultResourceLoaderCtor.mock.calls[0][0]; } describe("agent-runner session persistence", () => { it("persists by default, so a handle can reopen the conversation later", async () => { // `rememberAgents` defaults on: the session file is the only thing an // evicted agent leaves behind, so without it `@explore` after cleanup // could only ever start a fresh agent. vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(sessionManagerInMemory).not.toHaveBeenCalled(); expect(sessionManagerCreate).toHaveBeenCalled(); expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ sessionManager: { kind: "persistent-session-manager" }, })); }); it("keeps the session in memory when rememberAgents is off", async () => { setRememberAgents(false); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(sessionManagerInMemory).toHaveBeenCalledWith("/tmp"); expect(sessionManagerCreate).not.toHaveBeenCalled(); }); it("lets frontmatter override rememberAgents in both directions", async () => { // The setting is only a default. An agent that declares itself ephemeral // stays ephemeral with the setting on... vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: false })); createAgentSession.mockResolvedValue({ session: createSession("OK").session }); await runAgent(ctx, "Explore", "go", { pi }); expect(sessionManagerInMemory).toHaveBeenCalled(); expect(sessionManagerCreate).not.toHaveBeenCalled(); // ...and one that declares itself persistent still persists with it off. sessionManagerInMemory.mockClear(); setRememberAgents(false); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: true })); createAgentSession.mockResolvedValue({ session: createSession("OK").session }); await runAgent(ctx, "Explore", "go", { pi }); expect(sessionManagerCreate).toHaveBeenCalled(); expect(sessionManagerInMemory).not.toHaveBeenCalled(); }); it("leaves a nested child in memory, since nothing can address it later", async () => { // The default exists so `@handle` can reopen a conversation. A nested agent // never gets a handle, so its transcript would be unreachable by anything — // pure disk and /resume clutter. vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); createAgentSession.mockResolvedValue({ session: createSession("OK").session }); await runAgent(ctx, "Explore", "go", { pi, nested: true }); expect(sessionManagerInMemory).toHaveBeenCalled(); expect(sessionManagerCreate).not.toHaveBeenCalled(); }); it("still persists a nested child that asks for it in frontmatter", async () => { vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: true })); createAgentSession.mockResolvedValue({ session: createSession("OK").session }); await runAgent(ctx, "Explore", "go", { pi, nested: true }); expect(sessionManagerCreate).toHaveBeenCalled(); expect(sessionManagerInMemory).not.toHaveBeenCalled(); }); it("reopens an existing session file instead of starting a new conversation", async () => { vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "carry on", { pi, resumeSessionFile: "/sessions/explore.jsonl" }); // Neither create nor inMemory: both would start an empty conversation, and // the point of a resume is that the history is already there. expect(sessionManagerCreate).not.toHaveBeenCalled(); expect(sessionManagerInMemory).not.toHaveBeenCalled(); expect(sessionManagerOpen).toHaveBeenCalledWith("/sessions/explore.jsonl", "/normal/pi/sessions"); }); it("uses pi's normal persistent session location and links to the parent session", async () => { vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: true })); settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(sessionManagerInMemory).not.toHaveBeenCalled(); expect(sessionManagerCreate).toHaveBeenCalledWith( "/tmp", "/normal/pi/sessions", { parentSession: "/sessions/parent.jsonl" }, ); expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ sessionManager: { kind: "persistent-session-manager" }, })); }); it("uses a frontmatter sessionDir when persistSession is true and sessionDir is configured", async () => { vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ persistSession: true, sessionDir: ".seams/pi-sessions/seam-plan-reviewer" }), ); settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, cwd: "/repo" }); expect(sessionManagerCreate).toHaveBeenCalledWith( "/repo", "/repo/.seams/pi-sessions/seam-plan-reviewer", { parentSession: "/sessions/parent.jsonl" }, ); }); }); describe("agent-runner master tool allowlist", () => { it("extensions: true with extension tools — all 7 built-ins plus extension tools land in the allowlist", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/mcp.ts": ["mcp", "mcp_call"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); // Order is not semantically meaningful (pi-mono dedupes via Set); // assert membership and exact size instead. const tools = lastToolsPassed(); expect(tools).toHaveLength(BUILTINS_7.length + 2); expect(new Set(tools)).toEqual(new Set([...BUILTINS_7, "mcp", "mcp_call"])); }); it("enumerates tools across multiple loaded extensions", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/a.ts": ["tool_a"], "/ext/b.ts": ["tool_b"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).toContain("tool_a"); expect(tools).toContain("tool_b"); }); it("disallowedTools removes both built-ins and extension tools", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: true, disallowedTools: ["bash", "mcp"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/mcp.ts": ["mcp", "mcp_call"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).not.toContain("bash"); expect(tools).not.toContain("mcp"); expect(tools).toContain("mcp_call"); expect(tools).toContain("read"); }); it("EXCLUDED_TOOL_NAMES never reach the allowlist even if an extension registers them", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/evil.ts": ["Agent", "get_subagent_result", "steer_subagent", "ok_ext"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).not.toContain("Agent"); expect(tools).not.toContain("get_subagent_result"); expect(tools).not.toContain("steer_subagent"); expect(tools).toContain("ok_ext"); }); it("keeps nested tools unavailable without explicit opt-in", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: false })); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, }); expect(createNestedSubagentTools).not.toHaveBeenCalled(); expect(lastToolsPassed()).not.toContain("Agent"); expect(createAgentSession.mock.calls[0][0].customTools).toEqual([]); }); it("injects scoped nested tools for an opted-in non-isolated agent", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, allowedSubagents: ["scout"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const manager = {} as any; await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager, parentAgentId: "parent", depth: 1, maxSubagentDepth: 3 }, }); expect(createNestedSubagentTools).toHaveBeenCalledWith(expect.objectContaining({ manager, parentAgentId: "parent", depth: 1, maxSubagentDepth: 3, allowedSubagents: ["scout"], configCwd: "/tmp", })); expect(lastToolsPassed()).toEqual(expect.arrayContaining([ "Agent", "get_subagent_result", "steer_subagent", ])); expect(createAgentSession.mock.calls[0][0].customTools).toHaveLength(3); }); it("keeps opt-in nested tools active UNDER EXTENSIONS despite the EXCLUDED-name collision", async () => { // The nested tool names ARE EXCLUDED_TOOL_NAMES. Under the denylist mechanism // they must (a) not be excluded from the registry, and (b) survive the live // installExtensionToolScope renarrow that strips EXCLUDED_TOOL_NAMES. vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: true, allowedSubagents: "all" }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/ok.ts": ["ok_ext"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, }); const opts = createAgentSession.mock.calls[0][0]; // (a) not denied at the registry gate, and passed as customTools. expect(opts.excludeTools ?? []).not.toContain("Agent"); expect(opts.customTools).toHaveLength(3); // (b) survive the active-set renarrow alongside a real extension tool. const active = lastToolsPassed(); expect(active).toEqual(expect.arrayContaining(["Agent", "get_subagent_result", "steer_subagent"])); expect(active).toContain("ok_ext"); }); // Opt-in nested tools are re-admitted at three separate places because their // names collide with EXCLUDED_TOOL_NAMES. Every one of those re-admissions // carries a `disallowedTools` check, and no test set `disallowed_tools` and // `allowed_subagents` together — so dropping any of the three checks would // hand `Agent` back to an agent whose author explicitly denied it, with the // suite still green. describe("disallowed_tools beats opt-in nested delegation", () => { it("denies the tool under the isolated static allowlist", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, allowedSubagents: "all", disallowedTools: ["Agent"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, }); const tools = lastToolsPassed(); expect(tools).not.toContain("Agent"); // The siblings the agent did NOT deny stay available. expect(tools).toEqual(expect.arrayContaining(["get_subagent_result", "steer_subagent"])); }); it("denies the tool at the registry gate under extensions", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: true, allowedSubagents: "all", disallowedTools: ["Agent"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/ok.ts": ["ok_ext"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, }); expect(createAgentSession.mock.calls[0][0].excludeTools ?? []).toContain("Agent"); expect(lastToolsPassed()).not.toContain("Agent"); }); it("blocks the tool at runtime even though it was injected as a customTool", async () => { // The registry gate and the active set are static snapshots; this is the // live gate that judges a call as it happens. A nested tool is handed to // the session as a customTool, so this is the last line of defense. vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: true, allowedSubagents: "all", disallowedTools: ["Agent"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/ok.ts": ["ok_ext"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, }); await expect( session.agent.beforeToolCall?.({ toolCall: { name: "Agent" } }), ).resolves.toMatchObject({ block: true }); // ...while a nested tool that was NOT denied still passes the same gate. await expect( session.agent.beforeToolCall?.({ toolCall: { name: "steer_subagent" } }), ).resolves.not.toMatchObject({ block: true }); }); it("a partial denial does not take down the whole nested set", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, allowedSubagents: ["scout"], disallowedTools: ["get_subagent_result"], }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1, maxSubagentDepth: 3 }, }); const tools = lastToolsPassed(); expect(tools).not.toContain("get_subagent_result"); expect(tools).toContain("Agent"); expect(tools).toContain("steer_subagent"); }); }); it("still strips the orchestration tools under extensions when nesting is OFF", async () => { // Guards the negative: without allowed_subagents the EXCLUDED names stay denied // and inactive even though an extension registers tools with those very names. vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/evil.ts": ["Agent", "get_subagent_result", "steer_subagent", "ok_ext"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const active = lastToolsPassed(); expect(active).not.toContain("Agent"); expect(active).not.toContain("get_subagent_result"); expect(active).not.toContain("steer_subagent"); expect(active).toContain("ok_ext"); }); it("suppresses nested tools in isolated mode even when opted in", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, allowedSubagents: "all" }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, isolated: true, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, }); expect(createNestedSubagentTools).not.toHaveBeenCalled(); expect(lastToolsPassed()).not.toContain("Agent"); }); it("passes the inherited depth cap through to the nested tools", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, allowedSubagents: "all" }), ); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1, maxSubagentDepth: 3 }, }); expect(createNestedSubagentTools).toHaveBeenLastCalledWith(expect.objectContaining({ maxSubagentDepth: 3 })); }); it("injects no nested tools once the effective cap is reached", async () => { // At the cap the agent can never spawn — and so can never own a child to // fetch from or steer. Three always-erroring tools would just cost context. vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, allowedSubagents: "all" }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1, maxSubagentDepth: 1 }, }); expect(createNestedSubagentTools).not.toHaveBeenCalled(); expect(lastToolsPassed()).not.toContain("Agent"); }); it("extensions: false with disallowedTools — denylist applies to built-ins", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, disallowedTools: ["bash"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).not.toContain("bash"); expect(tools).toEqual(BUILTINS_7.filter((t) => t !== "bash")); }); it("dynamic mode: leaves the allowlist unset, denies via excludeTools, activates post-bind", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: true, disallowedTools: ["bash"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/mcp.ts": ["mcp"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); // Allowlist unset so async tools (e.g. MCP on session_start) can register; // scope is a denylist of this extension's own tools plus `disallowedTools`. const opts = createAgentSession.mock.calls[0][0]; expect(opts.tools).toBeUndefined(); expect(new Set(opts.excludeTools)).toEqual( new Set([...Object.values(SUBAGENT_TOOL_NAMES), "bash"]), ); // The active set is repaired AFTER bindExtensions (tools may register during // session_start), activating the full allowed registry — the extension tool // included, the denied built-in excluded. expect(session.setActiveToolsByName).toHaveBeenCalledTimes(1); const setOrder = session.setActiveToolsByName.mock.invocationCallOrder[0]; const bindOrder = session.bindExtensions.mock.invocationCallOrder[0]; expect(setOrder).toBeGreaterThan(bindOrder); const activated = new Set(session.setActiveToolsByName.mock.calls[0][0]); expect(activated.has("mcp")).toBe(true); expect(activated.has("read")).toBe(true); expect(activated.has("bash")).toBe(false); }); }); // ─── asynchronously-registered extension tools (issue #125) ────────────── // pi-mcp calls registerTool from `session_start`, context-mode from // `before_agent_start` — both long after loader.reload(). `registerTool` writes // into the live `extension.tools` map, which is what these tests simulate. describe("agent-runner async extension tool registration", () => { /** Simulate `pi.registerTool` on an already-loaded extension. */ function registerLate(extPath: string, toolName: string) { const ext = loaderExtensionsRef.current.extensions.find((e) => e.path === extPath); if (!ext) throw new Error(`no loaded extension at ${extPath}`); ext.tools.set(toolName, {}); } function setup(o: { builtinToolNames?: string[]; extSelectors?: string[] } = {}) { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: true, extSelectors: o.extSelectors }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(o.builtinToolNames ?? ["read"]); } it("a tool registered during session_start reaches the active set", async () => { setup(); withExtensions({ "/ext/mcp.ts": [] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); // pi-mcp's real shape: nothing at load, tools appear when bindExtensions // fires session_start and the MCP servers connect. session.bindExtensions.mockImplementation(async () => { registerLate("/ext/mcp.ts", "mcp_search"); }); await runAgent(ctx, "Explore", "go", { pi }); expect(lastToolsPassed()).toContain("mcp_search"); }); it("a tool registered after bind is picked up on the next turn_end", async () => { setup(); withExtensions({ "/ext/mcp.ts": [] }); const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(session.getActiveToolNames()).not.toContain("mcp_search"); // A lazy MCP server connects mid-conversation (context-mode registers at // before_agent_start, i.e. after runAgent already installed the scope). registerLate("/ext/mcp.ts", "mcp_search"); for (const l of listeners) l({ type: "turn_end" }); expect(session.getActiveToolNames()).toContain("mcp_search"); }); it("ext: admits a late tool from the selected extension but not from others", async () => { setup({ extSelectors: ["ext:foo"] }); withExtensions({ "/ext/foo.ts": [], "/ext/bar.ts": [] }); const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); registerLate("/ext/foo.ts", "foo_late"); registerLate("/ext/bar.ts", "bar_late"); for (const l of listeners) l({ type: "turn_end" }); const active = session.getActiveToolNames(); expect(active).toContain("foo_late"); // This is the case the static allowlist could never express: bar_late did // not exist at construction, so it could not have been denied by name. expect(active).not.toContain("bar_late"); }); it("ext:foo/bar narrowing still applies to late-registered siblings", async () => { setup({ extSelectors: ["ext:foo/keep_me"] }); withExtensions({ "/ext/foo.ts": [] }); const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); registerLate("/ext/foo.ts", "keep_me"); registerLate("/ext/foo.ts", "drop_me"); for (const l of listeners) l({ type: "turn_end" }); expect(session.getActiveToolNames()).toContain("keep_me"); expect(session.getActiveToolNames()).not.toContain("drop_me"); }); it("beforeToolCall blocks an out-of-scope tool and delegates otherwise", async () => { // Turn 1 cannot be narrowed — before_agent_start fires inside prompt() and // may widen the set after the turn's tools are snapshotted — so a call-time // guard is the only correct enforcement there. setup({ extSelectors: ["ext:foo"] }); withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/bar.ts": ["bar_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); await expect( session.agent.beforeToolCall?.({ toolCall: { name: "bar_tool" } }), ).resolves.toMatchObject({ block: true }); await expect( session.agent.beforeToolCall?.({ toolCall: { name: "foo_tool" } }), ).resolves.toBeUndefined(); }); it("beforeToolCall preserves a hook pi installed before us", async () => { setup(); withExtensions({ "/ext/foo.ts": ["foo_tool"] }); const { session } = createSession("OK"); const prior = vi.fn(async () => undefined); session.agent.beforeToolCall = prior; createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); await session.agent.beforeToolCall?.({ toolCall: { name: "foo_tool" } }); expect(prior).toHaveBeenCalledTimes(1); }); it("scope outlives runAgent so resumed turns stay narrowed", async () => { // runAgent tears down its own turn subscription in `finally`; the scope // hooks must NOT be torn down with it, or resume/steer would drift. setup({ extSelectors: ["ext:foo"] }); withExtensions({ "/ext/foo.ts": [], "/ext/bar.ts": [] }); const { session, listeners } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); await resumeAgent(session as any, "keep going"); registerLate("/ext/foo.ts", "foo_late"); registerLate("/ext/bar.ts", "bar_late"); for (const l of listeners) l({ type: "turn_end" }); expect(session.getActiveToolNames()).toContain("foo_late"); expect(session.getActiveToolNames()).not.toContain("bar_late"); await expect( session.agent.beforeToolCall?.({ toolCall: { name: "bar_late" } }), ).resolves.toMatchObject({ block: true }); }); it("isolated keeps the static allowlist — no live scoping installed", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: false })); vi.mocked(getToolNamesForType).mockReturnValueOnce(["read"]); withExtensions({ "/ext/foo.ts": ["foo_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, isolated: true }); // A hard registry gate is the right boundary here: nothing can register // asynchronously, so there is no active-set narrowing to maintain. expect(createAgentSession.mock.calls[0][0].tools).toEqual(["read"]); expect(session.setActiveToolsByName).not.toHaveBeenCalled(); expect(session.agent.beforeToolCall).toBeUndefined(); }); }); // ─── extensions: string[] as a loader-level extension filter ──────────── // An array entry is a bare name (filters default-discovered extensions), // a path (loads that extension fresh), or "*" (keep all defaults). // Filtering happens at the loader via additionalExtensionPaths + // extensionsOverride — excluded extensions never bind handlers or register // tools. describe("extensionCanonicalName", () => { it("strips .ts/.js from a single-file extension basename", () => { expect(extensionCanonicalName("/x/foo.ts")).toBe("foo"); expect(extensionCanonicalName("/x/foo.js")).toBe("foo"); }); it("uses the parent directory name for index.{ts,js} extensions", () => { expect(extensionCanonicalName("/x/foo/index.ts")).toBe("foo"); expect(extensionCanonicalName("/x/foo/index.js")).toBe("foo"); }); it("lowercases the result for case-insensitive matching", () => { expect(extensionCanonicalName("/x/MCP.ts")).toBe("mcp"); expect(extensionCanonicalName("/x/MyExt.js")).toBe("myext"); expect(extensionCanonicalName("/x/Foo/index.ts")).toBe("foo"); }); }); describe("extensionCanonicalNames (#143 — package short name alias)", () => { const tmpDirs: string[] = []; function pkgDir(name: string, piExtensions: unknown): string { const dir = mkdtempSync(join(tmpdir(), "subagents-pkg-")); tmpDirs.push(dir); const manifest: Record = { name }; if (piExtensions !== undefined) manifest.pi = { extensions: piExtensions }; writeFileSync(join(dir, "package.json"), JSON.stringify(manifest)); mkdirSync(join(dir, "src")); writeFileSync(join(dir, "src", "index.ts"), "export default () => {};"); return dir; } afterEach(() => { while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true }); }); it("aliases a package-declared index.ts entry to the unscoped, lowercased package name", () => { // Without this, `pi.extensions: ["./src/index.ts"]` only ever matches as "src". const dir = pkgDir("@tintinweb/Pi-Subagents", ["./src/index.ts"]); expect(extensionCanonicalNames(join(dir, "src", "index.ts"))).toEqual(["src", "pi-subagents"]); }); it("adds no alias for a loose file with no enclosing package.json", () => { const dir = mkdtempSync(join(tmpdir(), "subagents-loose-")); tmpDirs.push(dir); writeFileSync(join(dir, "foo.ts"), "export default () => {};"); expect(extensionCanonicalNames(join(dir, "foo.ts"))).toEqual(["foo"]); }); it("adds no alias when the nearest manifest does not declare this entry", () => { // The package.json is a real pi package but lists a *different* entry — so a // co-located file (e.g. our own test fixtures under this repo) is not falsely // stamped with the package name. const dir = pkgDir("@scope/other-ext", ["./src/other.ts"]); expect(extensionCanonicalNames(join(dir, "src", "index.ts"))).toEqual(["src"]); }); it("adds no alias when the nearest package.json has no pi manifest", () => { const dir = pkgDir("just-a-project", undefined); expect(extensionCanonicalNames(join(dir, "src", "index.ts"))).toEqual(["src"]); }); it("does not climb past a node_modules boundary into a consumer's manifest", () => { // A consumer that *declares* a dependency's entry must not lend its name to // that dependency: the walk stops at node_modules before reading it. const root = mkdtempSync(join(tmpdir(), "subagents-consumer-")); tmpDirs.push(root); writeFileSync( join(root, "package.json"), JSON.stringify({ name: "consumer", pi: { extensions: ["./node_modules/inner-ext/index.ts"] } }), ); const inner = join(root, "node_modules", "inner-ext"); mkdirSync(inner, { recursive: true }); writeFileSync(join(inner, "index.ts"), "export default () => {};"); // Only the path-derived name — never "consumer". expect(extensionCanonicalNames(join(inner, "index.ts"))).toEqual(["inner-ext"]); }); }); describe("parseExtensionsSpec", () => { it("classifies bare entries as names", () => { const spec = parseExtensionsSpec(["mcp", "logger"], "/work"); expect(spec.names).toEqual(new Set(["mcp", "logger"])); expect(spec.paths).toEqual([]); expect(spec.wildcard).toBe(false); }); it("treats '*' as the wildcard", () => { const spec = parseExtensionsSpec(["*"], "/work"); expect(spec.wildcard).toBe(true); expect(spec.names.size).toBe(0); expect(spec.paths).toEqual([]); }); it("resolves a relative path against cwd and adds its canonical name", () => { const spec = parseExtensionsSpec(["./rel/foo.ts"], "/work"); expect(spec.paths).toEqual(["/work/rel/foo.ts"]); expect(spec.names).toEqual(new Set(["foo"])); }); it("keeps an absolute path as-is", () => { const spec = parseExtensionsSpec(["/abs/bar.ts"], "/work"); expect(spec.paths).toEqual(["/abs/bar.ts"]); expect(spec.names).toEqual(new Set(["bar"])); }); it("expands a leading ~ to the home directory", () => { const spec = parseExtensionsSpec(["~/ext/baz.ts"], "/work"); expect(spec.paths[0]).toBe(`${homedir()}/ext/baz.ts`); expect(spec.names).toEqual(new Set(["baz"])); }); it("composes wildcard, names, and paths", () => { const spec = parseExtensionsSpec(["*", "mcp", "/abs/foo.ts"], "/work"); expect(spec.wildcard).toBe(true); expect(spec.names).toEqual(new Set(["mcp", "foo"])); expect(spec.paths).toEqual(["/abs/foo.ts"]); }); it("lowercases bare-name entries — extension names match case-insensitively", () => { const spec = parseExtensionsSpec(["Mcp", "LOGGER"], "/work"); expect(spec.names).toEqual(new Set(["mcp", "logger"])); }); it("ignores empty entries (defensive — upstream parsers already strip them)", () => { const spec = parseExtensionsSpec(["", "mcp", ""], "/work"); expect(spec.names).toEqual(new Set(["mcp"])); expect(spec.wildcard).toBe(false); }); }); describe("mandatory extension policy", () => { function mandatoryEntry(): { dir: string; path: string } { const dir = mkdtempSync(join(tmpdir(), "subagents-mandatory-runner-")); const path = join(dir, "permission-system.ts"); writeFileSync(path, "export default () => {};\n"); return { dir, path }; } function setup(extensions: true | string[] | false, excludeExtensions?: string[]) { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions, excludeExtensions })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions, excludeExtensions }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); } it("keeps exact mandatory handlers loaded under extensions:false without exposing their tools", async () => { const fixture = mandatoryEntry(); try { setup(false); withExtensions({ [fixture.path]: ["permission_admin"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, mandatoryExtensionPaths: [fixture.path], }); expect(lastLoaderOpts()).toEqual(expect.objectContaining({ noExtensions: false, additionalExtensionPaths: [realpathSync.native(fixture.path)], })); expect(loaderExtensionsRef.current.extensions.map((extension) => extension.path)) .toContain(fixture.path); expect(lastToolsPassed()).not.toContain("permission_admin"); expect(lastToolsPassed()).toEqual(expect.arrayContaining(BUILTINS_7)); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("ignores Agent lifecycle exclusion of a mandatory entry but still hides its tools", async () => { const fixture = mandatoryEntry(); try { setup(true, ["permission-system"]); withExtensions({ [fixture.path]: ["permission_admin"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, mandatoryExtensionPaths: [fixture.path], onToolActivity, }); expect(loaderExtensionsRef.current.extensions.map((extension) => extension.path)) .toContain(fixture.path); expect(lastToolsPassed()).not.toContain("permission_admin"); expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({ toolName: expect.stringContaining( 'ignored lifecycle exclusion of mandatory extension "permission-system"', ), })); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("fails closed before session creation when the exact mandatory entry did not load", async () => { const fixture = mandatoryEntry(); try { setup(false); withExtensions({}); await expect(runAgent(ctx, "Explore", "go", { pi, mandatoryExtensionPaths: [fixture.path], })).rejects.toThrow("Mandatory extension failed to load"); expect(createAgentSession).not.toHaveBeenCalled(); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); }); describe("agent-runner extension allowlist", () => { function setupArrayAgent(extensions: string[]) { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions })); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions })); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); } it("['*'] short-circuits — no extensionsOverride, behaves like extensions: true", async () => { setupArrayAgent(["*"]); withExtensions({ "/ext/a.ts": ["tool_a"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const opts = lastLoaderOpts(); expect(opts.extensionsOverride).toBeUndefined(); expect(opts.additionalExtensionPaths).toBeUndefined(); expect(lastToolsPassed()).toContain("tool_a"); }); it("['mcp'] keeps only the mcp-named extension, drops others", async () => { setupArrayAgent(["mcp"]); withExtensions({ "/ext/mcp.ts": ["mcp", "mcp_call"], "/ext/other.ts": ["other_tool"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).toContain("mcp"); expect(tools).toContain("mcp_call"); expect(tools).not.toContain("other_tool"); }); it("matches a package-installed extension by its package short name, not just its src dir (#143)", async () => { // A package whose entry is `src/index.ts` canonicalizes to "src"; a child // agent must still be able to allowlist it by the package name. const dir = mkdtempSync(join(tmpdir(), "subagents-match-")); try { writeFileSync( join(dir, "package.json"), JSON.stringify({ name: "@tintinweb/pi-subagents", pi: { extensions: ["./src/index.ts"] } }), ); mkdirSync(join(dir, "src")); writeFileSync(join(dir, "src", "index.ts"), "export default () => {};"); const entry = join(dir, "src", "index.ts"); setupArrayAgent(["pi-subagents"]); withExtensions({ [entry]: ["pkg_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); // Before the fix keepNames={pi-subagents} but the extension only answered // to "src", so it was filtered out and pkg_tool never reached the allowlist. expect(lastToolsPassed()).toContain("pkg_tool"); } finally { rmSync(dir, { recursive: true, force: true }); } }); it("an absolute path is added to additionalExtensionPaths and its extension survives", async () => { setupArrayAgent(["/abs/foo.ts"]); // Pre-register the path so the mock loader treats it as a successful load. withExtensions({ "/abs/foo.ts": ["foo_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(lastLoaderOpts().additionalExtensionPaths).toEqual(["/abs/foo.ts"]); expect(lastToolsPassed()).toContain("foo_tool"); }); it("['*', path] keeps all defaults plus the extra path", async () => { setupArrayAgent(["*", "/abs/foo.ts"]); withExtensions({ "/ext/default.ts": ["default_tool"], "/abs/foo.ts": ["foo_tool"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).toContain("default_tool"); expect(tools).toContain("foo_tool"); }); it("['mcp', path] keeps exactly those two, drops other defaults (no wildcard)", async () => { // Changelog: `["mcp", "/abs/foo.ts"]` is *just* those two. Distinct from // `['*', path]` (all defaults + path) and `['mcp']` (name only). setupArrayAgent(["mcp", "/abs/foo.ts"]); withExtensions({ "/ext/mcp.ts": ["mcp_tool"], "/abs/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const opts = lastLoaderOpts(); expect(opts.additionalExtensionPaths).toEqual(["/abs/foo.ts"]); // No "*" → the loader override is in force (narrowing, not load-all). expect(opts.extensionsOverride).toBeDefined(); const tools = lastToolsPassed(); expect(tools).toContain("mcp_tool"); expect(tools).toContain("foo_tool"); expect(tools).not.toContain("other_tool"); }); it("disallowedTools still applies to tools from an allowlisted extension", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: ["mcp"] })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: ["mcp"], disallowedTools: ["mcp"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); withExtensions({ "/ext/mcp.ts": ["mcp", "mcp_call"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).not.toContain("mcp"); expect(tools).toContain("mcp_call"); }); it("warns but proceeds when a bare name matches no loaded extension", async () => { setupArrayAgent(["mcp", "typo"]); withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(result.responseText).toBe("OK"); expect(onToolActivity).toHaveBeenCalledWith( expect.objectContaining({ toolName: expect.stringContaining('extension-error:extension "typo"'), }), ); }); it("warns but proceeds when a path entry fails to load", async () => { setupArrayAgent(["/abs/missing.ts"]); // Not pre-registered → the mock loader records a load error; the path's // canonical name ("missing") is what the unmatched-name check reports. const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(result.responseText).toBe("OK"); expect(onToolActivity).toHaveBeenCalledWith( expect.objectContaining({ toolName: expect.stringContaining('extension-error:extension "missing"'), }), ); }); it("matches `extensions: [Mcp]` against `mcp.ts` (case-insensitive)", async () => { setupArrayAgent(["Mcp"]); withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); // No extension-error warning — the name resolved. const errorCalls = onToolActivity.mock.calls.filter((c) => typeof c[0]?.toolName === "string" && c[0].toolName.startsWith("extension-error:"), ); expect(errorCalls).toEqual([]); expect(lastToolsPassed()).toContain("mcp_tool"); }); }); // ─── exclude_extensions: denylist (#94) ────────────────────────────────── describe("agent-runner exclude_extensions", () => { function setupAgent(overrides: Record) { vi.mocked(getConfig).mockReturnValueOnce(makeConfig(overrides)); vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig(overrides)); vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); } function extensionErrors(onToolActivity: ReturnType): string[] { return onToolActivity.mock.calls .map((c) => c[0]?.toolName) .filter((n): n is string => typeof n === "string" && n.startsWith("extension-error:")); } it("extensions: true + exclude — override installed, excluded tools dropped, others kept", async () => { setupAgent({ extensions: true, excludeExtensions: ["notify"] }); withExtensions({ "/ext/notify.ts": ["notify_send"], "/ext/mcp.ts": ["mcp_tool"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(lastLoaderOpts().extensionsOverride).toBeDefined(); const tools = lastToolsPassed(); expect(tools).not.toContain("notify_send"); expect(tools).toContain("mcp_tool"); expect(extensionErrors(onToolActivity)).toEqual([]); }); it("['*'] + exclude — wildcard no longer short-circuits, exclusion applies", async () => { setupAgent({ extensions: ["*"], excludeExtensions: ["notify"] }); withExtensions({ "/ext/notify.ts": ["notify_send"], "/ext/mcp.ts": ["mcp_tool"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(lastLoaderOpts().extensionsOverride).toBeDefined(); const tools = lastToolsPassed(); expect(tools).not.toContain("notify_send"); expect(tools).toContain("mcp_tool"); }); it("allowlist + exclude of a listed name — subtracted, 'in both' warning fires", async () => { setupAgent({ extensions: ["mcp", "other"], excludeExtensions: ["other"] }); withExtensions({ "/ext/mcp.ts": ["mcp_tool"], "/ext/other.ts": ["other_tool"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); const tools = lastToolsPassed(); expect(tools).toContain("mcp_tool"); expect(tools).not.toContain("other_tool"); expect(extensionErrors(onToolActivity)).toEqual([ expect.stringContaining('in both extensions: and exclude_extensions:'), ]); }); it("exclude typo — warning fires, all extensions still load", async () => { setupAgent({ extensions: true, excludeExtensions: ["nope"] }); withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(lastToolsPassed()).toContain("mcp_tool"); expect(extensionErrors(onToolActivity)).toEqual([ expect.stringContaining('exclude_extensions: "nope"'), ]); }); it("extensions: false + exclude — orphan warning, no override", async () => { setupAgent({ extensions: false, excludeExtensions: ["notify"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(lastLoaderOpts().extensionsOverride).toBeUndefined(); expect(extensionErrors(onToolActivity)).toEqual([ expect.stringContaining("exclude_extensions has no effect"), ]); }); it("isolated: true + exclude — excludes nulled, no warnings", async () => { setupAgent({ extensions: true, excludeExtensions: ["notify"] }); withExtensions({ "/ext/notify.ts": ["notify_send"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity, isolated: true }); expect(lastToolsPassed()).not.toContain("notify_send"); expect(extensionErrors(onToolActivity)).toEqual([]); }); it("tools: ext:foo referencing an excluded extension — existing orphan warning fires", async () => { setupAgent({ extensions: true, excludeExtensions: ["beta"], extSelectors: ["ext:beta"], }); withExtensions({ "/ext/beta.ts": ["beta_tool"], "/ext/mcp.ts": ["mcp_tool"], }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(lastToolsPassed()).not.toContain("beta_tool"); expect(extensionErrors(onToolActivity)).toEqual([ expect.stringContaining("extension-error:ext:beta"), ]); }); it("exclude matches case-insensitively", async () => { setupAgent({ extensions: true, excludeExtensions: ["MCP"] }); withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(lastToolsPassed()).not.toContain("mcp_tool"); expect(extensionErrors(onToolActivity)).toEqual([]); }); }); // ─── unknown built-in tool names in `tools:` (#75) ────────────────────── describe("agent-runner unknown built-in tools", () => { it("emits a tools-error warning for each plain entry not in BUILTIN_TOOL_NAMES", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, builtinToolNames: ["read", "reed", "grep", "edt"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(["read", "reed", "grep", "edt"]); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(result.responseText).toBe("OK"); const errorMessages = onToolActivity.mock.calls .map((c) => c[0]?.toolName) .filter((n): n is string => typeof n === "string" && n.startsWith("tools-error:")); expect(errorMessages).toHaveLength(2); expect(errorMessages.some((m) => m.includes('"reed"'))).toBe(true); expect(errorMessages.some((m) => m.includes('"edt"'))).toBe(true); }); it("stays quiet when all plain tool names are valid built-ins", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: false, builtinToolNames: ["read", "grep"] }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(["read", "grep"]); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); const errorMessages = onToolActivity.mock.calls .map((c) => c[0]?.toolName) .filter((n): n is string => typeof n === "string" && n.startsWith("tools-error:")); expect(errorMessages).toEqual([]); }); }); // ─── ext: tool selectors in `tools:` (opt-in flip) ────────────────────── describe("parseExtSelectors", () => { it("bare ext:foo → name only, no narrowing", () => { const { extNames, narrowing } = parseExtSelectors(["ext:foo"]); expect(extNames).toEqual(new Set(["foo"])); expect(narrowing.size).toBe(0); }); it("ext:foo/bar → name plus a narrowing entry", () => { const { extNames, narrowing } = parseExtSelectors(["ext:foo/bar"]); expect(extNames).toEqual(new Set(["foo"])); expect(narrowing.get("foo")).toEqual(new Set(["bar"])); }); it("multiple ext:foo/* entries union", () => { expect(parseExtSelectors(["ext:foo/a", "ext:foo/b"]).narrowing.get("foo")).toEqual( new Set(["a", "b"]), ); }); it("ext:foo + ext:foo/bar → narrowing wins", () => { const { narrowing } = parseExtSelectors(["ext:foo", "ext:foo/bar"]); expect(narrowing.get("foo")).toEqual(new Set(["bar"])); }); it("splits on the first / so tool names may contain /", () => { expect(parseExtSelectors(["ext:foo/bar/baz"]).narrowing.get("foo")).toEqual( new Set(["bar/baz"]), ); }); it("skips empty name and empty tool halves", () => { const { extNames, narrowing } = parseExtSelectors(["ext:", "ext:foo/"]); expect(extNames).toEqual(new Set(["foo"])); expect(narrowing.size).toBe(0); }); it("lowercases the extension name but preserves tool-name case", () => { // The extension half matches the loader's canonical name (also lowercased); // the tool half is matched against pi-mono's registered identifiers, which // are case-sensitive. const { extNames, narrowing } = parseExtSelectors(["ext:Mcp/SomeTool", "ext:FOO"]); expect(extNames).toEqual(new Set(["mcp", "foo"])); expect(narrowing.get("mcp")).toEqual(new Set(["SomeTool"])); }); }); describe("agent-runner ext: tool selectors", () => { function setupExtAgent(o: { extensions: boolean | string[]; builtinToolNames: string[]; extSelectors?: string[]; disallowedTools?: string[]; }) { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: o.extensions })); vi.mocked(getAgentConfig).mockReturnValueOnce( makeAgentConfig({ extensions: o.extensions, extSelectors: o.extSelectors, disallowedTools: o.disallowedTools, }), ); vi.mocked(getToolNamesForType).mockReturnValueOnce(o.builtinToolNames); } it("any ext: entry flips extension tools to an allowlist — non-selected extensions muted", async () => { // `tools: ext:foo` → zero built-ins, opt-in flip active. setupExtAgent({ extensions: true, builtinToolNames: [], extSelectors: ["ext:foo"] }); withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).toContain("foo_tool"); expect(tools).not.toContain("other_tool"); // loaded but muted expect(tools).not.toContain("read"); // tools: ext:foo → no built-ins // both extensions still load — no loader override needed under extensions: true expect(lastLoaderOpts().extensionsOverride).toBeUndefined(); }); it("'*' alongside ext: keeps all built-ins while the flip still applies", async () => { // `tools: *, ext:foo` → all built-ins, opt-in flip active. setupExtAgent({ extensions: true, builtinToolNames: BUILTINS_7, extSelectors: ["ext:foo"] }); withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); for (const b of BUILTINS_7) expect(tools).toContain(b); expect(tools).toContain("foo_tool"); expect(tools).not.toContain("other_tool"); }); it("ext:foo/bar narrows foo to a single tool", async () => { setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:foo/bar"] }); withExtensions({ "/ext/foo.ts": ["bar", "baz"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).toContain("read"); expect(tools).toContain("bar"); expect(tools).not.toContain("baz"); }); it("ext:foo is orphaned when extensions: false — no extension loads, warning fires", async () => { // `extensions:` is the sole loading authority. `ext:` selectors can only narrow // within the loaded set; they cannot pull an excluded extension back in. setupExtAgent({ extensions: false, builtinToolNames: ["read"], extSelectors: ["ext:foo"] }); withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(lastLoaderOpts().noExtensions).toBe(true); const tools = lastToolsPassed(); expect(tools).toEqual(["read"]); expect(tools).not.toContain("foo_tool"); expect(onToolActivity).toHaveBeenCalledWith( expect.objectContaining({ toolName: expect.stringContaining('extension-error:ext:foo'), }), ); }); it("ext: cannot pull an extension that `extensions: [...]` excludes — warns, no surfacing", async () => { // extensions: [a] loads only a. ext:foo references foo, which isn't loaded; // the opt-in flip still mutes a (it isn't named in ext:), so the agent gets // zero extension tools and a warning fires. setupExtAgent({ extensions: ["a"], builtinToolNames: [], extSelectors: ["ext:foo"] }); withExtensions({ "/ext/a.ts": ["a_tool"], "/ext/foo.ts": ["foo_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); const tools = lastToolsPassed(); expect(tools).not.toContain("foo_tool"); // foo never loaded expect(tools).not.toContain("a_tool"); // a loaded but muted by the ext: opt-in flip expect(onToolActivity).toHaveBeenCalledWith( expect.objectContaining({ toolName: expect.stringContaining('extension-error:ext:foo'), }), ); }); it("['*'] short-circuit survives ext: narrowing", async () => { setupExtAgent({ extensions: ["*"], builtinToolNames: ["read"], extSelectors: ["ext:foo/bar"] }); withExtensions({ "/ext/foo.ts": ["bar", "baz"], "/ext/other.ts": ["other_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(lastLoaderOpts().extensionsOverride).toBeUndefined(); // pure-["*"] short-circuit holds const tools = lastToolsPassed(); expect(tools).toContain("bar"); expect(tools).not.toContain("baz"); expect(tools).not.toContain("other_tool"); // flip mutes the unselected extension }); it("warns but proceeds when an ext: name doesn't match any loaded extension", async () => { setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:ghost"] }); withExtensions({ "/ext/real.ts": ["real_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(result.responseText).toBe("OK"); expect(onToolActivity).toHaveBeenCalledWith( expect.objectContaining({ toolName: expect.stringContaining('extension-error:ext:ghost'), }), ); }); it("isolated: true ignores extSelectors — no extension tools", async () => { setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:foo"] }); withExtensions({ "/ext/foo.ts": ["foo_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, isolated: true }); const tools = lastToolsPassed(); expect(tools).toContain("read"); expect(tools).not.toContain("foo_tool"); expect(lastLoaderOpts().noExtensions).toBe(true); }); it("ext: composes with a path-loaded extension via its canonical name", async () => { // Changelog: `ext:` is name-only (matched by canonical name), so it composes // with extensions loaded by path through `extensions:`. The path "/abs/foo.ts" // has canonical name "foo", which `ext:foo` then surfaces — no orphan warning. setupExtAgent({ extensions: ["/abs/foo.ts"], builtinToolNames: ["read"], extSelectors: ["ext:foo"], }); withExtensions({ "/abs/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); const onToolActivity = vi.fn(); await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); expect(lastLoaderOpts().additionalExtensionPaths).toEqual(["/abs/foo.ts"]); const tools = lastToolsPassed(); expect(tools).toContain("foo_tool"); // path-loaded ext surfaced via ext:foo expect(tools).toContain("read"); expect(tools).not.toContain("other_tool"); // dropped at the loader (not in keepNames) // ext:foo resolved against the path's canonical name → not orphaned. const errorCalls = onToolActivity.mock.calls.filter( (c) => typeof c[0]?.toolName === "string" && c[0].toolName.startsWith("extension-error:"), ); expect(errorCalls).toEqual([]); }); it("ext:foo/Bar narrowing is case-sensitive on the tool half", async () => { // The extension half is canonicalised (lowercased); the tool half is matched // verbatim against pi-mono's registered identifiers, so `Bar` must not match `bar`. setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:foo/Bar"] }); withExtensions({ "/ext/foo.ts": ["Bar", "bar"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).toContain("Bar"); expect(tools).not.toContain("bar"); // case-sensitive: not the selected tool }); it("disallowedTools removes a tool reached via an ext: selector", async () => { // The denylist applies uniformly to extension tools, including those surfaced // by the ext: opt-in flip — same construction-time `allowedTools` filter. setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:foo"], disallowedTools: ["foo_tool"], }); withExtensions({ "/ext/foo.ts": ["foo_tool", "foo_other"] }); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); const tools = lastToolsPassed(); expect(tools).toContain("read"); expect(tools).toContain("foo_other"); expect(tools).not.toContain("foo_tool"); // denylisted even though ext:foo selects it }); }); // The limit a run will enforce, resolved before the run starts. The widget's // turn counter has to predict it for agents spawned outside the Agent tool // (mentions, cross-extension RPC), and a second copy of the expression there // would drift from the one runAgent enforces — so both call this. describe("resolveEffectiveMaxTurns", () => { let prevDefault: number | undefined; beforeEach(() => { prevDefault = getDefaultMaxTurns(); vi.mocked(getAgentConfig).mockReturnValue(makeAgentConfig({ maxTurns: 7 }) as any); }); afterEach(() => { setDefaultMaxTurns(prevDefault); vi.mocked(getAgentConfig).mockReset(); }); it("prefers an explicit value over the agent's own and the project default", () => { setDefaultMaxTurns(20); expect(resolveEffectiveMaxTurns("test-agent", 3)).toBe(3); }); it("falls back to the agent's own max_turns", () => { setDefaultMaxTurns(20); expect(resolveEffectiveMaxTurns("test-agent")).toBe(7); }); it("falls back to the project default when the agent sets none", () => { setDefaultMaxTurns(20); vi.mocked(getAgentConfig).mockReturnValue(makeAgentConfig() as any); expect(resolveEffectiveMaxTurns("test-agent")).toBe(20); }); it("is unlimited when nothing sets a limit", () => { setDefaultMaxTurns(undefined); vi.mocked(getAgentConfig).mockReturnValue(makeAgentConfig() as any); expect(resolveEffectiveMaxTurns("test-agent")).toBeUndefined(); }); it("treats an explicit 0 as unlimited rather than as 'no opinion'", () => { // Not the same as omitting it: 0 is how a caller says "no limit", and // falling through to the default would impose one it asked not to have. setDefaultMaxTurns(20); expect(resolveEffectiveMaxTurns("test-agent", 0)).toBeUndefined(); }); }); // The soft-limit → grace → hard-abort machine (agent-runner.ts, the `turn_end` // branch) has never executed in a test: every consumer of the `steered`/`aborted` // flags mocks `runAgent` outright, so the flags are asserted but never produced. // The machine is what stops a runaway subagent, so a broken latch is either an // agent that never wraps up and never aborts, or one that aborts on turn 1. describe("agent-runner turn limits", () => { let prevMax: number | undefined; let prevGrace: number; beforeEach(() => { prevMax = getDefaultMaxTurns(); prevGrace = getGraceTurns(); }); afterEach(() => { // Both are module-global; leaking them would silently retune other suites. setDefaultMaxTurns(prevMax); setGraceTurns(prevGrace); }); /** * Run an agent whose prompt fires `turns` synthetic turn_end events before it * produces its final message — the same events a real session emits. */ async function runWithTurns(turns: number, options: Record = {}) { vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); const { session, listeners } = createSession("OK"); session.prompt.mockImplementation(async () => { for (let i = 0; i < turns; i++) { for (const l of [...listeners]) l({ type: "turn_end" }); } session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); }); createAgentSession.mockResolvedValue({ session }); const result = await runAgent(ctx, "Explore", "go", { pi, ...options }); return { session, result }; } it("does not steer or abort below the limit", async () => { const { session, result } = await runWithTurns(3, { maxTurns: 5 }); expect(session.steer).not.toHaveBeenCalled(); expect(session.abort).not.toHaveBeenCalled(); expect(result.steered).toBe(false); }); it("steers exactly once on reaching the limit, and does not abort", async () => { setGraceTurns(5); const { session, result } = await runWithTurns(5, { maxTurns: 5 }); expect(session.steer).toHaveBeenCalledTimes(1); expect(session.steer.mock.calls[0][0]).toContain("turn limit"); expect(session.abort).not.toHaveBeenCalled(); expect(result.steered).toBe(true); }); it("does not re-steer on every turn once the soft limit latched", async () => { // Without the latch the agent gets a wrap-up message every single turn, // which both burns tokens and drowns out its actual task. setGraceTurns(5); const { session } = await runWithTurns(8, { maxTurns: 5 }); expect(session.steer).toHaveBeenCalledTimes(1); expect(session.abort).not.toHaveBeenCalled(); }); it("hard-aborts once the grace turns are used up", async () => { setGraceTurns(2); const { session, result } = await runWithTurns(7, { maxTurns: 5 }); expect(session.steer).toHaveBeenCalledTimes(1); expect(session.abort).toHaveBeenCalled(); expect(result.aborted).toBe(true); }); it("keeps running through the grace window without aborting", async () => { setGraceTurns(3); const { session, result } = await runWithTurns(7, { maxTurns: 5 }); expect(session.abort).not.toHaveBeenCalled(); expect(result.aborted).toBe(false); expect(result.steered).toBe(true); }); it("treats maxTurns 0 as unlimited", async () => { const { session } = await runWithTurns(30, { maxTurns: 0 }); expect(session.steer).not.toHaveBeenCalled(); expect(session.abort).not.toHaveBeenCalled(); }); it("is unlimited when nothing configures a limit", async () => { setDefaultMaxTurns(undefined); const { session } = await runWithTurns(30); expect(session.steer).not.toHaveBeenCalled(); expect(session.abort).not.toHaveBeenCalled(); }); it("falls back to the global default when the call sets no limit", async () => { setDefaultMaxTurns(4); setGraceTurns(5); const { session } = await runWithTurns(4); expect(session.steer).toHaveBeenCalledTimes(1); }); it("an explicit maxTurns beats the global default", async () => { setDefaultMaxTurns(2); setGraceTurns(5); const { session } = await runWithTurns(4, { maxTurns: 10 }); expect(session.steer).not.toHaveBeenCalled(); }); it("reports each turn to the caller's counter", async () => { const onTurnEnd = vi.fn(); await runWithTurns(3, { maxTurns: 10, onTurnEnd }); expect(onTurnEnd.mock.calls.map(c => c[0])).toEqual([1, 2, 3]); }); }); // A parent Esc / interrupt reaches the child through options.signal. The only // existing coverage asserts the RECORD flips to "stopped" with runAgent mocked — // nothing checked that the signal actually reaches the session, so a child could // be marked stopped while it keeps running and burning tokens. describe("agent-runner abort signal forwarding", () => { it("aborts the session when the parent signal fires mid-run", async () => { vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); const controller = new AbortController(); const { session } = createSession("OK"); session.prompt.mockImplementation(async () => { controller.abort(); session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); }); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, signal: controller.signal }); expect(session.abort).toHaveBeenCalled(); }); it("removes its listener once the run settles", async () => { // A long-lived parent signal outlives many children; a listener left behind // per child is a leak that also re-aborts sessions that are already gone. vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); const controller = new AbortController(); const removeSpy = vi.spyOn(controller.signal, "removeEventListener"); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi, signal: controller.signal }); expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); controller.abort(); expect(session.abort).not.toHaveBeenCalled(); // detached, so a late abort is inert }); it("registers nothing when no signal is supplied", async () => { vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); const { session } = createSession("OK"); createAgentSession.mockResolvedValue({ session }); await runAgent(ctx, "Explore", "go", { pi }); expect(session.abort).not.toHaveBeenCalled(); }); }); // resolveDefaultModel picks the model a subagent runs on. Every failure here is // SILENT BY DESIGN: an unresolvable or unavailable `model:` deliberately falls // back to the parent's model rather than erroring, because a user's frontmatter // pin shouldn't hard-fail a spawn. That makes the availability filter untestable // through observed behavior — a broken check just means every model-pinned agent // quietly runs on the parent's model, costing whatever the parent costs. // // Exported for this (the file already exports normalizeMaxTurns/setGraceTurns // purely for test/agent-runner-settings.test.ts). describe("resolveDefaultModel", () => { const parent = { provider: "anthropic", id: "parent-model" } as any; const haiku = { provider: "anthropic", id: "claude-haiku-4-5" } as any; /** Registry whose `find` always succeeds; `getAvailable` is what varies. */ function registry(available?: any[]) { return { find: vi.fn((provider: string, id: string) => ({ provider, id }) as any), getAvailable: available ? () => available : undefined, }; } it("returns the configured model when the registry has it available", () => { const r = registry([haiku]); expect(resolveDefaultModel(parent, r, "anthropic/claude-haiku-4-5")) .toEqual({ provider: "anthropic", id: "claude-haiku-4-5" }); }); it("falls back to the parent when the model is NOT in the available set", () => { // The branch with teeth: without this filter the subagent is handed a model // the user has no credentials for, and the failure surfaces as a runtime // auth error from deep inside createAgentSession instead of a clean fallback. const r = registry([haiku]); expect(resolveDefaultModel(parent, r, "openai/gpt-5")).toBe(parent); }); it("trusts `find` when the registry cannot enumerate availability", () => { // getAvailable absent → no filtering possible, so a found model is used. const r = registry(undefined); expect(resolveDefaultModel(parent, r, "anthropic/claude-haiku-4-5")) .toEqual({ provider: "anthropic", id: "claude-haiku-4-5" }); }); it("falls back to the parent when the registry cannot find the model", () => { const r = { find: vi.fn(() => undefined), getAvailable: undefined }; expect(resolveDefaultModel(parent, r as any, "anthropic/nope")).toBe(parent); }); it("falls back to the parent for a model string with no provider prefix", () => { const r = registry([haiku]); expect(resolveDefaultModel(parent, r, "haiku")).toBe(parent); expect(r.find).not.toHaveBeenCalled(); }); it("returns the parent model when no model is configured", () => { expect(resolveDefaultModel(parent, registry([haiku]), undefined)).toBe(parent); }); it("returns undefined when neither a config model nor a parent model exists", () => { expect(resolveDefaultModel(undefined, registry([haiku]), undefined)).toBeUndefined(); }); });