feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
+164
View File
@@ -0,0 +1,164 @@
// src/abortable.ts was the only module in the repo that no test imported. It is
// reached indirectly through the get_subagent_result wait paths, but its two
// real invariants were never asserted:
//
// 1. Aborting cancels the CALLER'S WAIT, not the work (#159). The background
// child keeps running and its result stays unconsumed — so the wrapped
// promise settles later, after `abortable` already rejected. What makes
// that safe is that `abortable` subscribes to the child with BOTH handlers
// (`promise.then(onFulfilled, onRejected)`): the child's eventual rejection
// is therefore already handled and goes nowhere. Drop the rejection handler
// and a child that fails after the user pressed Esc raises an unhandled
// rejection at the process level, long after the abort, with nothing
// pointing back here.
//
// (The `settled` latch is defensive, not load-bearing: settling an already
// settled promise a second time is a no-op per spec, so removing the latch
// changes nothing observable. No test here claims to protect it.)
// 2. The abort listener is removed on every settle path. The parent's signal
// outlives many waits, so a leak here accumulates handlers across a fleet.
//
// Both failures are invisible to a test that only checks the happy path, which
// is why they are asserted directly rather than through a consumer.
import { afterEach, describe, expect, it, vi } from "vitest";
import { abortable } from "../src/abortable.js";
/**
* Collect unhandled rejections for the duration of a block.
*
* Vitest installs its own handler, so `process.on` alone would not observe them —
* `removeAllListeners` is used and the originals restored afterwards.
*/
async function withUnhandledRejectionSpy(fn: () => Promise<void>): Promise<unknown[]> {
const seen: unknown[] = [];
const prior = process.listeners("unhandledRejection");
process.removeAllListeners("unhandledRejection");
const capture = (reason: unknown) => { seen.push(reason); };
process.on("unhandledRejection", capture);
try {
await fn();
// Unhandled rejections are reported on a later macrotask, not a microtask.
await new Promise((r) => setTimeout(r, 20));
} finally {
process.off("unhandledRejection", capture);
for (const l of prior) process.on("unhandledRejection", l as never);
}
return seen;
}
describe("abortable", () => {
afterEach(() => vi.useRealTimers());
it("returns the original promise untouched when there is no signal", async () => {
const promise = Promise.resolve("value");
// Identity, not just equality: with no signal there is nothing to race, so
// wrapping would add a needless tick and a needless rejection surface.
expect(abortable(promise)).toBe(promise);
await expect(promise).resolves.toBe("value");
});
it("rejects immediately with the signal's reason when already aborted", async () => {
const controller = new AbortController();
controller.abort(new Error("gone before we started"));
await expect(abortable(new Promise(() => {}), controller.signal))
.rejects.toThrow("gone before we started");
});
it("resolves normally when the signal never fires", async () => {
const controller = new AbortController();
await expect(abortable(Promise.resolve(42), controller.signal)).resolves.toBe(42);
});
it("propagates a rejection from the wrapped promise", async () => {
const controller = new AbortController();
await expect(abortable(Promise.reject(new Error("work failed")), controller.signal))
.rejects.toThrow("work failed");
});
it("rejects with the abort reason when the signal fires mid-wait", async () => {
const controller = new AbortController();
const pending = abortable(new Promise(() => {}), controller.signal);
controller.abort(new Error("user pressed Esc"));
await expect(pending).rejects.toThrow("user pressed Esc");
});
it("absorbs a late REJECTION of the wrapped promise after an abort", async () => {
// The #159 guarantee: the child was not cancelled, so it can still fail
// after we stopped waiting. That failure must go nowhere, not to the
// process's unhandled-rejection handler. Verified to fail if `abortable`
// stops attaching a rejection handler to the wrapped promise.
const unhandled = await withUnhandledRejectionSpy(async () => {
const controller = new AbortController();
let failChild: ((e: unknown) => void) | undefined;
const childWork = new Promise((_resolve, reject) => { failChild = reject; });
const pending = abortable(childWork, controller.signal);
controller.abort(new Error("stopped waiting"));
await expect(pending).rejects.toThrow("stopped waiting");
failChild!(new Error("child died later"));
await new Promise((r) => setTimeout(r, 5));
});
expect(unhandled).toEqual([]);
});
it("absorbs a late RESOLUTION of the wrapped promise after an abort", async () => {
const controller = new AbortController();
let finishChild: ((v: string) => void) | undefined;
const childWork = new Promise<string>((resolve) => { finishChild = resolve; });
const pending = abortable(childWork, controller.signal);
controller.abort(new Error("stopped waiting"));
await expect(pending).rejects.toThrow("stopped waiting");
finishChild!("child finished anyway");
// The child's own result is still available to whoever holds it — abortable
// cancelled the wait, not the work.
await expect(childWork).resolves.toBe("child finished anyway");
});
it("removes its abort listener once the promise resolves", async () => {
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
await abortable(Promise.resolve("done"), controller.signal);
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("removes its abort listener once the promise rejects", async () => {
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
await expect(abortable(Promise.reject(new Error("nope")), controller.signal)).rejects.toThrow();
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("a late abort after normal settlement is inert", async () => {
// A long-lived parent signal is aborted for unrelated reasons after this
// wait already finished; nothing should be re-rejected or throw.
const unhandled = await withUnhandledRejectionSpy(async () => {
const controller = new AbortController();
await expect(abortable(Promise.resolve("done"), controller.signal)).resolves.toBe("done");
controller.abort(new Error("much later"));
await new Promise((r) => setTimeout(r, 5));
});
expect(unhandled).toEqual([]);
});
it("does not leak a listener per wait on a shared parent signal", async () => {
// Many children wait on one parent signal over a session's lifetime.
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
for (let i = 0; i < 20; i++) {
await abortable(Promise.resolve(i), controller.signal);
}
expect(remove).toHaveBeenCalledTimes(20);
});
});
@@ -0,0 +1,242 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAgents } from "../src/agent-types.js";
import subagentsExtension from "../src/index.js";
import type { AgentConfig, AgentRecord } from "../src/types.js";
import { type AgentActivity, AgentWidget } from "../src/ui/agent-widget.js";
import { ConversationViewer } from "../src/ui/conversation-viewer.js";
import { FleetList, type FleetUICtx } from "../src/ui/fleet-list.js";
const TYPE = "colored-reviewer";
const DISPLAY_NAME = "Code Reviewer";
const PURPLE_BACKGROUND = "\u001b[48;2;130;125;189m";
const config: AgentConfig = {
name: TYPE,
displayName: DISPLAY_NAME,
color: "purple",
description: "Reviews code",
extensions: false,
skills: false,
systemPrompt: "Review code.",
promptMode: "replace",
};
const theme = {
fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
bold: (text: string) => `*${text}*`,
getBgAnsi: (color: string) => `<${color}>`,
getColorMode: () => "truecolor" as const,
};
type RenderedComponent = { render(width?: number): string[] };
type WidgetFactory = (
tui: { terminal: { columns: number; rows?: number }; requestRender: ReturnType<typeof vi.fn> },
activeTheme: typeof theme,
) => RenderedComponent;
type SessionHandler = (...args: unknown[]) => unknown;
interface RegisteredTool {
name: string;
renderCall(
args: Record<string, unknown>,
activeTheme: typeof theme,
context: { isPartial: boolean; isError: boolean },
): RenderedComponent;
}
function registerColoredReviewer(color = "purple"): void {
registerAgents(new Map([[TYPE, { ...config, color }]]));
}
function makeRecord(): AgentRecord {
return {
id: "review-1",
type: TYPE,
description: "Review this change",
status: "running",
toolUses: 0,
startedAt: Date.now(),
session: {
messages: [],
subscribe: vi.fn(() => vi.fn()),
} as unknown as AgentRecord["session"],
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
compactionCount: 0,
};
}
function makeActivity(): AgentActivity {
return {
activeTools: new Map(),
toolUses: 0,
responseText: "",
turnCount: 1,
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
};
}
function makePi() {
const tools = new Map<string, RegisteredTool>();
const handlers = new Map<string, SessionHandler>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((tool: unknown) => {
const registered = tool as RegisteredTool;
tools.set(registered.name, registered);
}),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: unknown) => handlers.set(event, handler as SessionHandler)),
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as unknown as Parameters<typeof subagentsExtension>[0];
return { pi, tools, handlers };
}
beforeEach(() => {
registerColoredReviewer();
});
afterEach(() => {
registerAgents(new Map());
});
describe("custom agent color runtime surfaces", () => {
it("renders the registered Agent tool call header with the display name and color", async () => {
const { pi, tools, handlers } = makePi();
subagentsExtension(pi);
registerColoredReviewer();
try {
const tool = tools.get("Agent");
if (!tool) throw new Error("Agent tool was not registered");
const render = (context: { isPartial: boolean; isError: boolean }) => tool.renderCall(
{ subagent_type: TYPE, description: "Review this change" },
theme,
context,
).render(120).join("\n");
const output = render({ isPartial: false, isError: false });
expect(output).toContain(DISPLAY_NAME);
expect(output).toContain(PURPLE_BACKGROUND);
// The row tint is opened by the line itself and restored after the badge, so the
// line reads the same whether or not the caller paints one (HTML export does not).
expect(output.indexOf("<toolSuccessBg>")).toBeLessThan(output.indexOf(PURPLE_BACKGROUND));
expect(output.split("<toolSuccessBg>")).toHaveLength(3); // opened once, restored after the badge
// Left open on purpose: Box pads to width and then wraps, so a reset here would
// leave the padding untinted. Nothing after the badge may close the background.
expect(output).not.toContain("\u001b[49m");
expect(render({ isPartial: true, isError: false })).toContain("<toolPendingBg>");
expect(render({ isPartial: false, isError: true })).toContain("<toolErrorBg>");
const missingType = tool.renderCall(
{ description: "Review this change" },
theme,
{ isPartial: false, isError: false },
).render(120).join("\n");
expect(missingType).toContain("<toolTitle>*Agent*</toolTitle>");
expect(missingType).not.toContain(PURPLE_BACKGROUND);
// An agent without a color must render the pre-badge line byte for byte:
// no badge, and no row background of our own for HTML export to pick up.
registerAgents(new Map([[TYPE, { ...config, color: undefined }]]));
const uncolored = render({ isPartial: false, isError: false });
expect(uncolored.trimEnd()).toBe(`▸ <toolTitle>*${DISPLAY_NAME}*</toolTitle> <muted>Review this change</muted>`);
} finally {
await handlers.get("session_shutdown")?.({}, { hasUI: false, ui: {} });
}
});
it("renders the above-editor Agent widget with the display name and color", () => {
const record = makeRecord();
const widget = new AgentWidget(
{ listAgents: () => [record] } as unknown as ConstructorParameters<typeof AgentWidget>[0],
new Map([[record.id, makeActivity()]]),
() => "all",
);
let factory: WidgetFactory | undefined;
let placement: string | undefined;
widget.setUICtx({
setStatus: vi.fn(),
setWidget: (_key, content, options) => {
if (typeof content === "function") factory = content as WidgetFactory;
placement = options?.placement;
},
});
try {
widget.update();
const output = factory?.(
{ terminal: { columns: 120 }, requestRender: vi.fn() },
theme,
).render().join("\n");
expect(placement).toBe("aboveEditor");
expect(output).toContain(DISPLAY_NAME);
expect(output).toContain(PURPLE_BACKGROUND);
} finally {
widget.dispose();
}
});
it("renders the FleetView row with the display name and color", () => {
const record = makeRecord();
const manager = {
listAgents: () => [record],
abort: vi.fn(() => true),
steer: vi.fn(() => true),
} as unknown as ConstructorParameters<typeof FleetList>[0];
const fleet = new FleetList(manager, new Map());
let factory: WidgetFactory | undefined;
fleet.setUICtx({
setWidget: (_key, content) => {
if (typeof content === "function") factory = content as WidgetFactory;
},
onTerminalInput: vi.fn(() => vi.fn()),
getEditorText: vi.fn(() => ""),
notify: vi.fn(),
custom: (() => new Promise<undefined>(() => {})) as FleetUICtx["custom"],
});
try {
fleet.update();
const output = factory?.(
{ requestRender: vi.fn(), terminal: { columns: 120, rows: 40 } },
theme,
).render(120).join("\n");
expect(output).toContain(DISPLAY_NAME);
expect(output).toContain(PURPLE_BACKGROUND);
registerColoredReviewer("invalid");
const fallback = factory?.(
{ requestRender: vi.fn(), terminal: { columns: 120, rows: 40 } },
theme,
).render(120).join("\n");
expect(fallback).toContain(`<muted>${DISPLAY_NAME}</muted>`);
expect(fallback).not.toContain(PURPLE_BACKGROUND);
} finally {
fleet.dispose();
}
});
it("renders the conversation viewer header with the display name and color", () => {
const record = makeRecord();
const viewer = new ConversationViewer(
{ terminal: { rows: 30, columns: 120 }, requestRender: vi.fn() } as unknown as ConstructorParameters<typeof ConversationViewer>[0],
record.session!,
record,
undefined,
theme,
vi.fn(),
);
try {
const output = viewer.render(120).join("\n");
expect(output).toContain(DISPLAY_NAME);
expect(output).toContain(PURPLE_BACKGROUND);
} finally {
viewer.dispose();
}
});
});
+69
View File
@@ -0,0 +1,69 @@
import { visibleWidth } from "@earendil-works/pi-tui";
import { describe, expect, it } from "vitest";
import { renderAgentNameLabel, resolveAgentColor } from "../src/agent-color.js";
const theme = {
fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
bold: (text: string) => `*${text}*`,
getColorMode: () => "truecolor" as const,
};
describe("resolveAgentColor", () => {
it("resolves Claude Code names and Agency Agents aliases", () => {
expect(resolveAgentColor("purple")).toBe("#827DBD");
expect(resolveAgentColor("neon-cyan")).toBe("#06B6D4");
expect(resolveAgentColor("slate")).toBe("#64748B");
});
it("normalizes six-digit hex and rejects unsupported values", () => {
expect(resolveAgentColor(" #8b5cf6 ")).toBe("#8B5CF6");
expect(resolveAgentColor("not-a-color")).toBeUndefined();
expect(resolveAgentColor("#123")).toBeUndefined();
});
});
describe("renderAgentNameLabel", () => {
it("renders a padded truecolor badge with readable foreground", () => {
const ansiTheme = {
...theme,
bold: (text: string) => `\u001b[1m${text}\u001b[22m`,
};
const badge = renderAgentNameLabel("Code Reviewer", "purple", ansiTheme, { bold: true });
expect(badge).toContain("\u001b[48;2;130;125;189m");
expect(badge).toContain(" Code Reviewer ");
expect(visibleWidth(badge)).toBe("Code Reviewer".length + 2);
// Badge text follows the background's luminance: #827DBD is light enough for black,
// #1E3A8A is not. Both sit either side of the 0.179 WCAG threshold.
expect(badge).toContain("\u001b[38;2;0;0;0m");
expect(renderAgentNameLabel("Tester", "navy", theme)).toContain("\u001b[38;2;255;255;255m");
});
it("judges contrast against the effective color in 256-color mode", () => {
const ansiTheme = { ...theme, getColorMode: () => "256color" as const };
const label = renderAgentNameLabel("Reviewer", "#C430C4", ansiTheme);
expect(label).toContain("\u001b[48;5;170m");
expect(label).toContain("\u001b[38;5;16m");
const neutral = renderAgentNameLabel("Reviewer", "#808080", ansiTheme);
expect(neutral).toContain("\u001b[48;5;244m");
});
it("restores an enclosing tool background after the badge", () => {
const label = renderAgentNameLabel("Reviewer", "purple", theme, {
restoreBackground: "\u001b[48;2;1;2;3m",
});
expect(label).toMatch(/\u001b\[39m\u001b\[48;2;1;2;3m$/);
});
it("resets the background when the caller paints none", () => {
expect(renderAgentNameLabel("Reviewer", "purple", theme)).toMatch(/\u001b\[39m\u001b\[49m$/);
});
it("preserves existing theme styling without a valid color", () => {
expect(renderAgentNameLabel("Agent", undefined, theme, { fallbackColor: "toolTitle", bold: true }))
.toBe("<toolTitle>*Agent*</toolTitle>");
expect(renderAgentNameLabel("Agent", "invalid", theme, { fallbackColor: "muted" }))
.toBe("<muted>Agent</muted>");
});
});
+386
View File
@@ -0,0 +1,386 @@
// The `/agents` enable/disable operations edit an agent .md file's frontmatter.
// The LOAD side (src/custom-agents.ts) parses that frontmatter with a real YAML
// parser, so `enabled: false` is honored wherever it appears in the block. The
// WRITE side here must agree — README.md documents `enabled: false` as a field
// users hand-write, and a hand-authored file puts it wherever the author likes.
//
// These live in src/agent-file-toggle.ts rather than inside the `/agents`
// command closure because `registerCommand` is mocked in every wiring test,
// which is why none of this had coverage.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildNewAgentFile,
disableInContent,
enableInContent,
findAgentFile,
isDisabledContent,
isEmptyStub,
locateAgentFile,
} from "../src/agent-file-toggle.js";
/** What the loader concludes about a file, via the same parser it really uses. */
function loaderSeesDisabled(content: string): boolean {
return parseFrontmatter<Record<string, unknown>>(content).frontmatter.enabled === false;
}
const HAND_AUTHORED_DISABLED = "---\ndescription: Scout the repo\nenabled: false\n---\n\nYou are a scout.\n";
const EXTENSION_WRITTEN_DISABLED = "---\nenabled: false\ndescription: Scout the repo\n---\n\nYou are a scout.\n";
const ENABLED = "---\ndescription: Scout the repo\n---\n\nYou are a scout.\n";
describe("enableInContent", () => {
it("strips enabled: false when it is the first frontmatter line", () => {
const { content, changed } = enableInContent(EXTENSION_WRITTEN_DISABLED);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
it("strips enabled: false when another key precedes it", () => {
// The shape a user gets by following README.md — `description` first.
const { content, changed } = enableInContent(HAND_AUTHORED_DISABLED);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
it("strips enabled: false when it is the last frontmatter line", () => {
const src = "---\ndescription: Scout\ndisplay_name: Scout\nenabled: false\n---\n\nBody.\n";
const { content, changed } = enableInContent(src);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
it("reports changed: false when there is nothing to strip", () => {
const { content, changed } = enableInContent(ENABLED);
expect(changed).toBe(false);
expect(content).toBe(ENABLED);
});
it("leaves the body and other frontmatter keys untouched", () => {
const src = "---\ndescription: Scout\nenabled: false\n# a comment\nmodel: haiku\n---\n\nLine 1.\n\nLine 2.\n";
const { content } = enableInContent(src);
expect(content).toContain("# a comment");
expect(content).toContain("model: haiku");
expect(content).toContain("Line 1.\n\nLine 2.\n");
expect(loaderSeesDisabled(content)).toBe(false);
});
it("handles CRLF line endings", () => {
const src = "---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n";
const { content, changed } = enableInContent(src);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
});
describe("disableInContent", () => {
it("inserts enabled: false into a normal frontmatter block", () => {
const { content, outcome } = disableInContent(ENABLED);
expect(outcome).toBe("disabled");
expect(loaderSeesDisabled(content)).toBe(true);
});
it("is idempotent when the key is already first", () => {
expect(disableInContent(EXTENSION_WRITTEN_DISABLED).outcome).toBe("already-disabled");
});
it("is idempotent when the key is already present mid-block", () => {
expect(disableInContent(HAND_AUTHORED_DISABLED).outcome).toBe("already-disabled");
});
// The previous guard was `content.includes("\nenabled: false\n")`, which a
// trailing space defeats. It then inserted a SECOND `enabled: false`, and
// duplicate map keys make the whole file unparseable — so an agent the user
// asked to disable disappeared from `/agents` entirely (since #212 an
// unparseable agent file is skipped, not surfaced).
it("never writes a file the loader cannot parse", () => {
for (const src of [
"---\ndescription: x\nenabled: false \n---\nbody\n",
"---\ndescription: x\nenabled: false\t\n---\nbody\n",
HAND_AUTHORED_DISABLED,
EXTENSION_WRITTEN_DISABLED,
ENABLED,
]) {
const { content } = disableInContent(src);
expect(() => parseFrontmatter(content), JSON.stringify(src)).not.toThrow();
expect(loaderSeesDisabled(content), JSON.stringify(src)).toBe(true);
}
});
it("reports no-frontmatter rather than claiming success on a fence-less file", () => {
const src = "Just a body, no frontmatter at all.\n";
const { content, outcome } = disableInContent(src);
expect(outcome).toBe("no-frontmatter");
expect(content).toBe(src);
});
it("disables a CRLF file instead of misreporting it as frontmatter-less", () => {
const src = "---\r\ndescription: Scout\r\n---\r\n\r\nBody.\r\n";
const { content, outcome } = disableInContent(src);
expect(outcome).toBe("disabled");
expect(loaderSeesDisabled(content)).toBe(true);
});
it("reports no-frontmatter for a BOM-prefixed file, matching what the loader sees", () => {
// The parser doesn't accept a BOM either — it reports an empty frontmatter
// and treats the whole file as body. So refusing here is agreement with the
// read side, not a gap: inserting the key would have no effect on loading.
const src = "---\ndescription: Scout\n---\n\nBody.\n";
expect(parseFrontmatter<Record<string, unknown>>(src).frontmatter).toEqual({});
const { content, outcome } = disableInContent(src);
expect(outcome).toBe("no-frontmatter");
expect(content).toBe(src);
});
});
describe("isDisabledContent", () => {
it("sees the key at the first frontmatter line", () => {
expect(isDisabledContent(EXTENSION_WRITTEN_DISABLED)).toBe(true);
});
it("sees the key mid-block", () => {
expect(isDisabledContent(HAND_AUTHORED_DISABLED)).toBe(true);
});
it("sees the key in a CRLF file", () => {
expect(isDisabledContent("---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n")).toBe(true);
});
it("is false for an enabled file", () => {
expect(isDisabledContent(ENABLED)).toBe(false);
});
// Detection is a read, so it asks the loader's parser instead of mirroring it.
// A mirror has to be right about two independent things, and a regex was wrong
// about both: YAML's other spellings of `false`, and pi's fence scan, which
// closes the block at any line *starting* `---` — so `----` ends it early and
// the keys after it are body text, not frontmatter. Getting that backwards told
// a user their running agent was "already disabled".
it.each([
["lowercase bare false", "---\ndescription: x\nenabled: false\n---\nbody\n", true],
["False", "---\ndescription: x\nenabled: False\n---\nbody\n", true],
["FALSE", "---\ndescription: x\nenabled: FALSE\n---\nbody\n", true],
["trailing comment", "---\ndescription: x\nenabled: false # off for now\n---\nbody\n", true],
["quoted key", '---\ndescription: x\n"enabled": false\n---\nbody\n', true],
["trailing whitespace", "---\ndescription: x\nenabled: false \n---\nbody\n", true],
["'----' closes the block early", "---\ndescription: x\n----\nenabled: false\n---\nbody\n", false],
["'--- x' closes the block early", "---\ndescription: x\n--- x\nenabled: false\n---\nbody\n", false],
["quoted string, not a boolean", '---\ndescription: x\nenabled: "false"\n---\nbody\n', false],
["YAML 1.1 'no' is a string here", "---\ndescription: x\nenabled: no\n---\nbody\n", false],
["key only in the body", "---\ndescription: x\n---\nenabled: false\n", false],
])("agrees with the loader: %s", (_label, content, expected) => {
expect(loaderSeesDisabled(content)).toBe(expected); // premise: what the loader concludes
expect(isDisabledContent(content)).toBe(expected); // we must conclude the same
});
});
// The defect, stated as an invariant rather than as a list of shapes: the two
// sides must never disagree about whether a file is disabled. Whatever the
// loader reads as disabled, `/agents → Enable` has to be able to re-enable.
describe("read and write paths agree", () => {
const shapes: Array<[string, string]> = [
["key first", EXTENSION_WRITTEN_DISABLED],
["key after description", HAND_AUTHORED_DISABLED],
["key last", "---\ndescription: Scout\ndisplay_name: S\nenabled: false\n---\n\nBody.\n"],
["CRLF", "---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n"],
];
for (const [label, content] of shapes) {
it(`a file the loader reads as disabled can be enabled — ${label}`, () => {
expect(loaderSeesDisabled(content)).toBe(true); // premise: the loader agrees it's disabled
expect(enableInContent(content).changed).toBe(true);
});
}
it("disable → enable round-trips to the original hand-authored file", () => {
const disabled = disableInContent(ENABLED);
expect(disabled.outcome).toBe("disabled");
expect(enableInContent(disabled.content).content).toBe(ENABLED);
});
});
describe("isEmptyStub", () => {
it("recognizes the stub /agents writes to disable a built-in default", () => {
expect(isEmptyStub("---\n---\n")).toBe(true);
expect(isEmptyStub(enableInContent("---\nenabled: false\n---\n").content)).toBe(true);
});
it("is false for a file with real frontmatter", () => {
expect(isEmptyStub(ENABLED)).toBe(false);
});
});
describe("findAgentFile", () => {
let tmpDir: string;
let agentDir: string;
let prevAgentDir: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-toggle-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-toggle-agentdir-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
});
afterEach(() => {
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
rmSync(tmpDir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
});
function write(dir: string, name: string) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${name}.md`), ENABLED);
}
it("prefers .pi/agents over .agents/agents and the personal dir", () => {
write(join(tmpDir, ".pi", "agents"), "scout");
write(join(tmpDir, ".agents", "agents"), "scout");
write(join(agentDir, "agents"), "scout");
expect(findAgentFile("scout", tmpDir)).toEqual({
path: join(tmpDir, ".pi", "agents", "scout.md"),
location: "project",
});
});
it("falls back to the workspace .agents/agents dir", () => {
write(join(tmpDir, ".agents", "agents"), "scout");
write(join(agentDir, "agents"), "scout");
expect(findAgentFile("scout", tmpDir)).toEqual({
path: join(tmpDir, ".agents", "agents", "scout.md"),
location: "workspace",
});
});
it("falls back to the personal agent dir", () => {
write(join(agentDir, "agents"), "scout");
expect(findAgentFile("scout", tmpDir)).toEqual({
path: join(agentDir, "agents", "scout.md"),
location: "personal",
});
});
it("returns undefined when the agent has no file anywhere", () => {
expect(findAgentFile("nope", tmpDir)).toBeUndefined();
});
// An agent's type comes from its frontmatter `name:` now, so `<type>.md` is a
// guess. Getting it wrong is not a harmless miss: `/agents → Disable` takes
// the no-file branch and writes a NEW stub, which loses to the real file on
// load — so the agent stays enabled while the toast reports success.
describe("locateAgentFile", () => {
it("uses the file the loader read, whatever it is called", () => {
write(join(tmpDir, ".pi", "agents"), "reviewer");
const sourcePath = join(tmpDir, ".pi", "agents", "reviewer.md");
expect(locateAgentFile("code-reviewer", sourcePath, tmpDir)).toEqual({
path: sourcePath,
location: "project",
});
});
it("classifies a workspace and a personal source path", () => {
write(join(tmpDir, ".agents", "agents"), "reviewer");
write(join(agentDir, "agents"), "auditor");
expect(locateAgentFile("code-reviewer", join(tmpDir, ".agents", "agents", "reviewer.md"), tmpDir))
.toMatchObject({ location: "workspace" });
expect(locateAgentFile("code-auditor", join(agentDir, "agents", "auditor.md"), tmpDir))
.toMatchObject({ location: "personal" });
});
it("falls back to the <type>.md probe for a built-in with no source file", () => {
write(join(tmpDir, ".pi", "agents"), "scout");
expect(locateAgentFile("scout", undefined, tmpDir)).toEqual({
path: join(tmpDir, ".pi", "agents", "scout.md"),
location: "project",
});
});
it("falls back when the recorded path has since been deleted", () => {
write(join(tmpDir, ".pi", "agents"), "scout");
expect(locateAgentFile("scout", join(tmpDir, ".pi", "agents", "gone.md"), tmpDir)).toEqual({
path: join(tmpDir, ".pi", "agents", "scout.md"),
location: "project",
});
});
it("finds nothing when neither the source path nor the probe resolves", () => {
expect(locateAgentFile("nope", join(tmpDir, ".pi", "agents", "gone.md"), tmpDir)).toBeUndefined();
});
});
});
// `/agents → Create agent → Manual` writes an agent file from free-text prompts.
// The description is whatever the user typed into `ctx.ui.input("Description
// (one line)")` — no validation, no escaping — and it was interpolated straight
// into a YAML scalar.
//
// That matters more than a formatting nit because of how a broken agent file is
// handled since #212: it is SKIPPED with a warning (or aborts startup under
// strictAgentFiles). So the wizard reports "Created <path>", and the agent the
// user just built silently does not exist.
describe("buildNewAgentFile", () => {
const base = { tools: "read, grep", systemPrompt: "Do the thing.", description: "Scout" };
/** What the loader makes of the generated file. */
const parse = (content: string) => parseFrontmatter<Record<string, unknown>>(content).frontmatter;
it("round-trips an ordinary description", () => {
expect(parse(buildNewAgentFile(base)).description).toBe("Scout");
});
it("survives a description containing a colon", () => {
// "Scout: find things" is an entirely natural thing to type, and an
// unquoted YAML scalar treats the colon as a nested mapping — the parser
// throws and the whole file is unloadable.
const content = buildNewAgentFile({ ...base, description: "Scout: find things" });
expect(() => parse(content)).not.toThrow();
expect(parse(content).description).toBe("Scout: find things");
});
it("keeps a description containing a # instead of truncating it", () => {
// `#` opens a YAML comment, so "audit #security" silently becomes "audit".
const content = buildNewAgentFile({ ...base, description: "audit #security" });
expect(parse(content).description).toBe("audit #security");
});
it("leaves a `provider/model:thinking` suffix intact", () => {
// Not a hazard on its own — YAML splits on ": " (colon then space), so a
// bare `:high` suffix is a valid plain scalar either way. Pinned because
// it's the shape most likely to be typed into the custom-model prompt, and
// quoting must not mangle it.
const content = buildNewAgentFile({ ...base, model: "anthropic/claude-sonnet-4-6:high" });
expect(parse(content).model).toBe("anthropic/claude-sonnet-4-6:high");
});
it("survives a custom model containing a colon-space or a #", () => {
// The custom-model prompt is free text with no validation, so it has the
// same two hazards the description does.
for (const model of ["anthropic/foo: bar", "anthropic/x #c"]) {
const content = buildNewAgentFile({ ...base, model });
expect(() => parse(content), model).not.toThrow();
expect(parse(content).model, model).toBe(model);
}
});
it("emits the fields the wizard collects, and omits the ones left on inherit", () => {
const full = parse(buildNewAgentFile({ ...base, model: "anthropic/x", thinking: "high" }));
expect(full).toMatchObject({ tools: "read, grep", model: "anthropic/x", thinking: "high", prompt_mode: "replace" });
const minimal = parse(buildNewAgentFile(base));
expect(minimal.model).toBeUndefined();
expect(minimal.thinking).toBeUndefined();
});
it("keeps the system prompt as the body", () => {
const content = buildNewAgentFile({ ...base, systemPrompt: "Line 1.\n\nLine 2." });
expect(parseFrontmatter<Record<string, unknown>>(content).body).toBe("Line 1.\n\nLine 2.");
});
});
+323
View File
@@ -0,0 +1,323 @@
// The 10-minute record GC (AgentManager.cleanup) has never run in a test: the
// main agent-manager suite uses real timers throughout, and nothing calls the
// private method. Its two guards are load-bearing in opposite directions —
// inverting the cutoff disposes results the LLM hasn't read yet, and dropping
// the running/queued skip disposes a LIVE agent's session mid-run.
//
// It lives in its own file because vi.useFakeTimers() has to be installed
// BEFORE `new AgentManager()` (the constructor starts the interval), and fake
// timers are hostile to the promise-settling style of the main suite.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentManager } from "../src/agent-manager.js";
vi.mock("../src/agent-runner.js", () => ({
runAgent: vi.fn(),
resumeAgent: vi.fn(),
}));
vi.mock("../src/worktree.js", () => ({
createWorktree: vi.fn(),
cleanupWorktree: vi.fn(() => ({ hasChanges: false })),
pruneWorktrees: vi.fn(),
}));
import { runAgent } from "../src/agent-runner.js";
const mockPi = {} as any;
const mockCtx = { cwd: "/tmp" } as any;
const TEN_MINUTES = 10 * 60_000;
const TICK = 60_000;
describe("AgentManager — record GC", () => {
let manager: AgentManager;
beforeEach(() => {
// Before construction: the cleanup interval is started in the constructor.
vi.useFakeTimers();
});
afterEach(() => {
manager?.dispose();
vi.useRealTimers();
});
/** Spawn a background agent and settle it, returning its id and record. */
async function settled(prompt: string) {
vi.mocked(runAgent).mockResolvedValue({
responseText: "done",
session: { dispose: vi.fn() } as any,
aborted: false,
steered: false,
} as any);
manager ??= new AgentManager();
const id = manager.spawn(mockPi, mockCtx, "X", prompt, { description: prompt, isBackground: true });
await manager.getRecord(id)!.promise;
return { id, record: manager.getRecord(id)! };
}
it("keeps a record that completed inside the retention window", async () => {
manager = new AgentManager();
const { id, record } = await settled("recent");
// Age at sweep time is (TEN_MINUTES - 2*TICK) + TICK — just inside the window.
// Advancing the timers moves Date.now() too, so the margin has to outlast it.
record.completedAt = Date.now() - (TEN_MINUTES - 2 * TICK);
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.getRecord(id)).toBeDefined();
});
it("evicts a record that completed before the cutoff and disposes its session", async () => {
manager = new AgentManager();
const { id, record } = await settled("stale");
const dispose = vi.fn();
record.session = { dispose } as any;
record.completedAt = Date.now() - (TEN_MINUTES + 30_000);
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.getRecord(id)).toBeUndefined();
expect(manager.listAgents().map(a => a.id)).not.toContain(id);
expect(dispose).toHaveBeenCalled();
});
it("closes the evicted session's extension lifecycle before disposing it (#242)", async () => {
// The reported crash, on its own path: this sweep is what fires ~10 min after a
// subagent finishes. Disposing only invalidates the ExtensionRunner, so whatever
// an extension armed in `session_start` stayed armed — and its next tick threw
// `assertActive()` from a bare timer callback, killing interactive pi.
manager = new AgentManager();
const { id, record } = await settled("stale");
const emit = vi.fn(async () => {});
const dispose = vi.fn();
record.session = {
dispose,
extensionRunner: { hasHandlers: (event: string) => event === "session_shutdown", emit },
} as any;
record.completedAt = Date.now() - (TEN_MINUTES + 30_000);
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.getRecord(id)).toBeUndefined();
expect(emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
// After dispose() the runner is invalidated and every ctx getter throws, so an
// emit that landed afterwards would be worse than none.
expect(emit.mock.invocationCallOrder[0]).toBeLessThan(dispose.mock.invocationCallOrder[0]);
});
it("never evicts a running agent, however old its timestamp looks", async () => {
// A live agent's session being disposed mid-run is the worst failure this
// guard prevents, and `completedAt` on a running record is meaningless.
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
manager = new AgentManager();
const id = manager.spawn(mockPi, mockCtx, "X", "live", { description: "live", isBackground: true });
const record = manager.getRecord(id)!;
expect(record.status).toBe("running");
record.completedAt = Date.now() - 10 * TEN_MINUTES;
const dispose = vi.fn();
record.session = { dispose } as any;
await vi.advanceTimersByTimeAsync(TICK * 5);
expect(manager.getRecord(id)).toBeDefined();
expect(dispose).not.toHaveBeenCalled();
});
it("never evicts a queued agent", async () => {
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
manager = new AgentManager(undefined, 1);
manager.spawn(mockPi, mockCtx, "X", "holder", { description: "holder", isBackground: true });
const queuedId = manager.spawn(mockPi, mockCtx, "X", "waiter", { description: "waiter", isBackground: true });
const queued = manager.getRecord(queuedId)!;
expect(queued.status).toBe("queued");
queued.completedAt = Date.now() - 10 * TEN_MINUTES;
await vi.advanceTimersByTimeAsync(TICK * 5);
expect(manager.getRecord(queuedId)?.status).toBe("queued");
});
it("sweeps repeatedly, not just once", async () => {
// The interval must keep firing: a record that ages past the cutoff on a
// later tick has to be collected too.
manager = new AgentManager();
const { id, record } = await settled("ages-out");
record.completedAt = Date.now() - (TEN_MINUTES - 3 * TICK);
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.getRecord(id)).toBeDefined(); // still inside the window
await vi.advanceTimersByTimeAsync(TICK * 4);
expect(manager.getRecord(id)).toBeUndefined(); // aged out on a later tick
});
});
// Eviction is exactly the moment a handle would otherwise stop working. These
// cover what outlives it: enough to find the agent's session on disk and
// reopen the conversation, and the name held so nothing else claims it.
describe("AgentManager — tombstones outliving the GC", () => {
let manager: AgentManager;
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
manager?.dispose();
vi.useRealTimers();
});
/** Spawn, settle, and age past the cutoff so the next tick evicts it. */
async function evictable(type: string, prompt: string, sessionFile?: string) {
vi.mocked(runAgent).mockResolvedValue({
responseText: "done",
session: { dispose: vi.fn() } as any,
aborted: false,
steered: false,
} as any);
manager ??= new AgentManager();
const id = manager.spawn(mockPi, mockCtx, type, prompt, { description: prompt, isBackground: true });
const record = manager.getRecord(id)!;
await record.promise;
record.sessionFile = sessionFile;
record.completedAt = Date.now() - (TEN_MINUTES + 30_000);
return { id, record };
}
it("keeps an evicted agent reachable by name when its session is on disk", async () => {
manager = new AgentManager();
await evictable("Explore", "audit the RPC path", "/sessions/explore.jsonl");
await vi.advanceTimersByTimeAsync(TICK);
const resolved = manager.resolveMention("explore");
expect(resolved?.kind).toBe("tombstone");
expect(resolved).toMatchObject({
entry: { handle: "explore", type: "Explore", description: "audit the RPC path", sessionFile: "/sessions/explore.jsonl" },
});
});
it("leaves nothing behind when the session was only ever in memory", async () => {
// Without a file there is no conversation to reopen, so promising a resume
// would be a lie — the mention has to fall through to starting a new agent.
manager = new AgentManager();
await evictable("Explore", "ephemeral", undefined);
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.resolveMention("explore")).toBeUndefined();
expect(manager.listTombstones()).toHaveLength(0);
});
it("holds the evicted handle so a later agent of the same type can't shadow it", async () => {
manager = new AgentManager();
await evictable("Explore", "first", "/sessions/first.jsonl");
await vi.advanceTimersByTimeAsync(TICK);
const id = manager.spawn(mockPi, mockCtx, "Explore", "second", { description: "second", isBackground: true });
expect(manager.getRecord(id)!.handle).toBe("explore-2");
// ...and `@explore` still means the conversation you can resume.
expect(manager.resolveMention("explore")?.kind).toBe("tombstone");
});
it("prefers a live agent over a tombstone holding the same name", async () => {
manager = new AgentManager();
await evictable("Explore", "old", "/sessions/old.jsonl");
await vi.advanceTimersByTimeAsync(TICK);
// Give the live agent the tombstone's name directly: the collision this
// guards against is resolution order, not allocation.
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
const id = manager.spawn(mockPi, mockCtx, "Explore", "live", { description: "live", isBackground: true });
manager.getRecord(id)!.handle = "explore";
const resolved = manager.resolveMention("explore");
expect(resolved?.kind).toBe("live");
expect(resolved).toMatchObject({ record: { id } });
});
it("drops the oldest once the cap is reached, keeping the most recent", async () => {
manager = new AgentManager();
for (let i = 0; i < 101; i++) {
const { record } = await evictable("Explore", `run-${i}`, `/sessions/${i}.jsonl`);
// Distinct ages so "oldest" is well defined; run-0 is the oldest. Set on
// the record we just made — under fake timers every startedAt is equal,
// so listAgents() has no meaningful order to index into.
record.completedAt = Date.now() - (TEN_MINUTES + 101_000 - i * 1000);
}
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.listTombstones()).toHaveLength(100);
expect(manager.resolveMention("explore")).toBeUndefined(); // run-0's handle
expect(manager.resolveMention("explore-101")?.kind).toBe("tombstone");
});
it("hands a reclaimed name straight back, numbering nothing", async () => {
// The resume path's whole contract: `handleBase(type)` cannot reproduce a
// numbered handle, so the spawn takes the tombstone's names verbatim.
manager = new AgentManager();
await evictable("Explore", "first", "/sessions/first.jsonl");
manager.spawn(mockPi, mockCtx, "Explore", "second", { description: "second", isBackground: true });
await vi.advanceTimersByTimeAsync(TICK);
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
const id = manager.spawn(mockPi, mockCtx, "Explore", "resumed", {
description: "first",
isBackground: true,
reclaim: { handle: "explore", alias: "auth-audit" },
} as any);
expect(manager.getRecord(id)).toMatchObject({ handle: "explore", alias: "auth-audit" });
});
it("ignores a reclaim on a nested child, which has no name to hold", async () => {
manager = new AgentManager();
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
const id = manager.spawn(mockPi, mockCtx, "Explore", "child", {
description: "child",
parentAgentId: "parent-id",
reclaim: { handle: "explore", alias: "auth-audit" },
} as any);
expect(manager.getRecord(id)).toMatchObject({ handle: undefined, alias: undefined });
});
it("stops answering a name once its tombstone is dropped", async () => {
manager = new AgentManager();
await evictable("Explore", "done", "/sessions/done.jsonl");
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.resolveMention("explore")?.kind).toBe("tombstone");
manager.dropTombstone("explore");
expect(manager.resolveMention("explore")).toBeUndefined();
expect(manager.listTombstones()).toHaveLength(0);
});
it("frees a dropped name for the next agent of that type", async () => {
// Otherwise a resumed agent's own handle stays reserved by the corpse and
// every later spawn climbs: explore-2, explore-3, …
manager = new AgentManager();
await evictable("Explore", "done", "/sessions/done.jsonl");
await vi.advanceTimersByTimeAsync(TICK);
manager.dropTombstone("explore");
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
const id = manager.spawn(mockPi, mockCtx, "Explore", "next", { description: "next", isBackground: true });
expect(manager.getRecord(id)!.handle).toBe("explore");
});
it("forgets every name when the session ends", async () => {
// A handle from a conversation the user has left must not resolve, or
// `@explore` reaches an agent they have no memory of starting.
manager = new AgentManager();
await evictable("Explore", "prior session", "/sessions/prior.jsonl");
await vi.advanceTimersByTimeAsync(TICK);
expect(manager.listTombstones()).toHaveLength(1);
manager.clearCompleted(true);
expect(manager.listTombstones()).toHaveLength(0);
expect(manager.resolveMention("explore")).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,616 @@
/**
* agent-mention-provider.test.ts — the `@handle` suggestions stacked on pi's
* built-in autocomplete.
*
* The provider sits in front of file completion and must not take it away: `@`
* still means "attach a file" in pi, and agents are additive, so a token that
* matches both lists them both — agents first, then pi's rows, under one
* `prefix`. The risks are at the seam: dropping pi's list when an agent matches
* (which is what made a bare `@` stop offering files), inserting the wrong span
* because the two providers disagreed about the token, and claiming a token that
* was only ever a path. Every case below pins one of those.
*/
import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import type { AgentManager } from "../src/agent-manager.js";
import type { AgentRecord, AgentTombstone } from "../src/types.js";
import { createMentionProvider, mentionRoster } from "../src/ui/agent-mention.js";
const FILE_SUGGESTIONS = { items: [{ value: "@src/index.ts", label: "src/index.ts" }], prefix: "@src/" };
/** A stand-in for pi's CombinedAutocompleteProvider. */
function builtIn() {
return {
triggerCharacters: ["@", "#"],
getSuggestions: vi.fn().mockResolvedValue(FILE_SUGGESTIONS),
applyCompletion: vi.fn().mockReturnValue({ lines: ["applied"], cursorLine: 0, cursorCol: 7 }),
shouldTriggerFileCompletion: vi.fn().mockReturnValue(false),
};
}
function record(over: Partial<AgentRecord>): AgentRecord {
return {
id: `id-${over.handle}`,
type: "Explore",
description: "find flaky tests",
status: "running",
toolUses: 0,
startedAt: 1000,
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
compactionCount: 0,
...over,
} as AgentRecord;
}
function managerWith(...records: AgentRecord[]): AgentManager {
// listAgents() is newest-first, matching the real manager's contract.
return {
listAgents: () => [...records].sort((a, b) => b.startedAt - a.startedAt),
listTombstones: () => [],
} as unknown as AgentManager;
}
/** A manager holding only evicted agents, to exercise the resume rows. */
function managerWithTombstones(...entries: AgentTombstone[]): AgentManager {
return {
listAgents: () => [],
listTombstones: () => [...entries].sort((a, b) => b.completedAt - a.completedAt),
} as unknown as AgentManager;
}
function tombstone(over: Partial<AgentTombstone> = {}): AgentTombstone {
return {
handle: "explore",
id: "t1",
type: "Explore" as AgentTombstone["type"],
description: "audit the RPC path",
sessionFile: "/sessions/explore.jsonl",
completedAt: 5000,
...over,
};
}
/** Ask for suggestions on a single line, cursor at the end. */
const suggest = (provider: ReturnType<typeof createMentionProvider>, line: string) =>
provider.getSuggestions([line], 0, line.length, { signal: new AbortController().signal });
/**
* Just the rows we contributed. pi's stub rows are dropped by identity, so a
* test about handle ordering stays about handle ordering — without pretending
* no file matched, which is the state that hid this bug in the first place.
*/
const agentRows = (result: Awaited<ReturnType<typeof suggest>>) =>
(result?.items ?? []).filter(item => !(FILE_SUGGESTIONS.items as unknown[]).includes(item));
describe("agent suggestions", () => {
it("lists matching handles above pi's files rather than instead of them", async () => {
const current = builtIn();
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
const result = await suggest(provider, "@ex");
expect(result).toEqual({
items: [
{ value: "@explore", label: "@explore", description: "send message · running · find flaky tests" },
...FILE_SUGGESTIONS.items,
],
// Ours, not the stub's: when both match, both describe the same span (see
// the real-provider describe below), and ours is the authority on where
// the handle token starts.
prefix: "@ex",
});
// Verbatim: the inner provider has to see the same line, cursor and options
// the editor handed us, or its rows describe a different token than ours.
expect(current.getSuggestions).toHaveBeenCalledWith(["@ex"], 0, 3, expect.objectContaining({ signal: expect.anything() }));
});
it("offers every agent on a bare @, and still offers files", async () => {
// The regression this file exists for: an empty token prefix-matches EVERY
// handle, so suppressing files "when an agent matches" suppressed them on
// the one gesture people use to browse — `@` alone.
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWith(record({ handle: "explore" }), record({ handle: "plan", startedAt: 2000 })), []),
() => true,
);
const result = await suggest(provider, "@");
expect(result?.items.map(i => i.value)).toEqual(["@explore", "@plan", "@src/index.ts"]);
});
it("offers agents alone when no file matched", async () => {
const current = builtIn();
current.getSuggestions.mockResolvedValue(null);
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect(await suggest(provider, "@ex")).toEqual({
items: [{ value: "@explore", label: "@explore", description: "send message · running · find flaky tests" }],
prefix: "@ex",
});
});
it("still offers agents when the wrapped provider throws", async () => {
// We now call the inner provider for tokens we used to answer alone, and it
// is not always pi's — an extension registered before us sits inside. A
// rejection there must not delete the handle rows too.
const current = builtIn();
current.getSuggestions.mockRejectedValue(new Error("inner provider exploded"));
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect(await suggest(provider, "@ex")).toEqual({
items: [{ value: "@explore", label: "@explore", description: "send message · running · find flaky tests" }],
prefix: "@ex",
});
});
it("still offers agents when the wrapped provider throws synchronously", async () => {
// The async case above is caught by any `.catch()`; this one is not — a sync
// throw never yields a promise to attach to, so only try/catch holds it.
const current = builtIn();
current.getSuggestions.mockImplementation(() => { throw new Error("sync boom"); });
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect(await suggest(provider, "@ex")).toEqual({
items: [{ value: "@explore", label: "@explore", description: "send message · running · find flaky tests" }],
prefix: "@ex",
});
});
it("warns once about a failing inner provider, not once per keystroke", async () => {
// getSuggestions runs on every character after `@`; a log per call would
// bury the terminal. But swallowing it silently leaves a broken provider
// with no trace anywhere, since the popup just looks file-less.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const current = builtIn();
current.getSuggestions.mockRejectedValue(new Error("inner provider exploded"));
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
for (const line of ["@e", "@ex", "@exp"]) await suggest(provider, line);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("[pi-subagents]");
} finally {
warn.mockRestore();
}
});
it("returns pi's list untouched when no agent exists at all", async () => {
const provider = createMentionProvider(builtIn(), () => [], () => true);
expect(await suggest(provider, "@ex")).toBe(FILE_SUGGESTIONS);
});
it("puts steerable agents first, then earliest-launched", async () => {
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWith(
record({ handle: "explore", status: "completed", startedAt: 1000 }),
record({ handle: "explore-3", status: "running", startedAt: 3000 }),
record({ handle: "explore-2", status: "running", startedAt: 2000 }),
), []),
() => true,
);
const result = await suggest(provider, "@ex");
expect(agentRows(result).map(i => i.value)).toEqual(["@explore-2", "@explore-3", "@explore"]);
});
it("matches case-insensitively", async () => {
const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect(agentRows(await suggest(provider, "@EX")).map(i => i.value)).toEqual(["@explore"]);
});
it("completes a mention typed mid-message", async () => {
// The trigger fires at any token boundary, even though only a LEADING
// mention is actually sent — same split as Claude Code.
const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect((await suggest(provider, "ask @ex"))?.prefix).toBe("@ex");
});
});
describe("agents that have never run", () => {
const TYPES = [
{ name: "Explore", description: "Fast codebase exploration. Read-only, medium breadth." },
{ name: "code-review", description: "Reviews a diff." },
];
it("offers a registered type with no live instance, and says it will start one", async () => {
const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(), TYPES), () => true);
const result = await suggest(provider, "@ex");
expect(agentRows(result)).toEqual([{
value: "@explore",
label: "@explore",
description: "start agent · Fast codebase exploration.",
}]);
// Kept from before the merge: the span the rows are inserted over is the
// typed token, not the stub's "@src/".
expect(result?.prefix).toBe("@ex");
});
it("lets a live agent own its handle instead of listing the type twice", async () => {
// `@explore` has to mean the running Explore, or the mention would start a
// second one while the first is mid-task.
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWith(record({ handle: "explore" })), TYPES),
() => true,
);
const result = await suggest(provider, "@ex");
expect(agentRows(result).map(i => i.value)).toEqual(["@explore"]);
expect(agentRows(result)[0].description).toBe("send message · running · find flaky tests");
});
it("still offers the type once its only instance has finished, as a resume", async () => {
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWith(record({ handle: "explore", status: "completed" })), TYPES),
() => true,
);
expect((await suggest(provider, "@ex"))?.items[0].description)
.toBe("resume · completed · find flaky tests");
});
it("lists live agents before startable types", async () => {
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWith(record({ handle: "plan" })), TYPES),
() => true,
);
expect(agentRows(await suggest(provider, "@")).map(i => i.value))
.toEqual(["@plan", "@explore", "@code-review"]);
});
});
describe("delegation to pi's provider", () => {
it("delegates when no handle matches the typed prefix", async () => {
const current = builtIn();
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect(await suggest(provider, "@zz")).toBe(FILE_SUGGESTIONS);
expect(current.getSuggestions).toHaveBeenCalled();
});
it("delegates a path-shaped token even when its first segment names an agent", async () => {
const current = builtIn();
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect(await suggest(provider, "@explore/notes.md")).toBe(FILE_SUGGESTIONS);
expect(current.getSuggestions).toHaveBeenCalled();
});
it("delegates an @ that is not at a token boundary", async () => {
const current = builtIn();
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
expect(await suggest(provider, "mail@ex")).toBe(FILE_SUGGESTIONS);
});
it("never claims nested children — nothing can address them", async () => {
const current = builtIn();
const nested = record({ handle: "explore", parentAgentId: "parent-1" });
const provider = createMentionProvider(current, () => mentionRoster(managerWith(nested), []), () => true);
expect(await suggest(provider, "@ex")).toBe(FILE_SUGGESTIONS);
});
it("delegates everything while mentions are disabled", async () => {
const current = builtIn();
const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => false);
expect(await suggest(provider, "@ex")).toBe(FILE_SUGGESTIONS);
});
});
describe("composing with another extension's provider", () => {
// pi folds every registered wrapper over the base provider in registration
// order (interactive-mode.js:428), so an extension is either inside us or
// outside us depending on load order. Both directions have to work, and
// neither is under our control.
const TYPES = [{ name: "Explore", description: "Fast codebase exploration." }];
/** A foreign wrapper that owns `#` and delegates everything else. */
function hashWrapper(current: any) {
return {
triggerCharacters: ["#"],
getSuggestions: vi.fn(async (lines: string[], line: number, col: number, opts: any) =>
/(^|\s)#\w*$/.test(lines[line].slice(0, col))
? { items: [{ value: "#general", label: "#general" }], prefix: "#" }
: current.getSuggestions(lines, line, col, opts)),
applyCompletion: vi.fn((...args: any[]) => (current.applyCompletion as any)(...args)),
};
}
it("delegates to a provider registered before us", async () => {
const base = builtIn();
const provider = createMentionProvider(
hashWrapper(base) as any,
() => mentionRoster(managerWith(), TYPES),
() => true,
);
// ours wins for @, the inner wrapper still owns #, files still reach base
expect(agentRows(await suggest(provider, "@ex")).map(i => i.value)).toEqual(["@explore"]);
expect((await suggest(provider, "#gen"))?.items.map(i => i.value)).toEqual(["#general"]);
expect(await suggest(provider, "@src/")).toBe(FILE_SUGGESTIONS);
});
it("is still reachable through a provider registered after us", async () => {
const base = builtIn();
const ours = createMentionProvider(base, () => mentionRoster(managerWith(), TYPES), () => true);
const outer = hashWrapper(ours) as any;
expect(agentRows(await outer.getSuggestions(["@ex"], 0, 3, { signal: new AbortController().signal }))
.map(i => i.value)).toEqual(["@explore"]);
expect((await outer.getSuggestions(["#g"], 0, 2, { signal: new AbortController().signal }))
?.items.map((i: any) => i.value)).toEqual(["#general"]);
});
it("lets pi union the chain's trigger characters, as it already does", () => {
// Replicates setupAutocompleteProvider (interactive-mode.js:428): pi folds
// the wrappers over the base, collecting each one's OWN characters, then
// stamps the union on the outermost. Declaring the inner set ourselves
// would duplicate that and misreport what we handle.
const wrappers = [
(current: any) => hashWrapper(current),
(current: any) => createMentionProvider(current, () => mentionRoster(managerWith(), TYPES), () => true),
];
let provider: any = builtIn();
const collected: string[] = [];
for (const wrap of wrappers) {
provider = wrap(provider);
collected.push(...(provider.triggerCharacters ?? []));
}
expect(provider.triggerCharacters).toEqual(["@"]); // ours declares only @
expect([...new Set(collected)]).toEqual(["#", "@"]); // pi still ends up with both
});
it("can be rebuilt from the same factory without accumulating state", () => {
// Every later addAutocompleteProvider call re-runs the whole chain from a
// fresh base, so our factory is invoked again each time.
const roster = () => mentionRoster(managerWith(record({ handle: "explore" })), []);
const first = createMentionProvider(builtIn(), roster, () => true);
const second = createMentionProvider(builtIn(), roster, () => true);
expect(second.triggerCharacters).toEqual(first.triggerCharacters);
});
});
describe("against pi's real provider", () => {
// The mocks above pin our own branching; these pin the contract itself, by
// handing our items and prefix to the actual CombinedAutocompleteProvider.
// Getting `prefix` wrong by a character silently eats input on completion.
const real = () => new CombinedAutocompleteProvider([], process.cwd(), null);
const provider = () =>
createMentionProvider(real(), () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true);
it("inserts the handle and a trailing space at the start of a line", async () => {
const p = provider();
const suggestions = (await suggest(p, "@ex"))!;
expect(p.applyCompletion(["@ex"], 0, 3, suggestions.items[0], suggestions.prefix)).toEqual({
lines: ["@explore "],
cursorLine: 0,
cursorCol: 9,
});
});
it("replaces only the mention token when it sits mid-line", async () => {
const p = provider();
const suggestions = (await suggest(p, "ask @ex"))!;
expect(p.applyCompletion(["ask @ex"], 0, 7, suggestions.items[0], suggestions.prefix)).toEqual({
lines: ["ask @explore "],
cursorLine: 0,
cursorCol: 13,
});
});
it("keeps text after the cursor intact", async () => {
const p = provider();
const suggestions = (await suggest(p, "@ex"))!;
expect(p.applyCompletion(["@ex please"], 0, 3, suggestions.items[0], suggestions.prefix).lines)
.toEqual(["@explore please"]);
});
it("inserts a FILE-shaped row from OUR prefix, character for character", async () => {
// The merge ships both kinds of row under one `prefix`, so pi's real
// applyCompletion has to land a path from a span our regex measured. Off by
// one and it eats a character or leaves an `@` behind — silently.
//
// The file row is synthesized rather than discovered: pi's
// getFuzzyFileSuggestions returns [] the moment `fdPath` is null
// (pi-tui/dist/autocomplete.js:577), which is how every real-provider test
// here constructs it, and `fd` cannot be assumed on CI. What that leaves
// unverified is only pi's own token scan — `extractAtPrefix` takes the whole
// token after the last delimiter, and we only ever claim tokens of `[\w-]`,
// so the two spans cannot disagree where both produce rows.
const p = provider();
const suggestions = (await suggest(p, "@ex"))!;
const fileRow = { value: "@examples/agent-tool-description.md", label: "agent-tool-description.md" };
expect(p.applyCompletion(["@ex"], 0, 3, fileRow, suggestions.prefix).lines)
.toEqual(["@examples/agent-tool-description.md "]);
expect(p.applyCompletion(["ask @ex now"], 0, 7, fileRow, suggestions.prefix).lines)
.toEqual(["ask @examples/agent-tool-description.md now"]);
});
it("declares a trigger character the editor will actually accept", () => {
// editor.js:1851 drops multi-char entries, "/" and whitespace silently.
for (const character of provider().triggerCharacters ?? []) {
expect(character.length).toBe(1);
expect(character).not.toBe("/");
expect(character.trim()).toBe(character);
}
});
});
describe("insertion and trigger plumbing", () => {
it("hands applyCompletion to pi — its @-branch already inserts value plus a space", () => {
const current = builtIn();
const provider = createMentionProvider(current, () => mentionRoster(managerWith(), []), () => true);
const item = { value: "@explore", label: "@explore" };
expect(provider.applyCompletion(["@ex"], 0, 3, item, "@ex")).toEqual({
lines: ["applied"],
cursorLine: 0,
cursorCol: 7,
});
expect(current.applyCompletion).toHaveBeenCalledWith(["@ex"], 0, 3, item, "@ex");
});
it("keeps pi's file-completion gate rather than forcing it open", () => {
const current = builtIn();
const provider = createMentionProvider(current, () => mentionRoster(managerWith(), []), () => true);
expect(provider.shouldTriggerFileCompletion?.(["/model"], 0, 6)).toBe(false);
});
it("declares @ and nothing else", () => {
// `#` is pi's other default trigger, not ours — and the editor seeds its
// own set from DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS (editor.js:1849)
// and only adds, so claiming it here would gain nothing and mean nothing.
const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(), []), () => true);
expect(provider.triggerCharacters).toEqual(["@"]);
});
});
describe("named agents and evicted ones", () => {
it("lists a named agent once, under its alias, and says what type it is", async () => {
// Two rows for one agent would read as two agents; and `@auth-audit`
// alone says nothing about what it is, which the handle used to carry.
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWith(record({ handle: "explore", alias: "auth-audit", type: "Explore", status: "running", description: "audit the auth flow" })), []),
() => true,
);
expect(agentRows(await suggest(provider, "@"))).toEqual([
{ value: "@auth-audit", label: "@auth-audit", description: "send message · Explore · running · audit the auth flow" },
]);
});
it("leaves an unnamed agent's row free of a redundant type", async () => {
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWith(record({ handle: "explore", type: "Explore", status: "running", description: "find flaky tests" })), []),
() => true,
);
expect((await suggest(provider, "@"))!.items[0].description).toBe("send message · running · find flaky tests");
});
it("still resolves the unlisted type handle of a named agent", () => {
// The row shows the alias, but `@explore` must keep reaching this agent —
// that is what stops it from starting a second Explore.
const roster = mentionRoster(
managerWith(record({ handle: "explore", alias: "auth-audit", type: "Explore", status: "running" })),
[{ name: "Explore", description: "search" }],
);
// The type is NOT offered as startable: its handle belongs to the live agent.
expect(roster.map(t => t.handle)).toEqual(["auth-audit"]);
});
it("offers an evicted agent as a resume, after the live ones", async () => {
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWithTombstones(tombstone()), []),
() => true,
);
expect(agentRows(await suggest(provider, "@"))).toEqual([
{ value: "@explore", label: "@explore", description: "resume · Explore · audit the RPC path" },
]);
});
it("puts live agents ahead of resumable ones", async () => {
// A running agent is the likelier target, and a resume is the slower,
// more surprising action to land on by pressing Enter too quickly.
const manager = {
listAgents: () => [record({ handle: "plan", type: "Plan", status: "running" })],
listTombstones: () => [tombstone()],
} as unknown as AgentManager;
const provider = createMentionProvider(builtIn(), () => mentionRoster(manager, []), () => true);
expect(agentRows(await suggest(provider, "@")).map(i => i.value)).toEqual(["@plan", "@explore"]);
});
it("keeps an aliased tombstone's type handle reserved too", () => {
// It lists under its alias, but `@explore` still resumes it — so the
// Explore type must not also be offered as startable under that name.
const roster = mentionRoster(
managerWithTombstones(tombstone({ handle: "explore", alias: "auth-audit" })),
[{ name: "Explore", description: "search" }],
);
expect(roster.map(t => t.handle)).toEqual(["auth-audit"]);
});
it("does not offer a startable type whose handle a tombstone still holds", () => {
// `@explore` resumes the old conversation, so advertising "start agent"
// under the same name would promise the wrong action.
const roster = mentionRoster(managerWithTombstones(tombstone()), [{ name: "Explore", description: "search" }]);
expect(roster).toHaveLength(1);
expect(roster[0].kind).toBe("tombstone");
});
});
// FleetView, the widget, the tool header and the conversation viewer all render
// `display_name` (via getConfig). The popup rendering the raw type instead would
// make `@` the one surface that calls the same agent something else.
describe("rows carry the display name, not the raw type", () => {
const label = (type: string) => (type === "Explore" ? "Auth Auditor" : type);
it("names an aliased agent by its label", async () => {
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(
managerWith(record({ handle: "explore", alias: "auth-audit", type: "Explore", status: "running", description: "audit the auth flow" })),
[],
label,
),
() => true,
);
expect((await suggest(provider, "@"))!.items[0].description)
.toBe("send message · Auth Auditor · running · audit the auth flow");
});
it("names a resumable agent by its label", async () => {
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWithTombstones(tombstone()), [], label),
() => true,
);
expect((await suggest(provider, "@"))!.items[0].description)
.toBe("resume · Auth Auditor · audit the RPC path");
});
it("falls back to the raw type when no resolver is supplied", async () => {
// Which is also what getConfig does for an agent with no display_name, so
// the default keeps a caller without a registry honest rather than blank.
const provider = createMentionProvider(
builtIn(),
() => mentionRoster(managerWithTombstones(tombstone()), []),
() => true,
);
expect((await suggest(provider, "@"))!.items[0].description).toContain("· Explore ·");
});
});
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
/**
* agent-runner-e2e.test.ts — End-to-end test against the REAL pi-mono runtime.
*
* Every other agent-runner test mocks `@earendil-works/pi-coding-agent`: it
* asserts that `runAgent` hands the right `tools:` allowlist to a *simulated*
* `createAgentSession`. That proves our allowlist math, but not the assumption
* the math rests on — that real pi-mono actually gates a session to that
* allowlist, admitting extension-registered tools (the #47 fix) and dropping
* the rest.
*
* This test closes that loop with NO pi-mono mock:
* - a real extension fixture (`fixtures/e2e-probe-ext.mjs`) registers a tool,
* - the real `DefaultResourceLoader` loads it via `additionalExtensionPaths`,
* - the real `createAgentSession` builds the session,
* - we read the real `session.getActiveToolNames()` at `onSessionCreated`
* (fires after construction, before any prompt) and assert what the LLM
* would actually be allowed to call.
*
* No network/LLM: a faux Model object satisfies `createAgentSession`'s `model`
* param, and we never depend on a turn completing — the assertion is on the
* gated tool set, which is fixed at construction. (Driving a live faux model
* through `session.prompt()` is intentionally avoided: under Vite the faux
* provider registers in a different `pi-ai` module instance than the one
* pi-coding-agent streams through, which is brittle and orthogonal to gating.)
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { extensionCanonicalName, runAgent } from "../src/agent-runner.js";
import { registerAgents } from "../src/agent-types.js";
import type { AgentConfig } from "../src/types.js";
import { registerFauxProvider } from "./helpers/pi-ai.js";
// These tests spin up the REAL pi-mono runtime (loader + dynamic extension
// import + session construction), so a cold first run under full-suite CPU
// contention can exceed vitest's 5s default. Give the file generous headroom —
// a genuine hang still fails, just later.
vi.setConfig({ testTimeout: 30_000 });
const FIXTURE = resolve(fileURLToPath(new URL("./fixtures/e2e-probe-ext.mjs", import.meta.url)));
/** The fixture registers exactly this tool. */
const EXT_TOOL = "e2e_probe";
const BUILTINS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
/** Minimal `pi` stub — `detectEnv` only needs `exec` (returns non-git). */
function makePi() {
return { exec: async () => ({ code: 1, stdout: "", stderr: "" }) } as any;
}
describe("agent-runner end-to-end (real pi-mono session + real extension)", () => {
let cwd: string;
let faux: ReturnType<typeof registerFauxProvider>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "subagents-e2e-"));
// Only used as a valid Model object for createAgentSession; we never rely
// on it actually streaming (we assert on the pre-prompt gated tool set).
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
});
afterEach(() => {
faux.unregister();
rmSync(cwd, { recursive: true, force: true });
});
/**
* Register `cfg` as agent type "e2e", run it through the REAL runAgent, and
* return the real session's active tool names captured at construction time.
*/
async function activeToolsFor(
cfg: Partial<AgentConfig>,
mandatoryExtensionPaths?: readonly string[],
): Promise<string[]> {
registerAgents(
new Map([
[
"e2e",
{
name: "e2e",
description: "e2e",
builtinToolNames: BUILTINS,
skills: false,
systemPrompt: "You are e2e.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
...cfg,
} as AgentConfig,
],
]),
);
const model = faux.getModel();
const modelRegistry: any = {
find: () => model,
getAll: () => [model],
getAvailable: () => [model],
hasConfiguredAuth: () => true,
isUsingOAuth: () => false,
getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }),
registerProvider: () => {},
unregisterProvider: () => {},
};
const ctx: any = { cwd, getSystemPrompt: () => "PARENT", model, modelRegistry };
let active: string[] = [];
try {
await runAgent(ctx, "e2e", "go", {
pi: makePi(),
model,
mandatoryExtensionPaths,
onSessionCreated: (s) => {
active = s.getActiveToolNames();
},
});
} catch {
// A no-op/erroring prompt turn is fine — the gated tool set is fixed at
// construction, which `onSessionCreated` already captured.
}
return active;
}
it("real pi-mono admits an extension-registered tool when it's in the allowlist (#47)", async () => {
const active = await activeToolsFor({ extensions: [FIXTURE] });
// The extension actually loaded and its tool reached the live session.
expect(active).toContain(EXT_TOOL);
for (const b of BUILTINS) expect(active).toContain(b);
});
it("an extension tool is absent when extensions are disabled (not loaded)", async () => {
const active = await activeToolsFor({ extensions: false });
expect(active).not.toContain(EXT_TOOL);
for (const b of BUILTINS) expect(active).toContain(b);
});
it("loads an exact mandatory extension under extensions:false but keeps its tool hidden", async () => {
const active = await activeToolsFor({ extensions: false }, [FIXTURE]);
// Reaching onSessionCreated proves exact-path post-reload verification passed
// against the real loader. Mandatory lifecycle must not imply tool visibility.
expect(active).not.toContain(EXT_TOOL);
for (const b of BUILTINS) expect(active).toContain(b);
});
it("disallowedTools removes a real extension tool from the live session", async () => {
const active = await activeToolsFor({ extensions: [FIXTURE], disallowedTools: [EXT_TOOL] });
expect(active).not.toContain(EXT_TOOL); // loaded, then denied at construction
expect(active).toContain("read");
});
it("the ext: allowlist flip mutes a loaded-but-unselected extension in real pi-mono", async () => {
// Extension loads (extensions: [FIXTURE]), but a single ext: selector for a
// *different* name flips extension tools to an allowlist — the unselected
// fixture contributes nothing, even though it loaded and ran its handlers.
const active = await activeToolsFor({ extensions: [FIXTURE], extSelectors: ["ext:not-the-fixture"] });
expect(active).not.toContain(EXT_TOOL);
for (const b of BUILTINS) expect(active).toContain(b);
});
it("an ext: selector surfaces the loaded extension's tool through the flip", async () => {
// Derive the canonical name the loader/selector matcher uses, so the test
// tracks `extensionCanonicalName` rather than hard-coding a filename form.
const canon = extensionCanonicalName(FIXTURE);
const active = await activeToolsFor({
extensions: [FIXTURE],
builtinToolNames: ["read"],
extSelectors: [`ext:${canon}`],
});
expect(active).toContain(EXT_TOOL); // selected → surfaces despite the flip
expect(active).toContain("read");
expect(active).not.toContain("bash"); // builtinToolNames: ["read"] only
});
});
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
getDefaultMaxTurns,
getGraceTurns,
normalizeMaxTurns,
setDefaultMaxTurns,
setGraceTurns,
} from "../src/agent-runner.js";
describe("setDefaultMaxTurns / getDefaultMaxTurns", () => {
beforeEach(() => {
setDefaultMaxTurns(undefined);
});
it("defaults to undefined (unlimited)", () => {
expect(getDefaultMaxTurns()).toBeUndefined();
});
it("stores a positive integer", () => {
setDefaultMaxTurns(30);
expect(getDefaultMaxTurns()).toBe(30);
});
it("accepts boundary value 1", () => {
setDefaultMaxTurns(1);
expect(getDefaultMaxTurns()).toBe(1);
});
it("treats 0 as unlimited", () => {
setDefaultMaxTurns(0);
expect(getDefaultMaxTurns()).toBeUndefined();
});
it("clamps negative values to 1", () => {
setDefaultMaxTurns(-10);
expect(getDefaultMaxTurns()).toBe(1);
});
it("undefined resets to unlimited after being set", () => {
setDefaultMaxTurns(50);
expect(getDefaultMaxTurns()).toBe(50);
setDefaultMaxTurns(undefined);
expect(getDefaultMaxTurns()).toBeUndefined();
});
});
describe("normalizeMaxTurns", () => {
it("treats undefined as unlimited", () => {
expect(normalizeMaxTurns(undefined)).toBeUndefined();
});
it("treats 0 as unlimited", () => {
expect(normalizeMaxTurns(0)).toBeUndefined();
});
it("keeps positive values", () => {
expect(normalizeMaxTurns(7)).toBe(7);
});
it("clamps negative values to 1", () => {
expect(normalizeMaxTurns(-3)).toBe(1);
});
});
describe("setGraceTurns / getGraceTurns", () => {
beforeEach(() => {
setGraceTurns(5);
});
it("round-trips the value this suite set up", () => {
// NOT a default assertion — the beforeEach above sets 5, so this only
// proves the setter/getter pair agree. The real module default is asserted
// against a freshly-imported module in test/documented-defaults.test.ts.
expect(getGraceTurns()).toBe(5);
});
it("stores a positive integer", () => {
setGraceTurns(10);
expect(getGraceTurns()).toBe(10);
});
it("accepts boundary value 1", () => {
setGraceTurns(1);
expect(getGraceTurns()).toBe(1);
});
it("clamps 0 to 1", () => {
setGraceTurns(0);
expect(getGraceTurns()).toBe(1);
});
it("clamps negative values to 1", () => {
setGraceTurns(-5);
expect(getGraceTurns()).toBe(1);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
/**
* agent-startup-error.test.ts — a spawn that never starts must fail the tool
* call, not return a message (#179).
*
* The assertion is `rejects`, and that is the whole point: pi marks a tool
* result failed only when `execute` throws (`isError` on a returned result is
* discarded), so a returned diagnostic reaches the parent model as a subagent
* that ran and reported this — and the model retries the same doomed call.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/worktree.js", async () => {
const actual = await vi.importActual<typeof import("../src/worktree.js")>("../src/worktree.js");
return { ...actual, createWorktree: vi.fn(() => undefined) };
});
import subagentsExtension from "../src/index.js";
function boot() {
const tools = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn(),
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
subagentsExtension(pi);
return tools;
}
let cwd: string;
let originalCwd: string;
let originalAgentDir: string | undefined;
let originalHome: string | undefined;
function ctx() {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd,
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
describe("Agent startup failures fail the tool call (#179)", () => {
beforeEach(() => {
originalCwd = process.cwd();
cwd = mkdtempSync(join(tmpdir(), "startup-error-"));
process.chdir(cwd);
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
originalHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = join(cwd, "agent-dir");
process.env.HOME = cwd;
});
afterEach(() => {
delete (globalThis as any)[Symbol.for("pi-subagents:manager")];
process.chdir(originalCwd);
if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
if (originalHome == null) delete process.env.HOME;
else process.env.HOME = originalHome;
rmSync(cwd, { recursive: true, force: true });
});
for (const background of [false, true]) {
it(`rejects instead of returning the diagnostic (run_in_background: ${background})`, async () => {
const tools = boot();
await expect(
tools.get("Agent").execute(
"tc-1",
{
prompt: "do it",
description: "worktree probe",
subagent_type: "general-purpose",
isolation: "worktree",
run_in_background: background,
},
undefined, undefined, ctx(),
),
).rejects.toThrow('Cannot run with isolation: "worktree"');
});
}
});
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import subagentsExtension from "../src/index.js";
function agentTool() {
const tools = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((tool: any) => tools.set(tool.name, tool)),
registerCommand: vi.fn(),
on: vi.fn(),
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
subagentsExtension(pi);
return tools.get("Agent");
}
const theme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
} as any;
function render(tool: any, result: any): string {
return tool.renderResult(
{ content: result.content, details: result.details },
{ expanded: false, isPartial: false },
theme,
{ isError: result.isError },
).render(120).join("\n");
}
describe("Agent tool invocation error rendering", () => {
it("shows a Pi tool error instead of structured terminal status", () => {
const output = render(agentTool(), {
content: [{ type: "text", text: 'Cannot run with isolation: "worktree" — Git probe failed.' }],
isError: true,
details: { status: "aborted" },
});
expect(output).toContain('Cannot run with isolation: "worktree" — Git probe failed.');
expect(output).not.toContain("Aborted (max turns exceeded)");
});
it.each([
["missing details", undefined],
["empty details", {}],
["unknown status", { status: "unknown" }],
["a status with no rendering of its own", { status: "queued" }],
])("shows the real result text for %s", (_name, details) => {
const output = render(agentTool(), {
content: [{ type: "text", text: "Unstructured Agent result." }],
isError: false,
details,
});
expect(output).toContain("Unstructured Agent result.");
expect(output).not.toContain("Aborted (max turns exceeded)");
});
});
+493
View File
@@ -0,0 +1,493 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
BUILTIN_TOOL_NAMES,
getAgentConfig,
getAvailableTypes,
getConfig,
getDefaultAgentNames,
getMemoryToolNames,
getReadOnlyMemoryToolNames,
getToolNamesForType,
getUserAgentNames,
isDefaultsDisabled,
isValidType,
NO_FALLBACK,
registerAgents,
resolveEnabledTypeIn,
resolveSpawnType,
resolveSpawnTypeIn,
resolveType,
setDefaultsDisabled,
setFallbackSubagent,
} from "../src/agent-types.js";
import { DEFAULT_AGENTS } from "../src/default-agents.js";
import type { AgentConfig } from "../src/types.js";
function makeAgentConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
return {
name: "test-agent",
description: "Test agent",
builtinToolNames: ["read", "grep"],
extensions: false,
skills: false,
systemPrompt: "You are a test agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
...overrides,
};
}
describe("agent type registry", () => {
beforeEach(() => {
registerAgents(new Map());
});
describe("default agents", () => {
it("recognizes all default agent types", () => {
expect(isValidType("general-purpose")).toBe(true);
expect(isValidType("Explore")).toBe(true);
expect(isValidType("Plan")).toBe(true);
});
it("does not include removed agents", () => {
expect(isValidType("statusline-setup")).toBe(false);
expect(isValidType("claude-code-guide")).toBe(false);
});
it("rejects unknown types", () => {
expect(isValidType("nonexistent")).toBe(false);
expect(isValidType("")).toBe(false);
});
it("case-insensitive lookup works for isValidType", () => {
expect(isValidType("explore")).toBe(true);
expect(isValidType("EXPLORE")).toBe(true);
expect(isValidType("General-Purpose")).toBe(true);
expect(isValidType("plan")).toBe(true);
});
it("case-insensitive lookup works for getAgentConfig", () => {
const config = getAgentConfig("explore");
expect(config?.name).toBe("Explore");
expect(config?.model).toBe("anthropic/claude-haiku-4-5");
});
it("resolveType returns canonical key or undefined", () => {
expect(resolveType("Explore")).toBe("Explore");
expect(resolveType("explore")).toBe("Explore");
expect(resolveType("GENERAL-PURPOSE")).toBe("general-purpose");
expect(resolveType("nonexistent")).toBeUndefined();
});
it("returns correct config for default types", () => {
const config = getConfig("general-purpose");
expect(config.displayName).toBe("Agent");
expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
expect(config.extensions).toBe(true);
expect(config.skills).toBe(true);
});
it("Explore has read-only tools", () => {
const config = getConfig("Explore");
expect(config.builtinToolNames).toEqual(["read", "bash", "grep", "find", "ls"]);
expect(config.builtinToolNames).not.toContain("edit");
expect(config.builtinToolNames).not.toContain("write");
});
it("Explore has haiku model in config", () => {
const cfg = getAgentConfig("Explore");
expect(cfg?.model).toBe("anthropic/claude-haiku-4-5");
});
it("default agents are marked isDefault", () => {
const cfg = getAgentConfig("general-purpose");
expect(cfg?.isDefault).toBe(true);
});
// Regression guard for #37 — default agents must not bake in callsite-strategy fields.
// An explicit `false` here would silently win over the caller's `true` via `??` in
// resolveAgentInvocationConfig, breaking documented Agent tool params.
it("default agents do not lock strategy fields (run_in_background / inherit_context / isolated)", () => {
for (const name of ["general-purpose", "Explore", "Plan"]) {
const cfg = getAgentConfig(name);
expect(cfg?.runInBackground, `${name}.runInBackground`).toBeUndefined();
expect(cfg?.inheritContext, `${name}.inheritContext`).toBeUndefined();
expect(cfg?.isolated, `${name}.isolated`).toBeUndefined();
}
});
it("getDefaultAgentNames returns default agent names", () => {
const names = getDefaultAgentNames();
expect(names).toContain("general-purpose");
expect(names).toContain("Explore");
expect(names).toContain("Plan");
});
it("BUILTIN_TOOL_NAMES includes all built-in tools", () => {
expect(BUILTIN_TOOL_NAMES).toContain("read");
expect(BUILTIN_TOOL_NAMES).toContain("bash");
expect(BUILTIN_TOOL_NAMES).toContain("edit");
expect(BUILTIN_TOOL_NAMES).toContain("write");
expect(BUILTIN_TOOL_NAMES).toContain("grep");
expect(BUILTIN_TOOL_NAMES).toContain("find");
expect(BUILTIN_TOOL_NAMES).toContain("ls");
expect(BUILTIN_TOOL_NAMES.length).toBeGreaterThanOrEqual(7);
});
});
describe("disable defaults", () => {
// Module-level flag — always reset so later describes see the default roster.
afterEach(() => {
setDefaultsDisabled(false);
registerAgents(new Map());
});
it("defaults to enabled", () => {
expect(isDefaultsDisabled()).toBe(false);
});
it("registerAgents skips DEFAULT_AGENTS when disabled", () => {
setDefaultsDisabled(true);
registerAgents(new Map());
expect(getAvailableTypes()).toEqual([]);
expect(isValidType("general-purpose")).toBe(false);
expect(isValidType("Explore")).toBe(false);
expect(isValidType("Plan")).toBe(false);
});
it("user agents are unaffected when defaults are disabled", () => {
setDefaultsDisabled(true);
registerAgents(new Map([["auditor", makeAgentConfig({ name: "auditor" })]]));
expect(getAvailableTypes()).toEqual(["auditor"]);
expect(isValidType("auditor")).toBe(true);
expect(getDefaultAgentNames()).toEqual([]);
});
it("re-enabling restores defaults on next registerAgents", () => {
setDefaultsDisabled(true);
registerAgents(new Map());
expect(isValidType("general-purpose")).toBe(false);
setDefaultsDisabled(false);
registerAgents(new Map());
expect(isValidType("general-purpose")).toBe(true);
expect(isValidType("Explore")).toBe(true);
expect(isValidType("Plan")).toBe(true);
});
it("getConfig falls back to the hardcoded config when defaults are disabled and no user agents exist", () => {
setDefaultsDisabled(true);
registerAgents(new Map());
const config = getConfig("general-purpose");
expect(config.displayName).toBe("Agent");
expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
expect(config.promptMode).toBe("append");
});
});
describe("user agents", () => {
it("registers and retrieves user agents", () => {
const agents = new Map([["auditor", makeAgentConfig({ name: "auditor", description: "Auditor" })]]);
registerAgents(agents);
expect(isValidType("auditor")).toBe(true);
expect(getAgentConfig("auditor")?.description).toBe("Auditor");
});
it("includes user agents in available types", () => {
const agents = new Map([["auditor", makeAgentConfig({ name: "auditor" })]]);
registerAgents(agents);
const types = getAvailableTypes();
expect(types).toContain("general-purpose");
expect(types).toContain("Explore");
expect(types).toContain("auditor");
});
it("lists user agent names separately", () => {
const agents = new Map([
["auditor", makeAgentConfig({ name: "auditor" })],
["reviewer", makeAgentConfig({ name: "reviewer" })],
]);
registerAgents(agents);
const names = getUserAgentNames();
expect(names).toEqual(["auditor", "reviewer"]);
expect(names).not.toContain("general-purpose");
});
it("getConfig returns config for user agents", () => {
const agents = new Map([["auditor", makeAgentConfig({
name: "auditor",
description: "Security auditor",
builtinToolNames: ["read", "grep"],
extensions: false,
skills: true,
})]]);
registerAgents(agents);
const config = getConfig("auditor");
expect(config.displayName).toBe("auditor");
expect(config.description).toBe("Security auditor");
expect(config.builtinToolNames).toEqual(["read", "grep"]);
expect(config.extensions).toBe(false);
expect(config.skills).toBe(true);
});
it("getConfig returns extension allowlist for user agents", () => {
const agents = new Map([["partial", makeAgentConfig({
name: "partial",
extensions: ["web-search"],
skills: ["planning"],
})]]);
registerAgents(agents);
const config = getConfig("partial");
expect(config.extensions).toEqual(["web-search"]);
expect(config.skills).toEqual(["planning"]);
});
it("getToolNamesForType works for user agents", () => {
const agents = new Map([["auditor", makeAgentConfig({
name: "auditor",
builtinToolNames: ["read", "grep", "find"],
})]]);
registerAgents(agents);
const names = getToolNamesForType("auditor");
expect(names).toEqual(["read", "grep", "find"]);
});
it("getToolNamesForType honors an explicit empty builtinToolNames as zero built-ins", () => {
// `tools: none` and `tools:` with only `ext:` entries both produce `[]`.
const agents = new Map([["ext-only", makeAgentConfig({
name: "ext-only",
builtinToolNames: [],
})]]);
registerAgents(agents);
expect(getToolNamesForType("ext-only")).toEqual([]);
});
it("getConfig falls back to general-purpose for unknown types", () => {
const config = getConfig("nonexistent");
expect(config.displayName).toBe("Agent");
expect(config.description).toBe(DEFAULT_AGENTS.get("general-purpose")?.description);
});
it("clearing user agents works (defaults remain)", () => {
const agents = new Map([["auditor", makeAgentConfig({ name: "auditor" })]]);
registerAgents(agents);
expect(isValidType("auditor")).toBe(true);
registerAgents(new Map());
expect(isValidType("auditor")).toBe(false);
expect(isValidType("general-purpose")).toBe(true);
});
it("user agent overrides default with same name", () => {
const agents = new Map([["Explore", makeAgentConfig({
name: "Explore",
description: "Custom Explore",
builtinToolNames: BUILTIN_TOOL_NAMES,
})]]);
registerAgents(agents);
const config = getConfig("Explore");
expect(config.description).toBe("Custom Explore");
expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
});
it("disabled agent is excluded from available types", () => {
const agents = new Map([["Plan", makeAgentConfig({
name: "Plan",
enabled: false,
})]]);
registerAgents(agents);
expect(isValidType("Plan")).toBe(false);
expect(getAvailableTypes()).not.toContain("Plan");
});
it("general-purpose can be disabled but fallback still works", () => {
const agents = new Map([["general-purpose", makeAgentConfig({
name: "general-purpose",
enabled: false,
})]]);
registerAgents(agents);
expect(isValidType("general-purpose")).toBe(false);
// getConfig fallback should still return something reasonable
const config = getConfig("general-purpose");
expect(config.displayName).toBe("Agent");
});
});
describe("getMemoryToolNames", () => {
it("returns read, write, edit when none exist", () => {
const names = getMemoryToolNames(new Set());
expect(names).toContain("read");
expect(names).toContain("write");
expect(names).toContain("edit");
expect(names).toHaveLength(3);
});
it("skips tools that already exist", () => {
const names = getMemoryToolNames(new Set(["read", "edit"]));
expect(names).toEqual(["write"]);
});
it("returns empty when all memory tools already exist", () => {
const names = getMemoryToolNames(new Set(["read", "write", "edit"]));
expect(names).toHaveLength(0);
});
});
describe("getReadOnlyMemoryToolNames", () => {
it("returns only read when missing", () => {
const names = getReadOnlyMemoryToolNames(new Set());
expect(names).toEqual(["read"]);
});
it("returns empty when read already exists", () => {
const names = getReadOnlyMemoryToolNames(new Set(["read"]));
expect(names).toHaveLength(0);
});
});
describe("BUILTIN_TOOL_NAMES", () => {
// BUILTIN_TOOL_NAMES is derived dynamically from pi's tool factories
// (createCodingTools + createReadOnlyTools). This guards against pi-mono
// dropping/renaming a built-in: the set must still contain at least these
// 7. It's a superset check ("at least") — pi adding a new built-in is fine
// and won't fail this test.
const EXPECTED = ["read", "bash", "edit", "write", "grep", "find", "ls"];
it("contains at least the 7 known built-ins", () => {
for (const name of EXPECTED) {
expect(BUILTIN_TOOL_NAMES).toContain(name);
}
});
it("has no duplicate entries", () => {
expect(new Set(BUILTIN_TOOL_NAMES).size).toBe(BUILTIN_TOOL_NAMES.length);
});
});
});
describe("resolveSpawnType — fail-closed dispatch (#183)", () => {
afterEach(() => {
setFallbackSubagent(undefined);
setDefaultsDisabled(false);
registerAgents(new Map());
});
const roster = () => new Map([
["scout", makeAgentConfig({ name: "scout" })],
["retired", makeAgentConfig({ name: "retired", enabled: false })],
["router", makeAgentConfig({ name: "router" })],
]);
it("resolves an enabled type case-insensitively", () => {
registerAgents(roster());
expect(resolveSpawnType("SCOUT")).toEqual({ ok: true, type: "scout" });
});
it("falls back to general-purpose when unset, reporting what was asked for", () => {
registerAgents(roster());
expect(resolveSpawnType("typoo")).toEqual({
ok: true, type: "general-purpose", fellBackFrom: "typoo",
});
});
it("rejects unknown types under `none` and names what is available", () => {
registerAgents(roster());
setFallbackSubagent(NO_FALLBACK);
const r = resolveSpawnType("typoo");
expect(r.ok).toBe(false);
if (r.ok) throw new Error("expected rejection");
expect(r.message).toContain('Unknown or disabled agent type: "typoo"');
expect(r.message).toContain("scout");
expect(r.message).not.toContain("retired"); // disabled agents aren't offered
});
it("treats a disabled type as unresolvable, not as a valid name", () => {
// Regression: the old path used resolveType(), which ignores `enabled`, so a
// disabled agent dispatched with its own prompt and general-purpose's tools.
registerAgents(roster());
expect(resolveSpawnType("retired")).toEqual({
ok: true, type: "general-purpose", fellBackFrom: "retired",
});
setFallbackSubagent(NO_FALLBACK);
expect(resolveSpawnType("retired").ok).toBe(false);
});
it("refuses to guess between two types differing only by case", () => {
registerAgents(new Map([
["Scout", makeAgentConfig({ name: "Scout" })],
["scout", makeAgentConfig({ name: "scout" })],
]));
// An exact match is still unambiguous...
expect(resolveSpawnType("scout")).toEqual({ ok: true, type: "scout" });
// ...but a differently-cased spelling matches both, so it must not pick one.
expect(resolveSpawnType("SCOUT").ok).toBe(true);
expect(resolveSpawnType("SCOUT")).toEqual({
ok: true, type: "general-purpose", fellBackFrom: "SCOUT",
});
});
it("routes unresolvable types to a named fallback agent", () => {
registerAgents(roster());
setFallbackSubagent("router");
expect(resolveSpawnType("typoo")).toEqual({
ok: true, type: "router", fellBackFrom: "typoo",
});
});
it("fails loudly when the configured fallback is itself unusable", () => {
// Explicit configuration that cannot work is a misconfiguration, not a
// second chance to guess — never a silent drop to general-purpose.
registerAgents(roster());
setFallbackSubagent("retired");
const r = resolveSpawnType("typoo");
expect(r.ok).toBe(false);
if (r.ok) throw new Error("expected rejection");
expect(r.message).toContain("fallbackSubagent");
});
it("treats a missing type like any other unresolvable one", () => {
// Before this setting existed an empty type fell back like a typo; only
// opting in should change that, so the default must stay permissive.
registerAgents(roster());
for (const empty of ["", " ", undefined]) {
expect(resolveSpawnType(empty)).toMatchObject({ ok: true, type: "general-purpose" });
}
setFallbackSubagent(NO_FALLBACK);
for (const empty of ["", " ", undefined]) {
const r = resolveSpawnType(empty);
expect(r.ok).toBe(false);
if (r.ok) throw new Error("expected rejection");
expect(r.message).toContain("No agent type given");
}
});
it("resolves strictly regardless of the setting, for nested delegation", () => {
// Nested delegation uses this seam so a project-level fallback can't hand a
// nested caller an agent its allowlist never named.
const registry = roster();
setFallbackSubagent("router");
expect(resolveEnabledTypeIn(registry, "typoo")).toBeUndefined();
expect(resolveEnabledTypeIn(registry, "retired")).toBeUndefined();
expect(resolveEnabledTypeIn(registry, " SCOUT ")).toBe("scout");
// ...while the policy layer still honors it.
expect(resolveSpawnTypeIn(registry, "typoo")).toEqual({
ok: true, type: "router", fellBackFrom: "typoo",
});
});
});
+446
View File
@@ -0,0 +1,446 @@
import { describe, expect, it } from "vitest";
import { renderRunningAgentStatus } from "../src/index.js";
import type { WidgetMode } from "../src/types.js";
import { type AgentActivity, AgentWidget, fgPreservingNestedStyles, formatCost, formatSessionTokens } from "../src/ui/agent-widget.js";
describe("formatSessionTokens", () => {
const theme = { fg: (c: string, s: string) => `<${c}>${s}</${c}>`, bold: (s: string) => s };
const ansiTheme = {
fg: (c: string, s: string) => {
const codes: Record<string, string> = { dim: "2", warning: "33", accent: "35" };
return `\u001b[${codes[c] ?? "31"}m${s}\u001b[39m`;
},
bold: (s: string) => s,
};
it("applies threshold colors (<70 dim, 70–85 warning, ≥85 error)", () => {
expect(formatSessionTokens(1234, null, theme)).toBe("1.2k token");
expect(formatSessionTokens(1234, 50, theme)).toBe("1.2k token (<dim>50%</dim>)");
expect(formatSessionTokens(1234, 70, theme)).toBe("1.2k token (<warning>70%</warning>)");
expect(formatSessionTokens(1234, 84, theme)).toBe("1.2k token (<warning>84%</warning>)");
expect(formatSessionTokens(1234, 85, theme)).toBe("1.2k token (<error>85%</error>)");
expect(formatSessionTokens(1234, 99, theme)).toBe("1.2k token (<error>99%</error>)");
});
it("annotates compaction count alongside percent", () => {
// compactions only (e.g. immediately post-compaction, percent null)
expect(formatSessionTokens(1234, null, theme, 1)).toBe("1.2k token (<dim>⇊1</dim>)");
expect(formatSessionTokens(1234, null, theme, 3)).toBe("1.2k token (<dim>⇊3</dim>)");
// percent + compactions, joined with ` · `
expect(formatSessionTokens(1234, 45, theme, 2)).toBe("1.2k token (<dim>45%</dim> · <dim>⇊2</dim>)");
expect(formatSessionTokens(1234, 88, theme, 4)).toBe("1.2k token (<error>88%</error> · <dim>⇊4</dim>)");
// compactions=0 omitted
expect(formatSessionTokens(1234, 45, theme, 0)).toBe("1.2k token (<dim>45%</dim>)");
});
it("preserves the outer style after nested annotation styles reset", () => {
const tokenText = formatSessionTokens(1234, 70, ansiTheme);
expect(fgPreservingNestedStyles(ansiTheme, "accent", tokenText)).toBe(
"\u001b[35m1.2k token (\u001b[33m70%\u001b[39m\u001b[35m)\u001b[39m",
);
});
});
describe("renderRunningAgentStatus", () => {
it("renders running status as separate component lines", () => {
const theme = { fg: (_c: string, s: string) => s };
const component = renderRunningAgentStatus("⠋", "thinking: xhigh · 4 tool uses", "thinking…", theme);
expect(component.render(120).map((line) => line.trimEnd())).toEqual([
"⠋ thinking: xhigh · 4 tool uses",
" ⎿ thinking…",
]);
});
});
describe("AgentWidget", () => {
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
function makeActivity(): AgentActivity {
return {
activeTools: new Map(),
toolUses: 0,
responseText: "",
turnCount: 1,
};
}
function makeRecord(id: string, opts: { isBackground?: boolean; parentAgentId?: string } = {}) {
return {
id,
type: "general-purpose",
description: `${id} description`,
status: "running",
toolUses: 0,
startedAt: Date.now(),
lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compactionCount: 0,
isBackground: opts.isBackground,
parentAgentId: opts.parentAgentId,
};
}
/** Render the widget for a manager and return the produced lines ("" if nothing rendered). */
function renderLines(manager: unknown, activityId: string, mode?: () => WidgetMode): string {
const widget = new AgentWidget(
manager as any,
new Map([[activityId, makeActivity()]]),
mode,
);
let factory: any;
widget.setUICtx({
setStatus: () => {},
setWidget: (_key, content) => { factory = content; },
});
widget.update();
if (!factory) return "";
return factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme)
.render()
.join("\n");
}
// "all" (and the no-policy constructor default) shows every agent.
it("shows foreground agents in 'all' mode (and by default)", () => {
const manager = { listAgents: () => [makeRecord("foreground", { isBackground: false })] };
expect(renderLines(manager, "foreground")).toContain("foreground description");
expect(renderLines(manager, "foreground", () => "all")).toContain("foreground description");
});
it("hides nested children in every coordinator widget mode", () => {
const manager = {
listAgents: () => [makeRecord("nested", { isBackground: true, parentAgentId: "parent" })],
};
expect(renderLines(manager, "nested", () => "all")).toBe("");
expect(renderLines(manager, "nested", () => "background")).toBe("");
});
it("excludes foreground agents in 'background' mode", () => {
const manager = { listAgents: () => [makeRecord("foreground", { isBackground: false })] };
expect(renderLines(manager, "foreground", () => "background")).toBe("");
});
// Also covers scheduler-spawned agents (isBackground=true, no `invocation`
// snapshot): if the filter still keyed off `invocation.runInBackground` —
// #118's original approach — this would wrongly vanish.
it("renders background agents in 'background' mode", () => {
const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] };
const lines = renderLines(manager, "background", () => "background");
expect(lines).toContain("Agents");
expect(lines).toContain("background description");
});
// 'background' excludes only agents *known* to be foreground; one with no
// isBackground flag (e.g. a cross-extension RPC spawn) is kept, not hidden.
it("keeps agents with no isBackground flag in 'background' mode", () => {
const manager = { listAgents: () => [makeRecord("unflagged", {})] };
expect(renderLines(manager, "unflagged", () => "background")).toContain("unflagged description");
});
// "off" hides the widget entirely — even a background agent renders nothing.
it("renders nothing in 'off' mode", () => {
const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] };
expect(renderLines(manager, "background", () => "off")).toBe("");
});
});
// The widget caps itself at MAX_WIDGET_LINES (12) and, past that, hands out a
// line budget in priority order: running pairs, then the queued summary, then
// finished lines. Running and finished increment `hiddenRunning`/`hiddenFinished`
// when they don't fit; the queued line is dropped with NO counter at all, so the
// footer under-reports and — worse — the queue vanishes from the UI entirely.
// That happens exactly when the concurrency limit is saturated, i.e. when the
// queue is the thing the user most needs to see.
describe("formatCost", () => {
it("keeps the precision that distinguishes one run from another", () => {
// Rounding to cents would print the same figure for a run that cost four
// times another — the band most single subagent runs fall in.
expect(formatCost(0.0042)).toBe("~$0.0042");
expect(formatCost(0.0123)).toBe("~$0.0123");
expect(formatCost(1.239)).toBe("~$1.24");
});
it("never pads a round figure with noise, nor cuts it below cents", () => {
expect(formatCost(0.05)).toBe("~$0.05"); // not ~$0.0500
expect(formatCost(0.4)).toBe("~$0.40"); // not ~$0.4
expect(formatCost(12)).toBe("~$12.00");
});
it("shows nothing when there is nothing to show", () => {
// Zero is what a model with no pricing data reports, so `$0.00` would claim
// a measurement that was never made.
expect(formatCost(0)).toBe("");
expect(formatCost(Number.NaN)).toBe("");
expect(formatCost(-1)).toBe("");
});
it("says a real but tiny cost is tiny, not zero", () => {
// The distinction the whole helper turns on: "measured, below what four
// decimals can show" must not render the same as "never measured".
expect(formatCost(0.00002)).toBe("<$0.0001");
expect(formatCost(0)).toBe("");
});
it("marks the figure as an estimate", () => {
// The tilde is the whole disclaimer — it sits beside exact token counts.
expect(formatCost(0.5).startsWith("~")).toBe(true);
});
});
describe("AgentWidget cost display", () => {
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
function render(showCost: boolean, cost: number): string {
const agent = {
id: "a1",
type: "general-purpose",
description: "spending agent",
status: "running",
toolUses: 1,
startedAt: Date.now(),
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost },
compactionCount: 0,
};
// Carries figures of its own, in the shape the tracker used to have: spend
// is read from the record now, so these must not reach the line. Only the
// record accumulates a nested child's spend, and only it outlives the run.
const activity = new Map([["a1", {
activeTools: new Map(),
toolUses: 1,
responseText: "",
turnCount: 1,
lifetimeUsage: { input: 9, output: 9, cacheWrite: 0, cost: 0.9 },
} as unknown as AgentActivity]]);
const widget = new AgentWidget(
{ listAgents: () => [agent] } as any,
activity,
() => "all",
() => showCost,
);
let factory: any;
widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any);
widget.update();
return factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n");
}
it("shows the cost beside the token count when enabled", () => {
const line = render(true, 0.0042);
expect(line).toContain("1.2k token");
expect(line).toContain("~$0.0042");
});
it("shows no cost when disabled", () => {
const line = render(false, 0.0042);
expect(line).toContain("1.2k token");
expect(line).not.toContain("$");
});
it("shows no cost for an unpriced model, even when enabled", () => {
const line = render(true, 0);
expect(line).toContain("1.2k token");
expect(line).not.toContain("$");
});
it("keeps the cost visible after the agent finishes", () => {
// The activity entry is deleted the moment an agent finishes, so a finished
// line reading from it would drop the number precisely when the question
// "what did that cost" gets asked.
const finished = {
id: "a1", type: "general-purpose", description: "done agent", status: "completed",
toolUses: 2, startedAt: Date.now() - 1000, completedAt: Date.now(),
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost: 0.0042 },
compactionCount: 0,
};
const widget = new AgentWidget(
{ listAgents: () => [finished] } as any, new Map(), () => "all", () => true,
);
let factory: any;
widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any);
widget.update();
const out = factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n");
expect(out).toContain("done agent");
expect(out).toContain("~$0.0042");
});
it("shows stats for an agent nobody is tracking live", () => {
// A scheduled agent has no activity entry — it spawns through the manager
// directly — and used to render with no tokens and no cost at all.
const running = {
id: "sched", type: "general-purpose", description: "scheduled agent", status: "running",
toolUses: 1, startedAt: Date.now(),
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost: 0.0042 },
compactionCount: 0,
};
const widget = new AgentWidget(
{ listAgents: () => [running] } as any, new Map(), () => "all", () => true,
);
let factory: any;
widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any);
widget.update();
const out = factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n");
expect(out).toContain("1.2k token");
expect(out).toContain("~$0.0042");
});
it("defaults to hiding it", () => {
const agent = {
id: "a1", type: "general-purpose", description: "d", status: "running",
toolUses: 0, startedAt: Date.now(),
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost: 0.5 }, compactionCount: 0,
};
const activity = new Map([["a1", {
activeTools: new Map(), toolUses: 0, responseText: "", turnCount: 1,
} as AgentActivity]]);
const widget = new AgentWidget({ listAgents: () => [agent] } as any, activity, () => "all");
let factory: any;
widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any);
widget.update();
expect(factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n"))
.not.toContain("$");
});
});
describe("AgentWidget overflow accounting", () => {
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
function record(id: string, status: string) {
return {
id,
type: "general-purpose",
description: `${id} description`,
status,
toolUses: 0,
startedAt: Date.now(),
completedAt: status === "completed" ? Date.now() : undefined,
lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compactionCount: 0,
isBackground: true,
};
}
/** Render a whole fleet (mixed statuses) and return the produced lines. */
function renderFleet(counts: { running: number; queued: number; finished: number }): string[] {
const agents = [
...Array.from({ length: counts.running }, (_, i) => record(`run${i}`, "running")),
...Array.from({ length: counts.queued }, (_, i) => record(`q${i}`, "queued")),
...Array.from({ length: counts.finished }, (_, i) => record(`fin${i}`, "completed")),
];
const activity = new Map(agents.map(a => [a.id, {
activeTools: new Map(),
toolUses: 0,
responseText: "",
turnCount: 1,
} as AgentActivity]));
const widget = new AgentWidget({ listAgents: () => agents } as any, activity, () => "all");
let factory: any;
widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any);
widget.update();
if (!factory) return [];
return factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render();
}
/** The `+N more (…)` footer, if the widget overflowed. */
const footer = (lines: string[]) => lines.find(l => l.includes("more ("));
/** Every fleet shape worth rendering — swept, not sampled. */
const SHAPES: { running: number; queued: number; finished: number }[] = [];
for (let running = 0; running <= 8; running++)
for (let queued = 0; queued <= 8; queued++)
for (let finished = 0; finished <= 8; finished++) SHAPES.push({ running, queued, finished });
// Swept rather than sampled: reserving the queued row moves `budget` around by
// hand, and an off-by-one there overflows the cap only for specific shapes.
it("never exceeds the line cap, for any fleet shape", () => {
for (const counts of SHAPES) {
expect(renderFleet(counts).length, JSON.stringify(counts)).toBeLessThanOrEqual(12);
}
});
it("never prints a footer that miscounts what it hid, for any fleet shape", () => {
for (const counts of SHAPES) {
const f = footer(renderFleet(counts));
if (!f) continue;
const total = Number(/\+(\d+) more/.exec(f)?.[1]);
const where = `${JSON.stringify(counts)} → ${f}`;
// A visible footer means something was dropped, so "+0 more ()" is a lie...
expect(total, where).toBeGreaterThan(0);
// ...and it counts agents that have their own row, so it can never exceed
// them — in particular the queued summary must not be counted as an agent.
expect(total, where).toBeLessThanOrEqual(counts.running + counts.finished);
}
});
it("keeps the queued summary visible when the running agents fill the widget", () => {
// 5 running (10 lines) consume the entire budget, so the queued line is
// dropped — and with it, any sign that 3 agents are waiting to start.
const lines = renderFleet({ running: 5, queued: 3, finished: 1 });
expect(lines.join("\n")).toContain("3 queued");
});
it("counts everything it hid — the footer total matches what is missing", () => {
// Computed rather than hardcoded, so this survives a scenario change but not
// a change to what the footer counts.
const counts = { running: 5, queued: 3, finished: 1 };
const lines = renderFleet(counts);
const body = lines.join("\n");
const shownRunning = counts.running - [...Array(counts.running).keys()]
.filter(i => !body.includes(`run${i} description`)).length;
const shownFinished = counts.finished - [...Array(counts.finished).keys()]
.filter(i => !body.includes(`fin${i} description`)).length;
const actuallyHidden = (counts.running - shownRunning) + (counts.finished - shownFinished);
const reported = Number(/\+(\d+) more/.exec(footer(lines) ?? "")?.[1] ?? -1);
expect(reported).toBe(actuallyHidden);
});
it("gives the queued summary priority over finished lines", () => {
const lines = renderFleet({ running: 4, queued: 2, finished: 3 });
expect(lines.join("\n")).toContain("2 queued");
});
it("renders everything with no footer when the fleet fits", () => {
const lines = renderFleet({ running: 2, queued: 1, finished: 1 });
expect(lines.join("\n")).toContain("1 queued");
expect(footer(lines)).toBeUndefined();
});
// A background resume runs an agent that already finished once. markFinished
// only seeds an age it has not seen before, so without markRunning the agent
// carries its previous run's age — already past the linger limit — and the
// resumed run's ✓ line never renders: the agent just disappears.
it("shows the completion line again after a finished agent is resumed", () => {
const agent = record("resumed", "completed");
const activity = new Map([[agent.id, {
activeTools: new Map(),
toolUses: 0,
responseText: "",
turnCount: 1,
} as AgentActivity]]);
const widget = new AgentWidget({ listAgents: () => [agent] } as any, activity, () => "all");
let factory: any;
widget.setUICtx({ setStatus: () => {}, setWidget: (_k: any, c: any) => { factory = c; } } as any);
const render = () => {
widget.update();
return (factory?.({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render() ?? []).join("\n");
};
// First run finishes and ages out of the widget.
widget.markFinished(agent.id);
widget.onTurnStart();
widget.onTurnStart();
expect(render()).not.toContain("resumed description");
// Background resume puts it back on the running list.
agent.status = "running";
widget.markRunning(agent.id);
expect(render()).toContain("resumed description");
// ...and its completion is visible when the resumed run settles.
agent.status = "completed";
widget.markFinished(agent.id);
expect(render()).toContain("resumed description");
});
});
@@ -0,0 +1,114 @@
/**
* background-by-default.test.ts — the `backgroundByDefault` flip, asserted at
* the tool boundary rather than at the resolver.
*
* `documented-defaults.test.ts` pins `resolveAgentInvocationConfig`'s arguments;
* this pins what the orchestrator actually receives back from a real `Agent`
* call, which is the part the tool description makes promises about:
*
* - an unqualified spawn hands back an ID instead of the agent's output,
* - `run_in_background: false` still blocks and returns the output inline,
* - a fan-out sized like the ones the description tells the model to send
* runs concurrently instead of queueing behind `maxConcurrent`.
*
* That last one is the reason the concurrency default moved 4 → 10: foreground
* agents bypass the pool, so the limit only started applying to ordinary
* parallel work once background became the default.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
function ctx() {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
const textOf = (r: any): string => r.content[0].text;
const settled = (text: string) =>
vi.mocked(runAgent).mockResolvedValue({
responseText: text,
session: { dispose: vi.fn() } as any,
aborted: false,
steered: false,
} as any);
function spawn(tools: Map<string, any>, params: Record<string, unknown> = {}) {
return tools.get("Agent").execute(
"tc",
{ prompt: "go", description: "d", subagent_type: "general-purpose", ...params },
undefined,
undefined,
ctx(),
);
}
describe("backgroundByDefault", () => {
it("returns an agent ID, not the result, when the call doesn't specify", async () => {
const { pi, tools } = makePi();
subagentsExtension(pi);
settled("THE-PAYLOAD");
const out = textOf(await spawn(tools));
expect(out).toContain("Agent ID:");
// The whole point of backgrounding: the orchestrator does NOT get the
// output here — it arrives later as a notification preview.
expect(out).not.toContain("THE-PAYLOAD");
});
it("still blocks and returns the output inline when run_in_background is false", async () => {
const { pi, tools } = makePi();
subagentsExtension(pi);
settled("THE-PAYLOAD");
const out = textOf(await spawn(tools, { run_in_background: false }));
expect(out).toContain("THE-PAYLOAD");
expect(out).not.toContain("started in background");
});
it("starts a six-way fan-out concurrently instead of queueing the tail", async () => {
// Six is the shape the Agent tool description tells the model to send.
// With maxConcurrent at its old 4 this queued two of them.
const { pi, tools } = makePi();
subagentsExtension(pi);
// Never settles — every agent stays occupying its slot for the whole test.
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any);
const outs: string[] = [];
for (let i = 0; i < 6; i++) outs.push(textOf(await spawn(tools)));
expect(outs).toHaveLength(6);
for (const out of outs) expect(out).not.toContain("queued");
});
});
@@ -0,0 +1,287 @@
/**
* Agent tool wiring for `resume` + `run_in_background` (#214).
*
* The manager-level mechanics live in agent-manager.test.ts; what this file
* pins down is what the TOOL hands the manager, which is where a detached
* resume can quietly diverge from a detached spawn.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() };
});
vi.mock("../src/output-file.js", async () => {
const actual = await vi.importActual<typeof import("../src/output-file.js")>("../src/output-file.js");
return {
...actual,
createOutputFilePath: vi.fn(() => "/tmp/fake-subagent.output"),
writeInitialEntry: vi.fn(),
ensureOutputFile: vi.fn(),
streamToOutputFile: vi.fn(() => vi.fn()),
};
});
import { resumeAgent, runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
import { ensureOutputFile, streamToOutputFile, writeInitialEntry } from "../src/output-file.js";
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const emitted: { event: string; payload: any }[] = [];
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((tool: any) => tools.set(tool.name, tool)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn((event: string, payload: any) => { emitted.push({ event, payload }); }),
on: vi.fn(() => vi.fn()),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle, emitted };
}
function makeCtx(cwd: string) {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd,
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "session-1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
/** Flatten a tool result's content blocks to plain text. */
function resultText(result: any): string {
const content = result?.content;
if (Array.isArray(content)) return content.map((c: any) => c?.text ?? "").join("\n");
return String(content ?? result?.text ?? JSON.stringify(result));
}
/** Pull the agent id out of a background spawn/resume tool result. */
function agentIdOf(result: any): string {
const id = /Agent ID: (\S+)/.exec(resultText(result))?.[1];
if (!id) throw new Error(`no agent id in result: ${JSON.stringify(result)}`);
return id;
}
describe("Agent tool — background resume wiring", () => {
let cwd: string;
let agentDir: string;
let previousCwd: string;
let previousAgentDir: string | undefined;
let previousHome: string | undefined;
/** The session the spawned agent ends up holding — prior turns already in it. */
let session: any;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "pi-bg-resume-cwd-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-bg-resume-agent-"));
previousCwd = process.cwd();
previousAgentDir = process.env.PI_CODING_AGENT_DIR;
previousHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
mkdirSync(join(cwd, ".pi"), { recursive: true });
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false }));
mkdirSync(join(agentDir, "agents"), { recursive: true });
process.chdir(cwd);
session = {
messages: [
{ role: "user", content: "first task" },
{ role: "assistant", content: [{ type: "text", text: "first answer" }] },
{ role: "user", content: "tool result" },
],
subscribe: vi.fn(() => vi.fn()),
steer: vi.fn(async () => {}),
dispose: vi.fn(),
};
vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, options: any) => {
await Promise.resolve();
options.onSessionCreated?.(session);
return { responseText: "done", session, aborted: false, steered: false };
});
vi.mocked(resumeAgent).mockResolvedValue({ text: "resumed answer" } as any);
});
afterEach(() => {
process.chdir(previousCwd);
if (previousAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
if (previousHome == null) delete process.env.HOME;
else process.env.HOME = previousHome;
rmSync(cwd, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
vi.clearAllMocks();
});
/** Spawn a background agent and let it settle, so it holds a resumable session. */
async function spawnSettled(tools: Map<string, any>, ctx: any, type = "general-purpose") {
const res = await tools.get("Agent").execute(
"spawn-call",
{ prompt: "first task", description: "First task", subagent_type: type, run_in_background: true },
undefined,
undefined,
ctx,
);
const id = agentIdOf(res);
await new Promise((r) => setTimeout(r, 0));
return id;
}
// A background spawn deliberately omits the tool-call signal — that signal
// aborts when the parent turn is interrupted (user Esc), which must not reach
// an agent the caller has already detached from. A detached resume that
// forwarded it would die on Esc while background spawns from the same turn
// kept running.
it("does not tie the detached run to the tool-call signal", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const ctx = makeCtx(cwd);
const id = await spawnSettled(tools, ctx);
let runSignal: AbortSignal | undefined;
vi.mocked(resumeAgent).mockImplementation((_s: any, _p: any, opts: any) => {
runSignal = opts.signal;
return new Promise(() => {}); // never settles — the run is still in flight
});
const toolAbort = new AbortController();
await tools.get("Agent").execute(
"resume-call",
{ prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: true },
toolAbort.signal,
undefined,
ctx,
);
expect(runSignal).toBeDefined();
expect(runSignal!.aborted).toBe(false);
// The parent turn is interrupted. The detached run must survive it.
toolAbort.abort();
await new Promise((r) => setTimeout(r, 0));
expect(runSignal!.aborted).toBe(false);
await lifecycle.get("session_shutdown")?.({}, ctx);
});
// The transcript path is deterministic per agent+session, so the resume lands
// on the file the previous run wrote. writeInitialEntry truncates it.
it("appends to the agent's existing transcript instead of truncating it", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const ctx = makeCtx(cwd);
const id = await spawnSettled(tools, ctx);
vi.mocked(writeInitialEntry).mockClear();
vi.mocked(streamToOutputFile).mockClear();
await tools.get("Agent").execute(
"resume-call",
{ prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: true },
undefined,
undefined,
ctx,
);
// Never truncate; ensure the file exists without disturbing its contents.
expect(writeInitialEntry).not.toHaveBeenCalled();
expect(ensureOutputFile).toHaveBeenCalled();
// Streaming is anchored past the turns the session already carries, so the
// prior conversation is not re-emitted behind the resumed run.
expect(streamToOutputFile).toHaveBeenCalledTimes(1);
expect(vi.mocked(streamToOutputFile).mock.calls[0][4]).toBe(3);
await lifecycle.get("session_shutdown")?.({}, ctx);
});
// Resume ignores subagent_type — the record keeps the type it was spawned
// with — so a "created" event carrying the caller's type would re-register the
// agent under the wrong one in cross-extension mirrors keyed by id.
it("reports the record's own type, not the caller's ignored subagent_type", async () => {
writeFileSync(join(agentDir, "agents", "explorer.md"), `---\ndescription: Explorer agent\n---\n\nExplore.`);
const { pi, tools, lifecycle, emitted } = makePi();
subagentsExtension(pi);
const ctx = makeCtx(cwd);
const id = await spawnSettled(tools, ctx, "general-purpose");
emitted.length = 0;
const res = await tools.get("Agent").execute(
"resume-call",
{ prompt: "keep going", description: "Different description", subagent_type: "explorer", resume: id, run_in_background: true },
undefined,
undefined,
ctx,
);
const created = emitted.find(e => e.event === "subagents:created");
expect(created).toBeDefined();
expect(created!.payload.type).toBe("general-purpose");
expect(created!.payload.id).toBe(id);
expect(resultText(res)).toContain("Type: general-purpose");
await lifecycle.get("session_shutdown")?.({}, ctx);
});
// Detaching hands control back while the record is still running, so nothing
// stops the model from resuming the same agent again. The second resume must
// be refused with a message it can act on — not started, and not reported as
// a generic failure.
it("refuses a second background resume while the first run is in flight", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const ctx = makeCtx(cwd);
const id = await spawnSettled(tools, ctx);
vi.mocked(resumeAgent).mockImplementation(() => new Promise(() => {}));
vi.mocked(resumeAgent).mockClear();
const params = { prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: true };
await tools.get("Agent").execute("resume-1", params, undefined, undefined, ctx);
const second = await tools.get("Agent").execute("resume-2", params, undefined, undefined, ctx);
expect(resumeAgent).toHaveBeenCalledTimes(1);
const text = resultText(second);
expect(text).toContain("still running");
expect(text).toContain("steer_subagent");
await lifecycle.get("session_shutdown")?.({}, ctx);
});
// Resume follows the same default as a fresh spawn — background — so
// foreground is now the explicit case rather than the implicit one.
it("still resumes in the foreground when run_in_background is false", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const ctx = makeCtx(cwd);
const id = await spawnSettled(tools, ctx);
vi.mocked(resumeAgent).mockResolvedValue({ text: "inline answer" } as any);
const res = await tools.get("Agent").execute(
"resume-call",
{ prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: false },
undefined,
undefined,
ctx,
);
// Foreground resume returns the answer inline — no background handoff text.
expect(resultText(res)).toContain("inline answer");
expect(resultText(res)).not.toContain("You will be notified");
await lifecycle.get("session_shutdown")?.({}, ctx);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { inChildSessionContext, runInChildSessionContext } from "../src/child-context.js";
import subagentsExtension from "../src/index.js";
describe("child session async context", () => {
it("is scoped to the child async branch", async () => {
expect(inChildSessionContext()).toBe(false);
await runInChildSessionContext(async () => {
expect(inChildSessionContext()).toBe(true);
await Promise.resolve();
expect(inChildSessionContext()).toBe(true);
});
expect(inChildSessionContext()).toBe(false);
});
it("prevents a child resource load from creating another extension manager", async () => {
const pi = new Proxy({}, {
get: vi.fn(() => {
throw new Error("child extension factory must be a no-op");
}),
});
await runInChildSessionContext(async () => {
expect(() => subagentsExtension(pi as any)).not.toThrow();
});
});
});
@@ -0,0 +1,187 @@
/**
* #242 — `runAgent` calls `session.bindExtensions()` so `session_start` fires and
* extensions can set up per-session state, but nothing ever closed that lifecycle:
* both eviction (`removeRecord`) and quit (`dispose`) called `session.dispose()`,
* which in pi only calls `ExtensionRunner.invalidate()` — it does NOT emit
* `session_shutdown`. So anything an extension armed in `session_start` (timers, fs
* watchers, sockets) leaked once per spawn, and its next tick threw `assertActive()`
* from a bare `Timeout._onTimeout` — an uncaughtException that killed interactive pi.
*
* pi emits the event itself in `AgentSessionRuntime.dispose()` before disposing; these
* tests pin that we do the same, that quit actually waits for the handlers, and that a
* hung handler can't strand the user at a dead terminal.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentManager } from "../src/agent-manager.js";
import { registerPermissionChildSession } from "../src/permission-system-bridge.js";
vi.mock("../src/agent-runner.js", () => ({
runAgent: vi.fn(),
resumeAgent: vi.fn(),
}));
vi.mock("../src/worktree.js", () => ({
createWorktree: vi.fn(),
cleanupWorktree: vi.fn(() => ({ hasChanges: false })),
pruneWorktrees: vi.fn(),
isWorktreeIsolationEnabled: vi.fn(() => true),
}));
import { runAgent } from "../src/agent-runner.js";
import { pruneWorktrees } from "../src/worktree.js";
const mockPi = {} as any;
const mockCtx = { cwd: "/tmp" } as any;
/** A child session as `runAgent` leaves it: extensions bound, so a runner with handlers. */
function boundSession(emit: (...args: any[]) => any = vi.fn(async () => {})) {
return {
dispose: vi.fn(),
extensionRunner: {
hasHandlers: vi.fn((event: string) => event === "session_shutdown"),
emit: vi.fn(emit),
},
} as any;
}
/** Spawn one background agent that resolves with `session`, and wait for it to complete. */
async function spawnCompleted(manager: AgentManager, session: any) {
vi.mocked(runAgent).mockResolvedValue({
responseText: "done",
session,
aborted: false,
steered: false,
} as any);
const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", {
description: "test",
isBackground: true,
});
await manager.getRecord(id)!.promise;
return id;
}
describe("child session shutdown (#242)", () => {
let manager: AgentManager;
afterEach(async () => {
await manager?.dispose();
vi.useRealTimers();
vi.clearAllMocks();
});
it("emits session_shutdown before disposing an evicted session", async () => {
manager = new AgentManager();
const session = boundSession();
await spawnCompleted(manager, session);
manager.clearCompleted();
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
expect(session.extensionRunner.emit).toHaveBeenCalledWith({
type: "session_shutdown",
reason: "quit",
});
// Order is the whole point: after `dispose()` the runner is invalidated and
// every `ctx` getter throws, so a handler emitted afterwards is useless.
expect(session.extensionRunner.emit.mock.invocationCallOrder[0])
.toBeLessThan(session.dispose.mock.invocationCallOrder[0]);
});
it("publishes child disposal only after the session is closed", async () => {
manager = new AgentManager();
const order: string[] = [];
const pi = {
events: { emit: vi.fn((channel: string) => { order.push(channel); }) },
} as any;
const session = boundSession();
session.sessionManager = { getSessionId: () => "child-session" };
session.dispose.mockImplementation(() => { order.push("session-disposed"); });
registerPermissionChildSession(pi, session, "root-session");
await spawnCompleted(manager, session);
manager.clearCompleted();
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
expect(order).toEqual([
"subagents:child:session-created",
"session-disposed",
"subagents:child:disposed",
]);
});
it("quit waits for the child's shutdown handlers to finish", async () => {
manager = new AgentManager();
let releaseHandler!: () => void;
const session = boundSession(() => new Promise<void>(r => { releaseHandler = r; }));
await spawnCompleted(manager, session);
const disposed = manager.dispose();
let settled = false;
void disposed.then(() => { settled = true; });
// Several microtask turns: enough for a fire-and-forget implementation to have
// resolved, not enough for a correctly awaited one.
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(settled).toBe(false);
expect(session.dispose).not.toHaveBeenCalled();
releaseHandler();
await disposed;
expect(settled).toBe(true);
expect(session.dispose).toHaveBeenCalledOnce();
});
it("quit is not hostage to a handler that never resolves", async () => {
manager = new AgentManager();
const session = boundSession(() => new Promise<void>(() => {}));
await spawnCompleted(manager, session);
vi.useFakeTimers();
const disposed = manager.dispose();
// Past the internal ceiling. Without it the TUI is already torn down and the
// user is left at a dead terminal with only Ctrl-C.
await vi.advanceTimersByTimeAsync(5_000);
await disposed;
expect(session.dispose).toHaveBeenCalledOnce();
// Teardown continues past the timeout rather than unwinding.
expect(pruneWorktrees).toHaveBeenCalled();
});
it("skips the emit when no extension handles session_shutdown", async () => {
manager = new AgentManager();
const session = boundSession();
session.extensionRunner.hasHandlers = vi.fn(() => false);
await spawnCompleted(manager, session);
manager.clearCompleted();
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
expect(session.extensionRunner.emit).not.toHaveBeenCalled();
});
it("degrades on a stubbed session instead of throwing", async () => {
const rejections: unknown[] = [];
const onRejection = (err: unknown) => rejections.push(err);
process.on("unhandledRejection", onRejection);
try {
manager = new AgentManager();
// No extensionRunner at all — an older pi, or a partial `onSessionCreated` stub.
const noRunner = { dispose: vi.fn() } as any;
await spawnCompleted(manager, noRunner);
manager.clearCompleted();
await vi.waitFor(() => expect(noRunner.dispose).toHaveBeenCalled());
// Nothing at all — not even dispose.
const empty = {} as any;
await spawnCompleted(manager, empty);
manager.clearCompleted();
await expect(manager.dispose()).resolves.toBeUndefined();
await new Promise(r => setImmediate(r));
expect(rejections).toEqual([]);
} finally {
process.off("unhandledRejection", onRejection);
}
});
});
@@ -0,0 +1,172 @@
/**
* clear-completed-wiring.test.ts — reproduces issue #108 end-to-end through the
* REAL session lifecycle handlers + the REAL get_subagent_result tool.
*
* Bug: a background agent that has COMPLETED but whose result the LLM hasn't read
* yet (resultConsumed=false) was wiped by clearCompleted() on session_start /
* session_before_switch, so the next get_subagent_result returned "Agent not
* found". The fix makes both handlers call clearCompleted(true), preserving
* unread records (the 10-minute timer evicts them later).
*
* These tests exercise the wiring, not the manager method in isolation: spawn a
* real background agent, let it complete, fire the real session event, then read
* it back through the real tool — the exact path the reporter hit.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>(); // pi.on(...) — session_start, session_before_switch, session_shutdown
const events = new Map<string, any>(); // pi.events.on(...) — subagents:rpc:*, etc.
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn(),
on: vi.fn((event: string, handler: any) => {
events.set(event, handler);
return vi.fn();
}),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle, events };
}
function ctx() {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
const textOf = (r: any): string => r.content[0].text;
// Let runAgent's resolved .then() chain settle so the record reaches "completed".
const flush = async () => {
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
};
// Spawn a real background agent and drive it to status "completed" with
// resultConsumed=false (only get_subagent_result sets that flag for background).
async function spawnCompletedBackgroundAgent(tools: Map<string, any>): Promise<string> {
vi.mocked(runAgent).mockResolvedValue({
responseText: "THE-RESULT-PAYLOAD",
session: { dispose: vi.fn() } as any,
aborted: false,
steered: false,
});
const spawn = await tools.get("Agent").execute(
"tc-spawn",
{ prompt: "go", description: "Review monero_en.rs in depth", subagent_type: "general-purpose", run_in_background: true },
undefined,
undefined,
ctx(),
);
const id = textOf(spawn).match(/Agent ID: (\S+)/)?.[1];
expect(id, "background spawn should surface an agent id").toBeTruthy();
await flush();
return id as string;
}
describe("issue #108: unread completed background agents survive session events", () => {
let tmpDir: string;
let agentDir: string;
let prevCwd: string;
let prevAgentDir: string | undefined;
let prevHome: string | undefined;
beforeEach(() => {
// Hermetic cwd + global dir, scheduling off, so session_start doesn't spin a
// scheduler or touch the dev's filesystem — isolates the clearCompleted path.
tmpDir = mkdtempSync(join(tmpdir(), "pi-108-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-108-agentdir-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
prevHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
prevCwd = process.cwd();
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false }));
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(prevCwd);
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(tmpDir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("session_before_switch (user switches sessions) does NOT wipe the unread result", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const id = await spawnCompletedBackgroundAgent(tools);
// The exact #108 trigger: a session switch fires before the LLM read the result.
await lifecycle.get("session_before_switch")?.();
const res = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx());
const out = textOf(res);
expect(out).not.toContain("Agent not found");
expect(out).toContain("THE-RESULT-PAYLOAD");
await lifecycle.get("session_shutdown")?.({}, ctx());
});
it("session_start (/resume) does NOT wipe the unread result", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const id = await spawnCompletedBackgroundAgent(tools);
await lifecycle.get("session_start")?.({}, ctx());
const res = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx());
const out = textOf(res);
expect(out).not.toContain("Agent not found");
expect(out).toContain("THE-RESULT-PAYLOAD");
await lifecycle.get("session_shutdown")?.({}, ctx());
});
it("once read, a session switch DOES evict it — the fix stays surgical, no leak", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const id = await spawnCompletedBackgroundAgent(tools);
// LLM reads the result → resultConsumed=true.
const first = await tools.get("get_subagent_result").execute("tc-read1", { agent_id: id }, undefined, undefined, ctx());
expect(textOf(first)).toContain("THE-RESULT-PAYLOAD");
// Now a session switch SHOULD clean it up (consumed records are not preserved).
await lifecycle.get("session_before_switch")?.();
const second = await tools.get("get_subagent_result").execute("tc-read2", { agent_id: id }, undefined, undefined, ctx());
expect(textOf(second)).toContain("Agent not found");
await lifecycle.get("session_shutdown")?.({}, ctx());
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* context.test.ts — Parent conversation context extraction for inherit_context spawns.
*
* buildParentContext shapes what a subagent sees from its parent; silent bugs
* here would feed wrong context into spawns. Tests use realistic SessionEntry
* shapes and a minimal ExtensionContext stub — no mocking beyond the one
* getBranch() call the function actually reads.
*/
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { buildParentContext, extractText } from "../src/context.js";
function makeCtx(entries: unknown[]): ExtensionContext {
return { sessionManager: { getBranch: () => entries } } as unknown as ExtensionContext;
}
function userMsg(content: string | unknown[]) {
return { type: "message", message: { role: "user", content } };
}
function assistantMsg(blocks: unknown[]) {
return { type: "message", message: { role: "assistant", content: blocks } };
}
describe("extractText", () => {
it("joins multiple text blocks with newlines", () => {
expect(extractText([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toBe("a\nb");
});
it("filters out non-text blocks (tool_use, etc.)", () => {
expect(
extractText([
{ type: "text", text: "keep" },
{ type: "tool_use", name: "x", input: {} },
{ type: "text", text: "also keep" },
]),
).toBe("keep\nalso keep");
});
it("treats a text block with missing text field as empty", () => {
expect(extractText([{ type: "text" }, { type: "text", text: "x" }])).toBe("\nx");
});
it("returns empty string for an empty content array", () => {
expect(extractText([])).toBe("");
});
});
describe("buildParentContext", () => {
it("returns empty string for an empty branch", () => {
expect(buildParentContext(makeCtx([]))).toBe("");
});
it("returns empty string when no entries produce extractable content", () => {
// toolResult is skipped, empty-summary compaction is skipped
const out = buildParentContext(
makeCtx([
{ type: "message", message: { role: "tool_result", content: "..." } },
{ type: "compaction", summary: "" },
]),
);
expect(out).toBe("");
});
it("wraps a user+assistant exchange with the parent-context header and task footer", () => {
const out = buildParentContext(
makeCtx([userMsg("hello"), assistantMsg([{ type: "text", text: "hi back" }])]),
);
expect(out).toContain("# Parent Conversation Context");
expect(out).toContain("[User]: hello");
expect(out).toContain("[Assistant]: hi back");
expect(out).toMatch(/# Your Task \(below\)\n$/);
// Entries are joined with a blank line, preserving conversation order
expect(out).toContain("[User]: hello\n\n[Assistant]: hi back");
});
it("accepts user messages whose content is content-blocks (not just a string)", () => {
const out = buildParentContext(makeCtx([userMsg([{ type: "text", text: "from blocks" }])]));
expect(out).toContain("[User]: from blocks");
});
it("includes compaction summaries inline in their original position", () => {
const out = buildParentContext(
makeCtx([
userMsg("orig question"),
{ type: "compaction", summary: "we discussed X" },
assistantMsg([{ type: "text", text: "follow-up" }]),
]),
);
expect(out.indexOf("[User]: orig question"))
.toBeLessThan(out.indexOf("[Summary]: we discussed X"));
expect(out.indexOf("[Summary]: we discussed X"))
.toBeLessThan(out.indexOf("[Assistant]: follow-up"));
});
it("skips tool_result messages — they're too verbose for inherited context", () => {
const out = buildParentContext(
makeCtx([
userMsg("real user"),
{ type: "message", message: { role: "tool_result", content: "noisy tool output" } },
assistantMsg([{ type: "text", text: "real assistant" }]),
]),
);
expect(out).not.toContain("noisy tool output");
expect(out).toContain("[User]: real user");
expect(out).toContain("[Assistant]: real assistant");
});
it("trims and skips whitespace-only messages", () => {
const out = buildParentContext(
makeCtx([userMsg(" \n "), assistantMsg([{ type: "text", text: "non-empty" }])]),
);
expect(out).not.toMatch(/\[User\]:/);
expect(out).toContain("[Assistant]: non-empty");
});
it("ignores assistant messages whose only content is non-text blocks", () => {
// Assistant emitted only a tool_use — nothing extractable, so it shouldn't appear
const out = buildParentContext(
makeCtx([
userMsg("question"),
assistantMsg([{ type: "tool_use", name: "x", input: {} }]),
]),
);
expect(out).toContain("[User]: question");
expect(out).not.toContain("[Assistant]:");
});
});
@@ -0,0 +1,140 @@
import { KeybindingsManager, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import type { AgentRecord } from "../src/types.js";
import { ConversationViewer } from "../src/ui/conversation-viewer.js";
import type { ViewerKeybindings } from "../src/ui/viewer-keys.js";
import { createViewerKeys } from "../src/ui/viewer-keys.js";
const CTRL_P = "\x10";
const CTRL_N = "\x0e";
const UP = "\x1b[A";
const DOWN = "\x1b[B";
const SHIFT_UP = "\x1b[1;2A";
const SHIFT_DOWN = "\x1b[1;2B";
const PAGE_UP = "\x1b[5~";
const PAGE_DOWN = "\x1b[6~";
function createEmacsKeybindings(): KeybindingsManager {
return new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.select.up": ["up", "ctrl+p"],
"tui.select.down": ["down", "ctrl+n"],
});
}
function createViewer(keybindings?: ViewerKeybindings) {
const tui = {
terminal: { rows: 20, columns: 80 },
requestRender: vi.fn(),
} as any;
const messages = Array.from({ length: 60 }, (_, i) => ({
role: "user",
content: `message ${i}`,
}));
const session = {
messages,
subscribe: vi.fn(() => vi.fn()),
} as any;
const record = {
id: "test-1",
type: "general-purpose",
description: "test agent",
status: "completed",
toolUses: 0,
startedAt: Date.now(),
} as AgentRecord;
const theme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
} as any;
const viewer = new ConversationViewer(tui, session, record, undefined, theme, vi.fn(), undefined, keybindings);
viewer.render(80); // sets lastInnerW and scrolls to bottom (autoScroll)
return viewer;
}
function scrollOffset(viewer: ConversationViewer): number {
return (viewer as any).scrollOffset;
}
describe("viewer-keys", () => {
it("honors user keybindings when a manager is provided", () => {
const keys = createViewerKeys(createEmacsKeybindings());
expect(keys.scrollUp(CTRL_P)).toBe(true);
expect(keys.scrollUp(UP)).toBe(true);
expect(keys.scrollDown(CTRL_N)).toBe(true);
expect(keys.scrollDown(DOWN)).toBe(true);
});
it("falls back to hardcoded defaults without a manager", () => {
const keys = createViewerKeys();
expect(keys.scrollUp(UP)).toBe(true);
expect(keys.scrollUp(CTRL_P)).toBe(false);
expect(keys.scrollDown(DOWN)).toBe(true);
expect(keys.scrollDown(CTRL_N)).toBe(false);
expect(keys.pageUp(PAGE_UP)).toBe(true);
expect(keys.pageDown(PAGE_DOWN)).toBe(true);
});
it("keeps the k/j and shift+arrow aliases with and without a manager", () => {
for (const keys of [createViewerKeys(), createViewerKeys(createEmacsKeybindings())]) {
expect(keys.scrollUp("k")).toBe(true);
expect(keys.scrollDown("j")).toBe(true);
expect(keys.pageUp(SHIFT_UP)).toBe(true);
expect(keys.pageDown(SHIFT_DOWN)).toBe(true);
}
});
it("manager with no user overrides behaves like the hardcoded defaults", () => {
const keys = createViewerKeys(new KeybindingsManager(TUI_KEYBINDINGS, {}));
expect(keys.scrollUp(UP)).toBe(true);
expect(keys.scrollDown(DOWN)).toBe(true);
expect(keys.pageUp(PAGE_UP)).toBe(true);
expect(keys.pageDown(PAGE_DOWN)).toBe(true);
expect(keys.scrollUp(CTRL_P)).toBe(false);
expect(keys.scrollDown(CTRL_N)).toBe(false);
});
it("respects rebinding that removes a default key", () => {
const manager = new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.select.up": "ctrl+p",
});
const keys = createViewerKeys(manager);
expect(keys.scrollUp(CTRL_P)).toBe(true);
expect(keys.scrollUp(UP)).toBe(false);
});
});
describe("ConversationViewer custom keybindings", () => {
it("scrolls with ctrl+p/ctrl+n when bound to tui.select.up/down", () => {
const viewer = createViewer(createEmacsKeybindings());
const bottom = scrollOffset(viewer);
expect(bottom).toBeGreaterThan(0);
viewer.handleInput(CTRL_P);
expect(scrollOffset(viewer)).toBe(bottom - 1);
viewer.handleInput(CTRL_N);
expect(scrollOffset(viewer)).toBe(bottom);
});
it("keeps arrows and k/j working alongside custom bindings", () => {
const viewer = createViewer(createEmacsKeybindings());
const bottom = scrollOffset(viewer);
viewer.handleInput(UP);
viewer.handleInput("k");
expect(scrollOffset(viewer)).toBe(bottom - 2);
viewer.handleInput(DOWN);
viewer.handleInput("j");
expect(scrollOffset(viewer)).toBe(bottom);
});
it("treats ctrl+p/ctrl+n as unbound without a keybindings manager", () => {
const viewer = createViewer();
const bottom = scrollOffset(viewer);
viewer.handleInput(CTRL_P);
viewer.handleInput(CTRL_N);
expect(scrollOffset(viewer)).toBe(bottom);
viewer.handleInput(UP);
expect(scrollOffset(viewer)).toBe(bottom - 1);
});
});
@@ -0,0 +1,527 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentRecord } from "../src/types.js";
// ── Mock wrapTextWithAnsi ──────────────────────────────────────────────
// We need to control what wrapTextWithAnsi returns to simulate the
// upstream bug (returning lines wider than requested width).
// vi.mock is hoisted and intercepts before conversation-viewer.ts binds
// its import.
let wrapOverride: ((text: string, width: number) => string[]) | null = null;
vi.mock("@earendil-works/pi-tui", async (importOriginal) => {
const original = await importOriginal<typeof import("@earendil-works/pi-tui")>();
return {
...original,
wrapTextWithAnsi: (...args: [string, number]) => {
if (wrapOverride) return wrapOverride(...args);
return original.wrapTextWithAnsi(...args);
},
};
});
// Must import AFTER vi.mock declaration (vitest hoists vi.mock but the
// dynamic import of the test subject must happen after)
const { visibleWidth } = await import("@earendil-works/pi-tui");
const { ConversationViewer } = await import("../src/ui/conversation-viewer.js");
// ── Helpers ────────────────────────────────────────────────────────────
function mockTui(rows = 40, columns = 80) {
return {
terminal: { rows, columns },
requestRender: vi.fn(),
} as any;
}
function mockSession(messages: any[] = []) {
return {
messages,
subscribe: vi.fn(() => vi.fn()),
dispose: vi.fn(),
getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheWrite: 0 } }),
} as any;
}
function mockRecord(overrides: Partial<AgentRecord> = {}): AgentRecord {
return {
id: "test-1",
type: "general-purpose",
description: "test agent",
status: "running",
toolUses: 0,
startedAt: Date.now(),
...overrides,
} as AgentRecord;
}
function ansiTheme() {
return {
fg: (_color: string, text: string) => `\x1b[38;5;240m${text}\x1b[0m`,
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
} as any;
}
function assertAllLinesFit(lines: string[], width: number) {
for (let i = 0; i < lines.length; i++) {
const vw = visibleWidth(lines[i]);
expect(vw, `line ${i} exceeds width (${vw} > ${width}): ${JSON.stringify(lines[i])}`).toBeLessThanOrEqual(width);
}
}
// ── Tests ──────────────────────────────────────────────────────────────
beforeEach(() => {
wrapOverride = null;
});
describe("ConversationViewer cost display", () => {
/** The header line, with a cost of `cost` on the record and showCost `on`. */
function header(on: boolean, cost: number): string {
const record = mockRecord({
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost },
} as Partial<AgentRecord>);
const viewer = new ConversationViewer(
mockTui(30, 200), mockSession([]), record, undefined,
{ fg: (_c: string, t: string) => t, bold: (t: string) => t } as any,
vi.fn(), undefined, undefined, undefined, on,
);
return viewer.render(200).join("\n");
}
it("shows the cost beside the token count when enabled", () => {
// The viewer opens on finished agents, whose live activity entry is gone —
// so this reads the record, and would show nothing if it did not.
const out = header(true, 0.0042);
expect(out).toContain("1.2k token");
expect(out).toContain("~$0.0042");
});
it("shows no cost when disabled", () => {
const out = header(false, 0.0042);
expect(out).toContain("1.2k token");
expect(out).not.toContain("$");
});
it("shows no cost for a model with no pricing data", () => {
expect(header(true, 0)).not.toContain("$");
});
});
describe("ConversationViewer", () => {
describe("render width safety", () => {
const widths = [40, 80, 120, 216];
it("no line exceeds width with empty messages", () => {
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession([]), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with plain text messages", () => {
const messages = [
{ role: "user", content: "Hello, how are you?" },
{ role: "assistant", content: [{ type: "text", text: "I am fine, thank you for asking." }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("keeps bordered rows exact-width at a double-width truncation boundary", () => {
const width = 40;
for (let prefixLength = 0; prefixLength < width; prefixLength++) {
const viewer = new ConversationViewer(
mockTui(30, width),
mockSession([]),
mockRecord({ description: `${"a".repeat(prefixLength)}界more` }),
undefined,
ansiTheme(),
vi.fn(),
);
for (const line of viewer.render(width)) {
expect(
visibleWidth(line),
`prefix ${prefixLength} produced an under-width bordered row: ${JSON.stringify(line)}`,
).toBe(width);
}
}
});
it("no line exceeds width when text is longer than viewport", () => {
const longLine = "A".repeat(500);
const messages = [
{ role: "user", content: longLine },
{ role: "assistant", content: [{ type: "text", text: longLine }] },
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: longLine }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with embedded ANSI escape codes in content", () => {
const ansiText = `\x1b[1mBold heading\x1b[22m and \x1b[31mred text\x1b[0m ${"X".repeat(300)}`;
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: ansiText }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with long URLs", () => {
const url = "https://example.com/" + "a/b/c/d/e/".repeat(30) + "?q=" + "x".repeat(100);
const messages = [
{ role: "assistant", content: [{ type: "text", text: `Check this link: ${url}` }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with wide table-like content", () => {
const header = "| " + Array.from({ length: 20 }, (_, i) => `Column${i}`).join(" | ") + " |";
const dataRow = "| " + Array.from({ length: 20 }, () => "value123").join(" | ") + " |";
const table = [header, dataRow, dataRow, dataRow].join("\n");
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: table }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with bashExecution messages", () => {
const messages = [
{
role: "bashExecution", command: "cat " + "/very/long/path/".repeat(20) + "file.txt",
output: "O".repeat(600),
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
},
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with running activity indicator", () => {
const activity = {
activeTools: new Map([["read", "file.ts"], ["grep", "pattern"]]),
toolUses: 5, tokens: "10k", responseText: "R".repeat(400),
session: { getSessionStats: () => ({ tokens: { total: 50000 } }) },
};
const messages = [
{ role: "user", content: "do the thing" },
{ role: "assistant", content: [{ type: "text", text: "working on it" }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord({ status: "running" }), activity as any, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with tool calls", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me check that." },
{ type: "toolCall", toolUseId: "t1", name: "very_long_tool_name_" + "x".repeat(200), input: {} },
],
},
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width at narrow terminal", () => {
const messages = [
{ role: "user", content: "Hello world, this is a normal sentence." },
{ role: "assistant", content: [{ type: "text", text: "Sure, here's the answer." }] },
];
for (const w of [8, 10, 15, 20]) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with mixed ANSI + unicode content", () => {
const text = `\x1b[32m✓\x1b[0m Test passed — 日本語テスト ${"あ".repeat(50)} \x1b[33m⚠\x1b[0m`;
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
});
describe("safety net against upstream wrapTextWithAnsi bugs", () => {
// These tests call buildContentLines() directly (via the private method)
// because render() has its own truncation via row(). The safety net in
// buildContentLines is what prevents the TUI crash — it must clamp
// independently of render().
/** Call the private buildContentLines method directly. */
function callBuildContentLines(viewer: InstanceType<typeof ConversationViewer>, width: number): string[] {
return (viewer as any).buildContentLines(width);
}
it("mock is intercepting wrapTextWithAnsi", async () => {
const { wrapTextWithAnsi } = await import("@earendil-works/pi-tui");
wrapOverride = () => ["MOCK_SENTINEL"];
expect(wrapTextWithAnsi("anything", 10)).toEqual(["MOCK_SENTINEL"]);
wrapOverride = null;
});
it("clamps overwidth lines from toolResult content", () => {
const w = 80;
wrapOverride = () => ["X".repeat(w + 50)];
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines from user message content", () => {
const w = 80;
wrapOverride = () => ["Y".repeat(w + 100)];
const messages = [{ role: "user", content: "hello" }];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines from assistant message content", () => {
const w = 80;
wrapOverride = () => ["Z".repeat(w + 100)];
const messages = [
{ role: "assistant", content: [{ type: "text", text: "response" }] },
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines from bashExecution output", () => {
const w = 80;
wrapOverride = () => ["B".repeat(w + 100)];
const messages = [
{
role: "bashExecution", command: "ls", output: "out",
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
},
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines that also contain ANSI codes", () => {
const w = 80;
wrapOverride = () => [`\x1b[1m\x1b[31m${"W".repeat(w + 30)}\x1b[0m`];
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
});
describe("stop key", () => {
const W = 80;
it("two-press x stops a running agent (first arms, second aborts)", () => {
const onStop = vi.fn();
const tui = mockTui(30, W);
const viewer = new ConversationViewer(
tui, mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
);
// Idle footer offers the stop affordance.
expect(viewer.render(W).join("\n")).toContain("x stop");
// First press arms (no abort yet) and re-renders.
viewer.handleInput("x");
expect(onStop).not.toHaveBeenCalled();
expect(tui.requestRender).toHaveBeenCalled();
expect(viewer.render(W).join("\n")).toContain("x again to STOP");
// Second press aborts.
viewer.handleInput("x");
expect(onStop).toHaveBeenCalledTimes(1);
});
it("any other key disarms the confirm", () => {
const onStop = vi.fn();
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
);
viewer.handleInput("x"); // arm
viewer.handleInput("j"); // scroll → disarm
expect(viewer.render(W).join("\n")).toContain("x stop");
expect(viewer.render(W).join("\n")).not.toContain("x again to STOP");
viewer.handleInput("x"); // arms again, does NOT stop
expect(onStop).not.toHaveBeenCalled();
});
it("does not offer or perform stop once the agent is no longer running", () => {
const onStop = vi.fn();
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "completed" }), undefined, ansiTheme(), vi.fn(), onStop,
);
expect(viewer.render(W).join("\n")).not.toContain("x stop");
viewer.handleInput("x");
viewer.handleInput("x");
expect(onStop).not.toHaveBeenCalled();
});
it("no stop affordance when no onStop handler is provided (read-only history)", () => {
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
);
expect(viewer.render(W).join("\n")).not.toContain("x stop");
expect(() => { viewer.handleInput("x"); viewer.handleInput("x"); }).not.toThrow();
});
});
describe("steer composer", () => {
const W = 80;
function makeViewer(opts: { status?: AgentRecord["status"]; onSteer?: (m: string) => void } = {}) {
const onSteer = opts.onSteer ?? vi.fn();
const tui = mockTui(30, W);
const viewer = new ConversationViewer(
tui, mockSession(), mockRecord({ status: opts.status ?? "running" }),
undefined, ansiTheme(), vi.fn(), undefined, undefined, onSteer,
);
return { viewer, tui, onSteer };
}
it("offers the steer affordance for a running agent and opens on Enter", () => {
const { viewer } = makeViewer();
expect(viewer.render(W).join("\n")).toContain("Enter steer");
viewer.handleInput("\r"); // Enter
// Composer is shown (its prompt + send/cancel hint), idle footer is gone.
const out = viewer.render(W).join("\n");
expect(out).toContain("Enter send · Esc cancel");
expect(out).not.toContain("Enter steer");
});
it("typing then Enter sends the trimmed message and closes the composer", () => {
const { viewer, onSteer } = makeViewer();
viewer.handleInput("\r"); // open composer
for (const ch of " hello ") viewer.handleInput(ch);
viewer.handleInput("\r"); // send
expect(onSteer).toHaveBeenCalledWith("hello");
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
});
it("Esc cancels the composer without sending", () => {
const { viewer, onSteer } = makeViewer();
viewer.handleInput("\r"); // open composer
for (const ch of "draft") viewer.handleInput(ch);
viewer.handleInput("\x1b"); // Esc
expect(onSteer).not.toHaveBeenCalled();
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
});
it("an empty submit just returns (like Esc), without calling onSteer", () => {
const { viewer, onSteer } = makeViewer();
viewer.handleInput("\r"); // open composer
viewer.handleInput("\r"); // empty submit
expect(onSteer).not.toHaveBeenCalled();
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
});
it("scroll keys are inert while composing (input owns them)", () => {
const { viewer } = makeViewer();
viewer.handleInput("\r"); // open composer
// 'j' would normally scroll, but here it types into the composer.
viewer.handleInput("j");
expect(viewer.render(W).join("\n")).toContain("Enter send · Esc cancel");
});
it("no steer affordance once the agent is no longer running", () => {
const { viewer, onSteer } = makeViewer({ status: "completed" });
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
viewer.handleInput("\r");
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
expect(onSteer).not.toHaveBeenCalled();
});
it("no steer affordance when no onSteer handler is provided", () => {
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
);
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
expect(() => viewer.handleInput("\r")).not.toThrow();
});
it("composer rows never exceed width", () => {
for (const w of [40, 80, 120]) {
const tui = mockTui(30, w);
const viewer = new ConversationViewer(
tui, mockSession(), mockRecord({ status: "running" }),
undefined, ansiTheme(), vi.fn(), undefined, undefined, vi.fn(),
);
viewer.handleInput("\r"); // open composer
for (const ch of "x".repeat(200)) viewer.handleInput(ch);
assertAllLinesFit(viewer.render(w), w);
}
});
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* cost-display.test.ts — what `showCost` puts on each surface, and what it must
* never put there.
*
* Two rules run through all of it. A cost is shown only when there is one to
* show: a model pi has no rates for reports 0, and printing `$0.00` beside its
* tokens would claim the run was measured and free. And each surface punctuates
* its own — the stats line joins with `·`, the foreground result with `,`, the
* `get_subagent_result` header with `|` — which is why the cost travels as a
* number and is formatted at the end, not baked into the token string.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import { registerAgents } from "../src/agent-types.js";
import subagentsExtension from "../src/index.js";
import { ctx, flush, type Hermetic, hermeticDir, makePi, textOf } from "./helpers/boot-extension.js";
const COST = 0.0123;
/** One foreground run that spends `cost` on a single assistant message. */
function runSpending(cost: number) {
vi.mocked(runAgent).mockImplementation(async (_c: any, _t: any, _p: any, opts: any) => {
opts.onAssistantUsage?.({ input: 1000, output: 200, cacheWrite: 0, cost });
return { responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false };
});
}
const spawn = (tools: Map<string, any>) =>
tools.get("Agent").execute(
"tc-1",
{ prompt: "go", description: "spend", subagent_type: "general-purpose", run_in_background: false },
undefined, undefined, ctx(),
);
describe("cost display", () => {
let hermetic: Hermetic;
function boot(settings: Record<string, unknown>) {
hermetic = hermeticDir({ settings });
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
return { pi, tools, lifecycle };
}
beforeEach(() => {
vi.mocked(runAgent).mockReset();
});
afterEach(() => {
delete (globalThis as any)[Symbol.for("pi-subagents:manager")];
registerAgents(new Map());
hermetic?.restore();
});
describe("the foreground result the orchestrator reads", () => {
it("names the cost in the stats it already reports", async () => {
const { tools } = boot({ showCost: true });
runSpending(COST);
const text = textOf(await spawn(tools));
expect(text).toContain("~$0.0123");
// Comma-joined with the rest, not glued to the token count with the "·"
// that the widget uses — the separator belongs to the surface.
expect(text).toMatch(/1\.2k token, ~\$0\.0123/);
});
it("says nothing when the setting is off", async () => {
const { tools } = boot({ showCost: false });
runSpending(COST);
expect(textOf(await spawn(tools))).not.toContain("$");
});
it("says nothing for a model with no pricing data", async () => {
const { tools } = boot({ showCost: true });
runSpending(0);
const text = textOf(await spawn(tools));
expect(text).toContain("1.2k token"); // tokens are still exact
expect(text).not.toContain("$");
});
});
describe("get_subagent_result", () => {
it("reports the cost as its own labelled field", async () => {
const { tools } = boot({ showCost: true });
runSpending(COST);
await spawn(tools);
await flush();
// The agent above ran in the foreground; look it up by the handle its
// type gets, which is how the orchestrator would reach it.
const text = textOf(await tools.get("get_subagent_result").execute(
"tc-2", { agent_id: "general-purpose" }, undefined, undefined, ctx(),
));
// Pipe-separated `Label: value` fields, matching its neighbours.
expect(text).toContain("Cost: ~$0.0123");
});
it("omits the field entirely when unpriced", async () => {
const { tools } = boot({ showCost: true });
runSpending(0);
await spawn(tools);
await flush();
const text = textOf(await tools.get("get_subagent_result").execute(
"tc-2", { agent_id: "general-purpose" }, undefined, undefined, ctx(),
));
expect(text).not.toContain("Cost:");
});
});
describe("the background completion notification the model reads", () => {
/**
* The <task-notification> text sent into the parent conversation. Held
* behind a 200ms nudge debounce (plus batch finalization), so this polls
* rather than waiting a fixed beat.
*/
async function notificationText(pi: any): Promise<string> {
for (let i = 0; i < 60 && pi.sendMessage.mock.calls.length === 0; i++) {
await new Promise((r) => setTimeout(r, 25));
}
return pi.sendMessage.mock.calls.at(-1)?.[0]?.content ?? "";
}
const spawnBackground = (tools: Map<string, any>) =>
tools.get("Agent").execute(
"tc-1",
{ prompt: "go", description: "spend", subagent_type: "general-purpose", run_in_background: true },
undefined, undefined, ctx(),
);
it("includes the cost in the usage block when enabled", async () => {
const { pi, tools } = boot({ showCost: true, defaultJoinMode: "async" });
runSpending(COST);
await spawnBackground(tools);
expect(await notificationText(pi)).toContain("<estimated_cost_usd>0.0123</estimated_cost_usd>");
});
it("omits it when disabled — this is LLM context, not a display", async () => {
// A figure the orchestrator was not asked to track is one it may start
// reporting unprompted, so the setting gates the context too, not just
// what a human sees.
const { pi, tools } = boot({ showCost: false, defaultJoinMode: "async" });
runSpending(COST);
await spawnBackground(tools);
const text = await notificationText(pi);
expect(text).toContain("<total_tokens>");
expect(text).not.toContain("estimated_cost_usd");
});
it("omits it for a model with no pricing data", async () => {
const { pi, tools } = boot({ showCost: true, defaultJoinMode: "async" });
runSpending(0);
await spawnBackground(tools);
expect(await notificationText(pi)).not.toContain("estimated_cost_usd");
});
});
describe("the completion notification", () => {
/** Render a notification through the extension's registered renderer. */
function render(pi: any, details: any): string {
const [, renderer] = pi.registerMessageRenderer.mock.calls[0];
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
return renderer({ details }, { expanded: false }, theme).render().join("\n");
}
const agent = (description: string, totalTokens: number, totalCost: number) => ({
id: description, description, status: "completed", toolUses: 1, turnCount: 1,
totalTokens, totalCost, durationMs: 1000, resultPreview: "done",
});
it("totals a group, so nobody adds four figures by hand", () => {
const { pi } = boot({ showCost: true });
const out = render(pi, { ...agent("first", 1000, 0.01), others: [agent("second", 3000, 0.02)] });
expect(out).toContain("2 agents · 4.0k token · ~$0.03");
});
it("does not total a single agent — the line above already says it", () => {
const { pi } = boot({ showCost: true });
const out = render(pi, agent("only", 1000, 0.01));
expect(out).toContain("~$0.01");
expect(out).not.toContain("1 agents");
});
it("shows no total, and no per-agent cost, when unpriced", () => {
const { pi } = boot({ showCost: true });
const out = render(pi, { ...agent("first", 1000, 0), others: [agent("second", 3000, 0)] });
expect(out).not.toContain("$");
});
it("shows nothing when the setting is off", () => {
const { pi } = boot({ showCost: false });
const out = render(pi, { ...agent("first", 1000, 0.01), others: [agent("second", 3000, 0.02)] });
expect(out).not.toContain("$");
});
});
});
@@ -0,0 +1,320 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { type EventBus, PROTOCOL_VERSION, type RpcDeps, registerRpcHandlers, type SpawnCapable } from "../src/cross-extension-rpc.js";
/** Simple in-process event bus for testing. */
function createEventBus(): EventBus {
const listeners = new Map<string, Set<(data: unknown) => void>>();
return {
on(event, handler) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(handler);
return () => { listeners.get(event)?.delete(handler); };
},
emit(event, data) {
for (const handler of listeners.get(event) ?? []) handler(data);
},
};
}
describe("cross-extension RPC", () => {
let events: EventBus;
let manager: SpawnCapable;
let ctx: object | undefined;
let deps: RpcDeps;
beforeEach(() => {
events = createEventBus();
manager = { spawn: vi.fn().mockReturnValue("agent-42"), abort: vi.fn().mockReturnValue(true) };
ctx = { session: true };
deps = { events, pi: { events }, getCtx: () => ctx, manager };
});
// --- ping ---
describe("ping RPC", () => {
it("replies with protocol version", async () => {
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:ping:reply:req-1", reply);
events.emit("subagents:rpc:ping", { requestId: "req-1" });
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(reply).toHaveBeenCalledWith({ success: true, data: { version: PROTOCOL_VERSION } });
});
it("scopes replies — other requestIds do not receive it", async () => {
registerRpcHandlers(deps);
const wrongReply = vi.fn();
events.on("subagents:rpc:ping:reply:req-other", wrongReply);
events.emit("subagents:rpc:ping", { requestId: "req-1" });
await new Promise((r) => setTimeout(r, 20));
expect(wrongReply).not.toHaveBeenCalled();
});
it("unsub stops responding to pings", async () => {
const { unsubPing } = registerRpcHandlers(deps);
unsubPing();
const reply = vi.fn();
events.on("subagents:rpc:ping:reply:req-1", reply);
events.emit("subagents:rpc:ping", { requestId: "req-1" });
await new Promise((r) => setTimeout(r, 20));
expect(reply).not.toHaveBeenCalled();
});
});
// --- spawn ---
describe("spawn RPC", () => {
it("returns agent id on success", async () => {
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-s1", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-s1", type: "general-purpose", prompt: "do stuff",
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(reply).toHaveBeenCalledWith({ success: true, data: { id: "agent-42" } });
expect(manager.spawn).toHaveBeenCalledWith(
deps.pi, ctx, "general-purpose", "do stuff", {},
);
});
it("passes options through to manager.spawn", async () => {
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-s2", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-s2", type: "Explore", prompt: "find it",
options: { description: "search", isBackground: true },
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(manager.spawn).toHaveBeenCalledWith(
deps.pi, ctx, "Explore", "find it",
{ description: "search", isBackground: true },
);
});
it("returns error when no active session", async () => {
ctx = undefined;
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-s3", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-s3", type: "general-purpose", prompt: "x",
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(reply).toHaveBeenCalledWith({ success: false, error: "No active session" });
expect(manager.spawn).not.toHaveBeenCalled();
});
it("returns error when manager.spawn throws", async () => {
(manager.spawn as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("unknown agent type");
});
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-s4", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-s4", type: "bad-type", prompt: "x",
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(reply).toHaveBeenCalledWith({ success: false, error: "unknown agent type" });
});
it("scopes replies — other requestIds do not receive it", async () => {
registerRpcHandlers(deps);
const wrongReply = vi.fn();
const rightReply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-other", wrongReply);
events.on("subagents:rpc:spawn:reply:req-s5", rightReply);
events.emit("subagents:rpc:spawn", {
requestId: "req-s5", type: "general-purpose", prompt: "x",
});
await vi.waitFor(() => expect(rightReply).toHaveBeenCalled());
expect(wrongReply).not.toHaveBeenCalled();
});
it("unsub stops responding to spawns", async () => {
const { unsubSpawn } = registerRpcHandlers(deps);
unsubSpawn();
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-s6", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-s6", type: "general-purpose", prompt: "x",
});
// Give any potential async handler time to fire
await new Promise((r) => setTimeout(r, 20));
expect(reply).not.toHaveBeenCalled();
});
});
// --- stop ---
describe("stop RPC", () => {
it("returns success when agent is aborted", async () => {
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:stop:reply:req-st1", reply);
events.emit("subagents:rpc:stop", { requestId: "req-st1", agentId: "agent-42" });
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(reply).toHaveBeenCalledWith({ success: true });
expect(manager.abort).toHaveBeenCalledWith("agent-42");
});
it("returns error when agent not found", async () => {
(manager.abort as ReturnType<typeof vi.fn>).mockReturnValue(false);
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:stop:reply:req-st2", reply);
events.emit("subagents:rpc:stop", { requestId: "req-st2", agentId: "nonexistent" });
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(reply).toHaveBeenCalledWith({ success: false, error: "Agent not found" });
});
it("scopes replies — other requestIds do not receive it", async () => {
registerRpcHandlers(deps);
const wrongReply = vi.fn();
const rightReply = vi.fn();
events.on("subagents:rpc:stop:reply:req-other", wrongReply);
events.on("subagents:rpc:stop:reply:req-st3", rightReply);
events.emit("subagents:rpc:stop", { requestId: "req-st3", agentId: "agent-42" });
await vi.waitFor(() => expect(rightReply).toHaveBeenCalled());
expect(wrongReply).not.toHaveBeenCalled();
});
it("unsub stops responding to stop requests", async () => {
const { unsubStop } = registerRpcHandlers(deps);
unsubStop();
const reply = vi.fn();
events.on("subagents:rpc:stop:reply:req-st4", reply);
events.emit("subagents:rpc:stop", { requestId: "req-st4", agentId: "agent-42" });
await new Promise((r) => setTimeout(r, 20));
expect(reply).not.toHaveBeenCalled();
});
});
// --- concurrent requests ---
describe("concurrent requests", () => {
it("handles multiple simultaneous spawn requests independently", async () => {
let callCount = 0;
(manager.spawn as ReturnType<typeof vi.fn>).mockImplementation(() => `agent-${++callCount}`);
registerRpcHandlers(deps);
const reply1 = vi.fn();
const reply2 = vi.fn();
events.on("subagents:rpc:spawn:reply:req-a", reply1);
events.on("subagents:rpc:spawn:reply:req-b", reply2);
events.emit("subagents:rpc:spawn", { requestId: "req-a", type: "Explore", prompt: "first" });
events.emit("subagents:rpc:spawn", { requestId: "req-b", type: "Plan", prompt: "second" });
await vi.waitFor(() => {
expect(reply1).toHaveBeenCalled();
expect(reply2).toHaveBeenCalled();
});
expect(reply1).toHaveBeenCalledWith({ success: true, data: { id: "agent-1" } });
expect(reply2).toHaveBeenCalledWith({ success: true, data: { id: "agent-2" } });
});
});
// --- model override resolution (regression for cross-extension callers
// that forward a serializable string instead of a Model object) ---
describe("spawn RPC model override", () => {
const fakeModel = { id: "gpt-5.5", provider: "openai-codex", name: "GPT 5.5" };
const registry = {
find: (provider: string, id: string) =>
provider === fakeModel.provider && id === fakeModel.id ? fakeModel : null,
getAll: () => [fakeModel],
getAvailable: () => [fakeModel],
};
beforeEach(() => {
ctx = { session: true, modelRegistry: registry };
deps = { events, pi: { events }, getCtx: () => ctx, manager };
});
it("resolves a string model to a Model instance before manager.spawn", async () => {
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-m1", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-m1", type: "general-purpose", prompt: "x",
options: { model: "openai-codex/gpt-5.5" },
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(reply).toHaveBeenCalledWith({ success: true, data: { id: "agent-42" } });
expect(manager.spawn).toHaveBeenCalledWith(
deps.pi, ctx, "general-purpose", "x",
{ model: fakeModel },
);
});
it("passes a Model object through unchanged", async () => {
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-m2", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-m2", type: "general-purpose", prompt: "x",
options: { model: fakeModel },
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
expect(manager.spawn).toHaveBeenCalledWith(
deps.pi, ctx, "general-purpose", "x",
{ model: fakeModel },
);
});
it("surfaces a clear error when the model string can't be resolved", async () => {
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-m3", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-m3", type: "general-purpose", prompt: "x",
options: { model: "nope/does-not-exist" },
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
const call = (reply as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.success).toBe(false);
expect(call.error).toMatch(/Model not found/);
expect(manager.spawn).not.toHaveBeenCalled();
});
it("errors when ctx has no modelRegistry but a string model is given", async () => {
ctx = { session: true }; // no modelRegistry
registerRpcHandlers(deps);
const reply = vi.fn();
events.on("subagents:rpc:spawn:reply:req-m4", reply);
events.emit("subagents:rpc:spawn", {
requestId: "req-m4", type: "general-purpose", prompt: "x",
options: { model: "openai-codex/gpt-5.5" },
});
await vi.waitFor(() => expect(reply).toHaveBeenCalled());
const call = (reply as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.success).toBe(false);
expect(call.error).toMatch(/modelRegistry is unavailable/);
expect(manager.spawn).not.toHaveBeenCalled();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
// README publishes concrete default values (Persistent settings, README:441).
// Every existing test that looked like it checked one actually SET the value
// first — `test/agent-runner-settings.test.ts` had a `beforeEach(setGraceTurns(5))`
// followed by `it("defaults to 5")`, which asserts the setter, not the default.
//
// The defaults live in module-level `let`s that the settings appliers overwrite
// at boot, so reading them after any other suite has run tells you nothing.
// `vi.resetModules()` + a dynamic import gives a genuinely fresh module, which
// is why this lives in its own file: resetModules is file-wide and hostile to
// suites that hold module references across tests.
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("documented defaults (README:441)", () => {
beforeEach(() => {
vi.resetModules();
});
// src/agent-runner.js pulls in the whole pi-coding-agent graph, and
// `server.deps.inline` means resetModules re-transforms all of it — several
// seconds under a loaded full run, versus instant in isolation. The default
// 5s timeout makes these two flaky, so they get an explicit generous one
// rather than a retry.
const HEAVY_REIMPORT_MS = 60_000;
it("grace turns after the soft limit default to 5", async () => {
const { getGraceTurns } = await import("../src/agent-runner.js");
expect(getGraceTurns()).toBe(5);
}, HEAVY_REIMPORT_MS);
it("max turns is unlimited by default", async () => {
const { getDefaultMaxTurns } = await import("../src/agent-runner.js");
expect(getDefaultMaxTurns()).toBeUndefined();
}, HEAVY_REIMPORT_MS);
it("nested subagent depth defaults to 2", async () => {
const { getMaxSubagentDepth } = await import("../src/nested-tools.js");
expect(getMaxSubagentDepth()).toBe(2);
});
// Raised from 4 when top-level spawns started defaulting to background:
// foreground bypasses the pool entirely, so a limit tuned for opt-in
// background would now queue the tail of ordinary parallel fan-outs.
it("background concurrency defaults to 10", async () => {
const { AgentManager } = await import("../src/agent-manager.js");
const manager = new AgentManager();
try {
expect(manager.getMaxConcurrent()).toBe(10);
} finally {
manager.dispose();
}
});
it("top-level spawns default to background, nested spawns to foreground", async () => {
const { resolveAgentInvocationConfig } = await import("../src/invocation-config.js");
// The setting's default (true) is what index.ts passes for top-level calls.
expect(resolveAgentInvocationConfig(undefined, {}, { defaultRunInBackground: true }).runInBackground).toBe(true);
// nested-tools.ts passes false unconditionally.
expect(resolveAgentInvocationConfig(undefined, {}, { defaultRunInBackground: false }).runInBackground).toBe(false);
// An explicit param still wins over either default.
expect(resolveAgentInvocationConfig(undefined, { run_in_background: false }, { defaultRunInBackground: true }).runInBackground).toBe(false);
expect(resolveAgentInvocationConfig(undefined, { run_in_background: true }, { defaultRunInBackground: false }).runInBackground).toBe(true);
});
it("model scope is off by default", async () => {
// Off is the safe default: on, an unconfigured enabledModels would start
// refusing spawns. README:428 documents it as opt-in.
const { isScopeModelsEnabled } = await import("../src/model-scope.js");
expect(isScopeModelsEnabled()).toBe(false);
});
});
@@ -0,0 +1,81 @@
/**
* isolated-provider.e2e.test.ts — reachability guard for PR #152 (issue #151).
*
* PR #152 fixes isolated subagents dropping extension-registered custom providers
* on Pi >= 0.80.8 (the `modelRegistry` → `modelRuntime` migration). agent-runner
* forwards the parent's runtime, read off the ModelRegistry facade as
* `ctx.modelRegistry.runtime` via `as unknown as { runtime }`.
*
* That forwarding is already guarded by the unit test in test/agent-runner.test.ts
* ("passes the parent model runtime …") — but against a MOCK whose `.runtime` is
* hand-set. The mock cannot catch the one thing that would silently break the fix:
* `.runtime` is a `private readonly` field on the real ModelRegistry, absent from
* the public type AND the package exports. If a future Pi renames it, makes it a
* true #private, or moves the module, the cast quietly yields `undefined`, the fix
* omits `modelRuntime`, and the bug returns with no failing test.
*
* This test closes exactly that gap and nothing else: it asserts the real facade
* exposes a runtime-reachable `.runtime` that IS the runtime it wraps. It is not a
* guard for the forwarding itself (that's the unit test's job) — a fuller e2e that
* drives real `runAgent` end-to-end is tracked as a follow-up.
*
* VERSION GATE: `.runtime` only exists in the post-migration facade world (Pi >=
* 0.80.8, where `ModelRuntime` is first exported). The repo's dev dependency is
* pinned post-migration, so this runs by default — but it stays gated because CI
* also runs the suite against the peer-range floor (see .github/workflows/ci.yml),
* where Pi predates the migration. There we DYNAMICALLY import Pi and skip
* cleanly; a static `import { ModelRuntime }` would be a link-time error.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
// Dynamic, so this file LOADS on pre-migration Pi (a static `import { ModelRuntime }`
// would be a link-time error there — 0.80.6 doesn't export it).
const pi = (await import("@earendil-works/pi-coding-agent")) as Record<string, unknown>;
const ModelRuntime = pi.ModelRuntime as
| { create(opts?: Record<string, unknown>): Promise<ModelRuntimeLike> }
| undefined;
// The migration is exactly "ModelRuntime now exists". Absent ⇒ pre-0.80.8 ⇒ there
// is no `.runtime` facade to guard.
const MIGRATED = typeof ModelRuntime?.create === "function";
const RT = ModelRuntime as { create(opts?: Record<string, unknown>): Promise<ModelRuntimeLike> };
// The one method the reach scenario needs; `.runtime` itself is private (reached below).
interface ModelRuntimeLike {
registerProvider(id: string, config: Record<string, unknown>): void;
}
const tmpDirs: string[] = [];
afterAll(() => {
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
describe.skipIf(!MIGRATED)("PR #152 reach: real ModelRegistry exposes .runtime (Pi >= 0.80.8)", () => {
it("ctx.modelRegistry.runtime is reachable and IS the runtime it wraps", async () => {
// A real, configured runtime — as an extension leaves it after registerProvider.
const dir = mkdtempSync(join(tmpdir(), "iso-prov-"));
tmpDirs.push(dir);
const runtime = await RT.create({
authPath: join(dir, "auth.json"),
modelsPath: join(dir, "models.json"),
allowModelNetwork: false,
});
// `.runtime` is private and not in the package exports — reach the compiled
// class by file path, exactly the field the patch's cast depends on. If Pi
// moves/renames/#privates it, THIS line fails loudly instead of the fix
// silently no-op'ing back to the #151 bug.
const indexUrl = import.meta.resolve("@earendil-works/pi-coding-agent");
const mrUrl = indexUrl.replace(/index\.js$/, "core/model-registry.js");
const { ModelRegistry } = (await import(mrUrl)) as {
ModelRegistry: new (rt: ModelRuntimeLike) => { runtime?: unknown };
};
const facade = new ModelRegistry(runtime);
// This is the exact expression agent-runner reads (`ctx.modelRegistry.runtime`).
expect((facade as { runtime?: unknown }).runtime).toBe(runtime);
});
});
@@ -0,0 +1,97 @@
/**
* mention-clone-tool-reachability.e2e.test.ts — reachability guard for the one
* tool the mention clone is built around.
*
* `runMentionClone` hands a session ONE tool and expects the model to call it.
* Whether that tool ever reaches the model is decided entirely inside Pi, by
* `createAgentSession`'s allowlist plumbing — and the unit tests cannot see it:
* their `createAgentSession` is a mock that hands `customTools[0]` straight to
* the model turn, so a session option that silently strips the tool passes
* every one of them.
*
* That is not hypothetical. The clone shipped with `noTools: "all"` on the
* reading its doc comment invites ("start with no tools enabled" — no
* built-ins, keep mine). Pi turns that flag into an EMPTY allowlist, and an
* empty array is truthy, so `AgentSession` builds an empty `Set` and
* `isAllowedTool` rejects every name — custom tools are filtered by the same
* predicate as built-ins. Every mention was prompted with no tools, answered in
* prose, and fell back to a direct start with a warning. The unit suite stayed
* green throughout.
*
* So this asserts against a REAL session, on the two things a mock cannot
* establish:
* 1. the clone's `Agent` tool is actually active on it, and
* 2. nothing else is — the invisible turn cannot read, write or run anything.
*
* No network/LLM: a faux provider satisfies session construction, and the
* assertion is on the constructed tool set rather than on a model turn.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Real pi-mono session construction; a cold first run under full-suite CPU
// contention can exceed vitest's 5s default.
vi.setConfig({ testTimeout: 30_000 });
// Hoisted so the (lifted) mock factory can reach it. Everything except the
// capture is the real module — the point is to construct a REAL session.
const { sessions } = vi.hoisted(() => ({ sessions: [] as any[] }));
vi.mock("@earendil-works/pi-coding-agent", async () => {
const actual = await vi.importActual<any>("@earendil-works/pi-coding-agent");
return {
...actual,
createAgentSession: async (opts: any) => {
const created = await actual.createAgentSession(opts);
sessions.push(created.session);
return created;
},
};
});
import { runMentionClone } from "../../src/mention-clone.js";
import { fauxModelBackend } from "../helpers/faux-model-backend.js";
import { registerFauxProvider } from "../helpers/pi-ai.js";
describe("mention clone tool reachability against real pi-mono", () => {
let cwd: string;
let faux: ReturnType<typeof registerFauxProvider>;
beforeEach(() => {
sessions.length = 0;
cwd = mkdtempSync(join(tmpdir(), "subagents-mention-clone-"));
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
});
afterEach(() => {
faux.unregister();
rmSync(cwd, { recursive: true, force: true });
});
it("the clone's Agent tool is live on the real session, and it is the only one", async () => {
const model = faux.getModel();
const backend = fauxModelBackend(model);
const ctx: any = {
cwd,
model,
getSystemPrompt: () => "PARENT",
// mention-clone reads the runtime off the registry facade, the same shim
// agent-runner carries for Pi >= 0.80.8.
modelRegistry: { ...backend.modelRegistry, runtime: backend.modelRuntime },
sessionManager: { getEntries: () => [], getLeafId: () => undefined },
};
// Never called: the assertion is on what the session exposes, not on the
// faux model deciding to use it.
const agentTool = { name: "Agent", execute: vi.fn() } as any;
// Never rejects by contract; a faux turn that cannot complete is fine,
// because the tool set is fixed at construction.
await runMentionClone({ ctx, type: "Explore", message: "go", agentTool });
expect(sessions).toHaveLength(1);
// The bug this file exists for: with an empty allowlist this is `[]`.
expect(sessions[0].getActiveToolNames()).toEqual(["Agent"]);
});
});
@@ -0,0 +1,145 @@
/**
* tool-veto-reachability.e2e.test.ts — reachability guard for the `ext:` turn-1
* tool veto (issue #125).
*
* `installExtensionToolScope` enforces `ext:` narrowing two ways. Re-narrowing the
* ACTIVE set on `turn_end` is built entirely on public API (`getAllTools`,
* `getActiveToolNames`, `setActiveToolsByName`) and is covered by the unit tests.
* The second half is not: turn 1 cannot be narrowed at all — `before_agent_start`
* fires INSIDE `prompt()` and may widen the tool set, but `createContextSnapshot()`
* freezes that turn's tools immediately after, leaving no window — so out-of-scope
* calls are vetoed at call time by wrapping `session.agent.beforeToolCall`.
*
* That wrap is the one place this extension reaches past the documented surface:
* - `ExtensionBindings` has no tool_call hook, so there is no SDK-level way to
* inject a veto into a session we construct. Pi exposes the veto to EXTENSIONS
* as `pi.on("tool_call") -> { block, reason }`, but we are the SDK caller here,
* not an extension bound to the child session.
* - So we wrap the property Pi itself installs in the AgentSession constructor
* (`_installAgentToolHooks`), chaining to the prior hook so Pi's own `tool_call`
* dispatch still runs.
*
* The unit tests assert our wrapper's behavior against a MOCK session whose `agent`
* is a hand-written `{ beforeToolCall: undefined }`. That mock cannot catch the one
* thing that would silently break the veto: if a future Pi renames `beforeToolCall`,
* stops installing it, makes `agent` non-enumerable/private, or moves the veto
* elsewhere, our assignment lands on a property nothing reads. Every test still
* passes, and out-of-scope tools become callable on turn 1 with no failing test.
*
* This guard closes exactly that gap and nothing else. It asserts against a REAL
* session that:
* 1. Pi installs its own `beforeToolCall` (so there IS a prior hook to chain), and
* 2. after `runAgent`, ours is installed and vetoes an out-of-scope tool in the
* `{ block, reason }` shape Pi honors.
*
* No network/LLM: a faux Model satisfies `createAgentSession`, and the veto is
* invoked directly rather than through a model turn.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent-runner.js";
import { registerAgents } from "../../src/agent-types.js";
import type { AgentConfig } from "../../src/types.js";
import { registerFauxProvider } from "../helpers/pi-ai.js";
// Real pi-mono (loader + dynamic extension import + session construction).
vi.setConfig({ testTimeout: 30_000 });
/** Registers `alpha_read` / `alpha_write`; reused so no new fixture is needed. */
const ALPHA = resolve(fileURLToPath(new URL("../fixtures/ext-alpha.mjs", import.meta.url)));
/** Registers `beta_tool` — loaded but NOT selected by the `ext:` selector below. */
const BETA = resolve(fileURLToPath(new URL("../fixtures/ext-beta.mjs", import.meta.url)));
function makePi() {
return { exec: async () => ({ code: 1, stdout: "", stderr: "" }) } as any;
}
describe("tool veto reachability against real pi-mono", () => {
let cwd: string;
let faux: ReturnType<typeof registerFauxProvider>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "subagents-veto-"));
faux = registerFauxProvider({
provider: "faux",
models: [{ id: "faux-1", contextWindow: 200_000 }],
});
});
afterEach(() => {
faux.unregister();
rmSync(cwd, { recursive: true, force: true });
});
it("pi installs a chainable beforeToolCall, and runAgent's veto blocks out-of-scope tools", async () => {
registerAgents(
new Map([
[
"veto",
{
name: "veto",
description: "veto guard",
builtinToolNames: ["read"],
// Select alpha only — beta loads (its handlers run) but is muted.
extensions: [ALPHA, BETA],
extSelectors: ["ext:ext-alpha.mjs"],
skills: false,
systemPrompt: "You are veto.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
} as AgentConfig,
],
]),
);
const model = faux.getModel();
const modelRegistry: any = {
find: () => model,
getAll: () => [model],
getAvailable: () => [model],
hasConfiguredAuth: () => true,
isUsingOAuth: () => false,
getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }),
registerProvider: () => {},
unregisterProvider: () => {},
};
const ctx: any = { cwd, getSystemPrompt: () => "PARENT", model, modelRegistry };
let priorIsFunction: boolean | undefined;
let session: any;
try {
await runAgent(ctx, "veto", "go", {
pi: makePi(),
model,
onSessionCreated: (s: any) => {
session = s;
// By onSessionCreated our wrapper is already installed, so this being a
// function proves the property is reachable and writable. Pi installing
// its own in the constructor is what gives us something to chain to —
// asserted below via the in-scope path returning undefined rather than
// throwing on a missing prior hook.
priorIsFunction = typeof s.agent?.beforeToolCall === "function";
},
});
} catch {
// A faux-model turn may not complete; the veto is fixed at construction.
}
expect(priorIsFunction).toBe(true);
// Out of scope: beta loaded but the ext: flip did not select it.
await expect(
session.agent.beforeToolCall({ toolCall: { name: "beta_tool" }, args: {} }),
).resolves.toMatchObject({ block: true, reason: expect.any(String) });
// In scope: must NOT be blocked. Reaching Pi's own prior hook without throwing
// also proves the chain is intact (a clobbered/absent prior would surface here).
await expect(
session.agent.beforeToolCall({ toolCall: { name: "alpha_read" }, args: {} }),
).resolves.toSatisfy((r: any) => !r?.block);
});
});
@@ -0,0 +1,148 @@
/**
* usage-reaches-session-stats.e2e.test.ts — the premise #193 rests on, checked
* against the REAL pi runtime.
*
* Every unit test for usage reporting asserts that our tool results carry a
* `usage` field. None of them can establish the thing that makes carrying it
* worth doing: that pi picks it up. That happens entirely inside pi —
* `createToolResultMessage` copies `AgentToolResult.usage` onto the persisted
* message, and `getSessionStats()` folds `toolResult.usage` into the tokens and
* cost the footer, the statusline and `/cost` read. Mock pi, and a release that
* stopped doing either would leave the whole feature reporting into a void with
* a green suite.
*
* So this drives a real `AgentSession` and reads its real `getSessionStats()`,
* with the exact object `PendingUsagePool.drain()` produces — including the
* `cacheRead` our own display total drops but this report must carry, and the
* cost breakdown whose `total` pi reads with no guard at all.
*
* No network/LLM and no model turn: the message is appended through pi's own
* `sessionManager.appendMessage`, because what is under test is the accounting,
* not the streaming that would normally produce the message.
*
* This test is also what set the peer floor. Pi began folding `toolResult.usage`
* into `getSessionStats()` in 0.81.0, when the computation moved to walking
* session entries through `addUsageToTotals`; every 0.80.x sums assistant
* messages alone and drops the field. Running unconditionally is the point —
* against a Pi that does not aggregate, this fails rather than skipping, which
* is how the range stays honest. `peerDependencies` moved to `>=0.81.0` for
* exactly this reason, so the CI floor job runs it too.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PendingUsagePool } from "../../src/usage.js";
import { fauxModelBackend } from "../helpers/faux-model-backend.js";
import { registerFauxProvider } from "../helpers/pi-ai.js";
// Real pi session construction; a cold first run under full-suite CPU
// contention can exceed vitest's 5s default.
vi.setConfig({ testTimeout: 30_000 });
describe("subagent usage reaches the parent session's stats (real pi)", () => {
let cwd: string;
let faux: ReturnType<typeof registerFauxProvider>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "subagents-usage-e2e-"));
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
});
afterEach(() => {
faux.unregister();
rmSync(cwd, { recursive: true, force: true });
});
/** A real session, in memory, on a faux model. */
async function realSession() {
const model = faux.getModel();
const backend = fauxModelBackend(model);
const { session } = await createAgentSession({
cwd,
sessionManager: SessionManager.inMemory(cwd),
model: model as any,
modelRegistry: backend.modelRegistry,
modelRuntime: backend.modelRuntime,
tools: [],
} as any);
return session;
}
/** The tool result our `Agent` tool returns, as pi would persist it. */
function toolResultCarrying(usage: unknown) {
return {
role: "toolResult" as const,
toolCallId: "tc-1",
toolName: "Agent",
content: [{ type: "text" as const, text: "Agent completed." }],
isError: false,
timestamp: 1,
usage,
};
}
it("pi adds our reported tokens and cost to getSessionStats()", async () => {
const session = await realSession();
try {
const before = session.getSessionStats();
const pool = new PendingUsagePool();
pool.add({ input: 1000, output: 400, cacheWrite: 100, cacheRead: 9000, cost: 0.0123 });
pool.add({ input: 2000, output: 600, cacheWrite: 200, cacheRead: 18_000, cost: 0.0077 });
const usage = pool.drain();
session.sessionManager.appendMessage(toolResultCarrying(usage) as any);
const after = session.getSessionStats();
// Exactly what we reported, on every component pi tracks — cacheRead
// included, which is the one pi counts for its own messages and our own
// display total leaves out.
expect(after.tokens.input - before.tokens.input).toBe(3000);
expect(after.tokens.output - before.tokens.output).toBe(1000);
expect(after.tokens.cacheWrite - before.tokens.cacheWrite).toBe(300);
expect(after.tokens.cacheRead - before.tokens.cacheRead).toBe(27_000);
// The cost: the whole point of the feature for anyone watching a
// statusline. `addUsageToTotals` reads `usage.cost.total` with no guard,
// so an incomplete object would have thrown before reaching here.
expect(after.cost - before.cost).toBeCloseTo(0.02, 10);
} finally {
session.dispose?.();
}
});
it("leaves the context-window percentage alone", async () => {
// pi derives context usage from assistant messages only. If that ever
// changed, a delegating session would look like it was filling its context
// with work that happened somewhere else entirely — and users would compact
// for no reason.
const session = await realSession();
try {
const before = session.getSessionStats().contextUsage?.percent ?? null;
const pool = new PendingUsagePool();
pool.add({ input: 150_000, output: 400, cacheWrite: 100, cost: 1.5 });
session.sessionManager.appendMessage(toolResultCarrying(pool.drain()) as any);
expect(session.getSessionStats().contextUsage?.percent ?? null).toBe(before);
} finally {
session.dispose?.();
}
});
it("counts nothing for a tool result that carries no usage", async () => {
// The `reportUsage: false` shape, and every other tool in the session.
const session = await realSession();
try {
const before = session.getSessionStats();
session.sessionManager.appendMessage(toolResultCarrying(undefined) as any);
const after = session.getSessionStats();
expect(after.tokens.input).toBe(before.tokens.input);
expect(after.cost).toBe(before.cost);
} finally {
session.dispose?.();
}
});
});
+206
View File
@@ -0,0 +1,206 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "../src/enabled-models.js";
/** Mock models matching typical registry shape. */
const MODELS = [
{ id: "gemma-4-31b-it", name: "Gemma 4 31B", provider: "google" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" },
];
function makeRegistry(models = MODELS, available?: typeof MODELS): ModelRegistryRef {
return {
getAll() { return models; },
getAvailable: available ? () => available : undefined,
};
}
describe("readEnabledModels", () => {
let agentDir: string;
let projectDir: string;
let originalEnv: string | undefined;
const projectFile = () => join(projectDir, ".pi", "settings.json");
const globalFile = () => join(agentDir, "settings.json");
beforeEach(() => {
agentDir = mkdtempSync(join(tmpdir(), "pi-em-global-"));
projectDir = mkdtempSync(join(tmpdir(), "pi-em-project-"));
originalEnv = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
});
afterEach(() => {
if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalEnv;
rmSync(agentDir, { recursive: true, force: true });
rmSync(projectDir, { recursive: true, force: true });
});
function writeProject(obj: unknown) {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(projectFile(), JSON.stringify(obj));
}
it("returns undefined when both settings files are missing", () => {
expect(readEnabledModels(projectDir)).toBeUndefined();
});
it("returns undefined when field absent from both files", () => {
writeFileSync(globalFile(), JSON.stringify({ defaultProvider: "openai" }));
expect(readEnabledModels(projectDir)).toBeUndefined();
});
it("returns enabledModels from global when project file absent", () => {
writeFileSync(globalFile(), JSON.stringify({
enabledModels: ["anthropic/claude-sonnet-4-6", "google/gemma-4-31b-it"],
}));
expect(readEnabledModels(projectDir)).toEqual([
"anthropic/claude-sonnet-4-6",
"google/gemma-4-31b-it",
]);
});
it("returns enabledModels from project when global file absent", () => {
writeProject({ enabledModels: ["anthropic/claude-haiku-4-5"] });
expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-haiku-4-5"]);
});
it("project overrides global (array replaces wholly, mirrors pi's deep-merge)", () => {
writeFileSync(globalFile(), JSON.stringify({
enabledModels: ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"],
}));
writeProject({ enabledModels: ["anthropic/claude-haiku-4-5"] });
// Project replaces wholly — globals NOT merged in
expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-haiku-4-5"]);
});
it("falls back to global when project file has no enabledModels field", () => {
writeFileSync(globalFile(), JSON.stringify({
enabledModels: ["anthropic/claude-sonnet-4-6"],
}));
writeProject({ defaultProvider: "anthropic" }); // project exists but no enabledModels
expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-sonnet-4-6"]);
});
it("returns undefined when global JSON is corrupt (try/catch swallow)", () => {
writeFileSync(globalFile(), "not json {{{");
expect(readEnabledModels(projectDir)).toBeUndefined();
});
it("returns undefined when enabledModels is not an array (global)", () => {
writeFileSync(globalFile(), JSON.stringify({ enabledModels: "anthropic/claude-sonnet-4-6" }));
expect(readEnabledModels(projectDir)).toBeUndefined();
});
it("returns undefined when enabledModels is not an array (project)", () => {
writeProject({ enabledModels: "anthropic/claude-haiku-4-5" });
// Project's non-array enabledModels is invalid → falls back to global; global empty → undefined
expect(readEnabledModels(projectDir)).toBeUndefined();
});
});
describe("resolveEnabledModels", () => {
it("returns undefined for empty patterns", () => {
expect(resolveEnabledModels([], makeRegistry())).toBeUndefined();
expect(resolveEnabledModels(undefined, makeRegistry())).toBeUndefined();
});
it("returns undefined when no matches", () => {
expect(resolveEnabledModels(["nonexistent/foo"], makeRegistry())).toBeUndefined();
});
it("skips empty string patterns", () => {
const result = resolveEnabledModels(["", "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"], makeRegistry());
// Empty string should not match — only exact patterns should match
expect(result!.size).toBe(2);
});
it("skips whitespace-only patterns", () => {
const result = resolveEnabledModels([" ", "google/gemma-4-31b-it"], makeRegistry());
expect(result).toEqual(new Set(["google/gemma-4-31b-it"]));
});
it("returns undefined when getAvailable returns empty array", () => {
const result = resolveEnabledModels(
["anthropic/claude-haiku-4-5"],
makeRegistry(MODELS, []),
);
expect(result).toBeUndefined();
});
it("deduplicates duplicate patterns", () => {
const result = resolveEnabledModels(
["anthropic/claude-haiku-4-5", "anthropic/claude-haiku-4-5"],
makeRegistry(),
);
expect(result!.size).toBe(1); // duplicate resolves to one entry
});
describe("exact provider/modelId", () => {
it("resolves exact match (key stored lowercase)", () => {
const result = resolveEnabledModels(["google/gemma-4-31b-it"], makeRegistry());
expect(result).toEqual(new Set(["google/gemma-4-31b-it"]));
});
it("resolves model id with colon (part of id, not split)", () => {
const result = resolveEnabledModels(
["anthropic/claude-opus-4-6"],
makeRegistry(),
);
expect(result).toEqual(new Set(["anthropic/claude-opus-4-6"]));
});
it("is case-insensitive", () => {
const result = resolveEnabledModels(["GOOGLE/GEMMA-4-31B-IT"], makeRegistry());
expect(result).toEqual(new Set(["google/gemma-4-31b-it"]));
});
});
describe("no bare modelId or fuzzy matching", () => {
it("returns undefined for bare id (pi always writes provider/modelId)", () => {
const result = resolveEnabledModels(["gemma-4-31b-it"], makeRegistry());
expect(result).toBeUndefined();
});
it("returns undefined for bare substring patterns", () => {
const result = resolveEnabledModels(["Opus"], makeRegistry());
expect(result).toBeUndefined();
});
});
describe("mixed patterns", () => {
it("combines multiple exact provider/modelId in one call", () => {
const result = resolveEnabledModels(
["google/gemma-4-31b-it", "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"],
makeRegistry(),
);
expect(result!.has("google/gemma-4-31b-it".toLowerCase())).toBe(true);
expect(result!.has("anthropic/claude-haiku-4-5".toLowerCase())).toBe(true);
expect(result!.has("anthropic/claude-sonnet-4-6".toLowerCase())).toBe(true);
expect(result!.has("google/gemini-2.5-pro".toLowerCase())).toBe(false);
expect(result!.has("anthropic/claude-opus-4-6".toLowerCase())).toBe(false);
});
});
describe("getAvailable filtering", () => {
it("resolves only against available models when getAvailable present", () => {
const available = [MODELS[0], MODELS[3]]; // google + haiku only
const result = resolveEnabledModels(
["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6", "google/gemma-4-31b-it"],
makeRegistry(MODELS, available),
);
// haiku and google are available; sonnet is not
expect(result!.has("anthropic/claude-haiku-4-5".toLowerCase())).toBe(true);
expect(result!.has("anthropic/claude-sonnet-4-6".toLowerCase())).toBe(false); // not available
expect(result!.has("google/gemma-4-31b-it".toLowerCase())).toBe(true);
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import { execSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { detectEnv } from "../src/env.js";
/** Minimal mock of pi.exec() that shells out via child_process. */
function mockPi(): ExtensionAPI {
return {
exec: async (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => {
try {
const stdout = execSync(`${command} ${args.join(" ")}`, {
cwd: options?.cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: options?.timeout,
});
return { stdout, stderr: "", code: 0, killed: false };
} catch (err: any) {
return { stdout: "", stderr: err.stderr ?? "", code: err.status ?? 1, killed: false };
}
},
} as unknown as ExtensionAPI;
}
describe("detectEnv", () => {
it("detects git repo in current project", async () => {
const env = await detectEnv(mockPi(), process.cwd());
expect(env.isGitRepo).toBe(true);
expect(env.platform).toBe(process.platform);
});
it("returns branch name when on a branch", async () => {
// Create a temp repo on a known branch to test branch detection
const tmpDir = mkdtempSync(join(tmpdir(), "pi-env-branch-"));
try {
execSync("git init && git config user.email test@test.com && git config user.name Test && git checkout -b test-branch && git commit --allow-empty -m init", {
cwd: tmpDir, stdio: "pipe",
});
const env = await detectEnv(mockPi(), tmpDir);
expect(env.isGitRepo).toBe(true);
expect(env.branch).toBe("test-branch");
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it("detects non-git directory", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "pi-env-test-"));
try {
const env = await detectEnv(mockPi(), tmpDir);
expect(env.isGitRepo).toBe(false);
expect(env.branch).toBe("");
expect(env.platform).toBe(process.platform);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* ext-templates-e2e.test.ts — Data-driven, headless end-to-end runner for
* `tools:`/`ext:`/`extensions:` scoping against the REAL pi-mono runtime.
*
* Unlike agent-runner-e2e.test.ts (which builds AgentConfig objects in code),
* this exercises the FULL chain from frontmatter onward:
*
* test/fixtures/.pi/agents/*.md (pre-configured agent templates)
* → real loadCustomAgents() (frontmatter → parseToolsField/ext:)
* → registerAgents() (real registry)
* → real runAgent() [headless] (real DefaultResourceLoader loads the
* real .mjs extension fixtures)
* → real createAgentSession() (real pi-mono tool gating)
* → session.getActiveToolNames() (what the LLM could actually call)
*
* Each template is self-describing: its `expect_present` / `expect_absent`
* frontmatter declares the tools that must / must not be active. Adding a
* scenario = adding a .md file; no test code changes needed.
*
* Headless: a faux Model object satisfies createAgentSession; we assert on the
* pre-prompt gated tool set captured at onSessionCreated, so no LLM/network is
* involved. cwd is the fixtures dir so the templates' relative `extensions:`
* paths resolve and the .mjs fixtures can import `@sinclair/typebox` from the
* repo's node_modules.
*/
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { runAgent } from "../src/agent-runner.js";
import { getAgentConfig, registerAgents } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js";
import { resolveAgentInvocationConfig } from "../src/invocation-config.js";
import { registerFauxProvider } from "./helpers/pi-ai.js";
// Real pi-mono (loader + dynamic extension import + session construction) — a
// cold run under full-suite contention can exceed vitest's 5s default.
vi.setConfig({ testTimeout: 30_000 });
const FIXTURES_DIR = resolve(fileURLToPath(new URL("./fixtures", import.meta.url)));
const TEMPLATES_DIR = join(FIXTURES_DIR, ".pi", "agents");
function csv(val: unknown): string[] {
return typeof val === "string" ? val.split(",").map((s) => s.trim()).filter(Boolean) : [];
}
/** Discover scenarios from the template files at collection time. */
const SCENARIOS = readdirSync(TEMPLATES_DIR)
.filter((f) => f.endsWith(".md"))
.map((file) => {
const fm = parseFrontmatter<Record<string, unknown>>(
readFileSync(join(TEMPLATES_DIR, file), "utf8"),
).frontmatter;
return {
name: file.replace(/\.md$/, ""), // loadCustomAgents keys agents by filename
present: csv(fm.expect_tools_present),
absent: csv(fm.expect_tools_absent),
promptContains: csv(fm.expect_prompt_contains),
promptAbsent: csv(fm.expect_prompt_absent),
};
});
/** Distinctive marker for the parent system prompt (prompt_mode: append asserts it leaks in). */
const PARENT_PROMPT = "PARENT_PROMPT_MARKER";
describe("ext: / tools: scoping — template-driven e2e (real pi-mono, headless)", () => {
let prevAgentDir: string | undefined;
let prevHome: string | undefined;
let hermeticDir: string;
let faux: ReturnType<typeof registerFauxProvider>;
beforeAll(() => {
// Isolate global discovery (getAgentDir / ~/.pi) so the dev's real agents
// and extensions can't bleed into the run.
hermeticDir = mkdtempSync(join(tmpdir(), "subagents-tmpl-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
prevHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = hermeticDir;
process.env.HOME = hermeticDir;
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
// Load the templates through the REAL loader (project agents come from
// <cwd>/.pi/agents → FIXTURES_DIR/.pi/agents) and install them in the
// registry runAgent reads from.
registerAgents(loadCustomAgents(FIXTURES_DIR));
});
afterAll(() => {
faux.unregister();
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(hermeticDir, { recursive: true, force: true });
});
async function runScenario(agentName: string): Promise<{ active: string[]; prompt: string }> {
const model = faux.getModel();
const modelRegistry: any = {
find: () => model,
getAll: () => [model],
getAvailable: () => [model],
hasConfiguredAuth: () => true,
isUsingOAuth: () => false,
getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }),
registerProvider: () => {},
unregisterProvider: () => {},
};
// cwd = fixtures dir so the templates' relative extensions: paths resolve.
// getSystemPrompt returns a distinctive marker so prompt_mode: append can be
// proven to inherit the parent prompt.
const ctx: any = { cwd: FIXTURES_DIR, getSystemPrompt: () => PARENT_PROMPT, model, modelRegistry };
const pi: any = { exec: async () => ({ code: 1, stdout: "", stderr: "" }) };
// Mirror production: the caller resolves frontmatter-locked fields (isolated,
// inherit_context, …) into runAgent options via resolveAgentInvocationConfig.
// isolated is the one that affects tool gating (forces extensions:false + drops ext:).
const resolved = resolveAgentInvocationConfig(getAgentConfig(agentName), { modelFromParams: false } as any);
let active: string[] = [];
let prompt = "";
try {
await runAgent(ctx, agentName, "go", {
pi,
model,
cwd: FIXTURES_DIR,
isolated: resolved.isolated,
inheritContext: resolved.inheritContext,
onSessionCreated: (s) => {
// Both fixed at construction (before any prompt turn): the gated tool
// set and the effective system prompt (built from prompt_mode + skills).
active = s.getActiveToolNames();
prompt = s.systemPrompt;
},
});
} catch {
// Prompt may error (no live provider) — both observables are captured at
// onSessionCreated, before the turn.
}
return { active, prompt };
}
it("every template on disk is discovered, registered, and self-describing", () => {
// 1:1 with the .md files in the templates dir — nothing silently dropped.
const onDisk = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".md")).length;
expect(SCENARIOS.length).toBe(onDisk);
expect(SCENARIOS.length).toBeGreaterThanOrEqual(6);
for (const s of SCENARIOS) {
// Each declares at least one expectation, so no scenario is a no-op.
expect(s.present.length + s.promptContains.length + s.promptAbsent.length).toBeGreaterThan(0);
// Each loaded as ITS OWN agent — guards against runAgent silently falling
// back to general-purpose when a template fails to parse/register.
const cfg = getAgentConfig(s.name);
expect(cfg, `template "${s.name}" did not register (parse error or name mismatch?)`).toBeDefined();
expect(cfg?.name).toBe(s.name);
}
});
it.each(SCENARIOS)(
"$name → active tools and system prompt match the template",
async ({ name, present, absent, promptContains, promptAbsent }) => {
const { active, prompt } = await runScenario(name);
for (const tool of present) expect(active, `${name}: expected "${tool}" active`).toContain(tool);
for (const tool of absent) expect(active, `${name}: expected "${tool}" NOT active`).not.toContain(tool);
for (const s of promptContains) expect(prompt, `${name}: prompt should contain "${s}"`).toContain(s);
for (const s of promptAbsent) expect(prompt, `${name}: prompt should NOT contain "${s}"`).not.toContain(s);
},
);
});
@@ -0,0 +1,246 @@
/**
* fallback-subagent-wiring.test.ts — proves `fallbackSubagent` actually gates
* dispatch through the real registered tools (#183), not just that the resolver
* returns the right verdict.
*
* The load-bearing assertion in the rejection tests is that `runAgent` was NEVER
* called: the complaint in #183 is that a background call starts executing the
* wrong agent before the caller learns anything, so a rejection that still
* spawns would be no fix at all. The fallback tests assert the opposite — that
* it ran, and which agent it ran.
*/
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import { getAllTypes, getAvailableTypes, NO_FALLBACK, registerAgents, setFallbackSubagent } from "../src/agent-types.js";
import subagentsExtension from "../src/index.js";
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
let cwd: string;
let originalCwd: string;
let originalAgentDir: string | undefined;
let originalHome: string | undefined;
/** Real agent files on disk. The Agent tool reloads the registry from
* `process.cwd()` on every call, so an in-memory registry would be wiped before
* dispatch is resolved — and a "disabled" fixture that never loads would make
* the disabled-type test pass merely because the name was unknown. */
function writeAgents(): void {
const dir = join(cwd, ".pi", "agents");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "scout.md"), "---\ndescription: Scout\ntools: read\n---\nScout.\n");
writeFileSync(join(dir, "retired.md"), "---\ndescription: Retired\ntools: read\nenabled: false\n---\nRetired.\n");
}
function ctx() {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd,
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
const textOf = (r: any): string => r.content[0].text;
describe("fallbackSubagent gates dispatch through the real Agent tool", () => {
beforeEach(() => {
originalCwd = process.cwd();
cwd = mkdtempSync(join(tmpdir(), "fallback-wiring-"));
writeAgents();
process.chdir(cwd);
// A developer's real ~/.pi/subagents.json would otherwise set this very
// setting under the tests, and their global agents would pollute the roster.
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
originalHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = join(cwd, "agent-dir");
process.env.HOME = cwd;
vi.mocked(runAgent).mockReset();
});
afterEach(() => {
setFallbackSubagent(undefined);
delete (globalThis as any)[Symbol.for("pi-subagents:manager")];
process.chdir(originalCwd);
if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
if (originalHome == null) delete process.env.HOME;
else process.env.HOME = originalHome;
registerAgents(new Map());
rmSync(cwd, { recursive: true, force: true });
});
function boot() {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
return { pi, tools, lifecycle };
}
for (const background of [false, true]) {
it(`refuses an unknown type without spawning (run_in_background: ${background})`, async () => {
const { tools } = boot();
setFallbackSubagent(NO_FALLBACK);
const result = await tools.get("Agent").execute(
"tc-1",
{
prompt: "do it",
description: "typo dispatch",
subagent_type: "definitely-missing",
run_in_background: background,
},
undefined, undefined, ctx(),
);
expect(textOf(result)).toContain('Unknown or disabled agent type: "definitely-missing"');
expect(textOf(result)).toContain("scout");
// The whole point: nothing ran.
expect(runAgent).not.toHaveBeenCalled();
});
}
it("still falls back — and says so — when the setting is unset", async () => {
const { tools } = boot();
vi.mocked(runAgent).mockResolvedValue({
responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false,
});
const result = await tools.get("Agent").execute(
"tc-2",
{ prompt: "do it", description: "typo dispatch", subagent_type: "definitely-missing" },
undefined, undefined, ctx(),
);
expect(textOf(result)).toContain('Note: Unknown agent type "definitely-missing"');
// Not merely "something ran" — a fallback that routed anywhere else would pass that.
expect(runAgent).toHaveBeenCalledWith(
expect.anything(), "general-purpose", "do it", expect.anything(),
);
});
it("carries the fallback note on the background branch too", async () => {
// Previously the note was computed after spawnAndWait returned, so only a
// foreground caller ever saw it (#183).
const { tools } = boot();
vi.mocked(runAgent).mockReturnValue(new Promise(() => {}) as any);
const result = await tools.get("Agent").execute(
"tc-3",
{
prompt: "do it",
description: "typo dispatch",
subagent_type: "definitely-missing",
run_in_background: true,
},
undefined, undefined, ctx(),
);
expect(textOf(result)).toContain('Note: Unknown agent type "definitely-missing"');
expect(textOf(result)).toContain("started in background");
});
it("refuses a disabled type, which used to dispatch with a mixed identity", async () => {
const { tools } = boot();
setFallbackSubagent(NO_FALLBACK);
// Pin the fixture: without this the test passes identically if retired.md
// stopped loading, since "unknown" and "disabled" share one message.
expect(getAllTypes()).toContain("retired");
expect(getAvailableTypes()).not.toContain("retired");
const result = await tools.get("Agent").execute(
"tc-4",
{ prompt: "do it", description: "disabled dispatch", subagent_type: "retired" },
undefined, undefined, ctx(),
);
expect(textOf(result)).toContain("Unknown or disabled agent type");
expect(runAgent).not.toHaveBeenCalled();
});
it("never persists a blank type into a scheduled job", async () => {
// `fellBackFrom` is "" for a blank request, and `??` does not treat "" as
// nullish — the job would be stored with an empty type and re-fail forever.
const { tools, lifecycle } = boot();
await lifecycle.get("session_start")({}, ctx());
const result = await tools.get("Agent").execute(
"tc-5",
{ prompt: "later", description: "blank type", subagent_type: " ", schedule: "+1h" },
undefined, undefined, ctx(),
);
expect(textOf(result)).toContain("Scheduled");
const storeDir = join(cwd, ".pi", "subagent-schedules");
const jobs = readdirSync(storeDir).flatMap((f) =>
JSON.parse(readFileSync(join(storeDir, f), "utf-8")).jobs ?? [],
);
expect(jobs).toHaveLength(1);
expect(jobs[0].subagent_type).toBe("general-purpose");
});
it("never blocks resume, which ignores subagent_type entirely", async () => {
// resume replays a stored session; the type is required by the schema but
// unused. Gating it would make a live agent unresumable the moment its type
// is deleted or disabled — the opposite of what strict dispatch is for.
const { tools } = boot();
vi.mocked(runAgent).mockResolvedValue({
// `messages` is not optional on a real AgentSession, and a background
// resume reads it to anchor transcript streaming.
responseText: "first", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false,
});
const spawned = await tools.get("Agent").execute(
"tc-6",
{ prompt: "start", description: "live agent", subagent_type: "scout", run_in_background: false },
undefined, undefined, ctx(),
);
const id = /Agent ID: (\S+)/.exec(textOf(spawned))?.[1]
?? (spawned as any).details?.agentId;
expect(id).toBeTruthy();
setFallbackSubagent(NO_FALLBACK);
const resumed = await tools.get("Agent").execute(
"tc-7",
{ resume: id, prompt: "keep going", description: "resume", subagent_type: "deleted-since" },
undefined, undefined, ctx(),
);
expect(textOf(resumed)).not.toContain("Unknown or disabled agent type");
});
it("applies the same contract to cross-extension spawns", async () => {
// The registry entry is what RPC callers reach; it must not be a way around
// the setting. A throw here becomes an error envelope at the RPC boundary.
boot();
setFallbackSubagent(NO_FALLBACK);
const registry = (globalThis as any)[Symbol.for("pi-subagents:manager")];
expect(() => registry.spawn({}, ctx(), "definitely-missing", "do it", { description: "rpc" }))
.toThrow(/Unknown or disabled agent type/);
expect(runAgent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,9 @@
---
description: "Loads alpha+beta, selects only alpha via ext:. Flip mutes beta."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs"
expect_tools_present: "read, bash, alpha_read, alpha_write"
expect_tools_absent: "beta_tool"
---
e2e template: a single ext: selector flips extension tools to an allowlist;
alpha is selected (all its tools surface), beta is loaded but muted.
@@ -0,0 +1,9 @@
---
description: "Selects beta via ext:; the loaded alpha extension is muted."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-beta.mjs"
expect_tools_present: "read, beta_tool"
expect_tools_absent: "alpha_read, alpha_write"
---
e2e template: mirror of all-and-alpha-selected — selecting beta proves the flip
mutes the *other* loaded extension (alpha) regardless of which one is named.
@@ -0,0 +1,10 @@
---
description: "Selects alpha but denylists alpha_write via disallowed_tools."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs"
disallowed_tools: "alpha_write"
expect_tools_present: "read, alpha_read"
expect_tools_absent: "alpha_write, beta_tool"
---
e2e template: disallowed_tools removes an extension tool even when the ext:
selector would otherwise surface it.
@@ -0,0 +1,12 @@
---
description: "An ext: selector cannot resurrect an excluded extension."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
exclude_extensions: ext-beta.mjs
tools: "*, ext:ext-beta.mjs"
expect_tools_present: "read"
expect_tools_absent: "beta_tool, alpha_read, alpha_write"
---
e2e template: exclude_extensions beats a tools: ext: selector — beta never
loads, so ext:ext-beta.mjs is an orphan (warns, does not pull beta back in).
Alpha tools are also absent because any ext: entry flips extension tools to an
explicit allowlist and alpha is not selected.
+12
View File
@@ -0,0 +1,12 @@
---
description: "Loads alpha+beta, excludes beta via exclude_extensions."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
exclude_extensions: ext-beta.mjs
tools: "*"
expect_tools_present: "read, alpha_read, alpha_write"
expect_tools_absent: "beta_tool"
---
e2e template: exclude_extensions removes an extension after the include set is
computed — alpha surfaces normally, beta's tools never register. (Excluding a
name that the extensions: list also loads warns "in both — exclude wins"; the
exclusion still applies.)
@@ -0,0 +1,8 @@
---
description: "extensions:false — no extension tools at all."
extensions: false
tools: "*"
expect_tools_present: "read, bash, edit, write, grep, find, ls"
expect_tools_absent: "alpha_read, alpha_write, beta_tool"
---
e2e template: extensions are disabled, so only the built-in tools are active.
+8
View File
@@ -0,0 +1,8 @@
---
description: "tools value format: YAML flow array."
tools: [read, grep, find]
expect_tools_present: "read, grep, find"
expect_tools_absent: "bash, edit, write, ls"
---
e2e template (format check 3/3): YAML array. Per the README, `[a, b]` == `"a, b"`,
so this must yield the same active tool set as the CSV forms.
@@ -0,0 +1,8 @@
---
description: "tools value format: quoted CSV string."
tools: "read, grep, find"
expect_tools_present: "read, grep, find"
expect_tools_absent: "bash, edit, write, ls"
---
e2e template (format check 2/3): quoted CSV string. Same result as the unquoted
and array forms.
@@ -0,0 +1,8 @@
---
description: "tools value format: unquoted CSV."
tools: read, grep, find
expect_tools_present: "read, grep, find"
expect_tools_absent: "bash, edit, write, ls"
---
e2e template (format check 1/3): unquoted CSV. Must be equivalent to the quoted
and array forms — see fmt-quoted-csv.md and fmt-array.md.
@@ -0,0 +1,11 @@
---
description: "isolated:true forces built-ins only, overriding extensions and ext:."
isolated: true
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs"
expect_tools_present: "read, bash, edit, write, grep, find, ls"
expect_tools_absent: "alpha_read, alpha_write, beta_tool"
---
e2e template: per the README, isolated:true is hermetic — it forces
extensions:false + skills:false and drops ext: selectors, leaving only built-ins,
even though this template also sets extensions and an ext: selector.
@@ -0,0 +1,10 @@
---
description: "ext: selecting the lazy extension surfaces its session_start tool."
extensions: "./ext-lazy.mjs, ./ext-alpha.mjs"
tools: "*, ext:ext-lazy.mjs"
expect_tools_present: "read, bash, lazy_tool"
expect_tools_absent: "alpha_read, alpha_write"
---
e2e template: the case a static allowlist can never express. `lazy_tool` does not
exist when the session is constructed, so it cannot be listed up front — scope
has to be re-derived once the extension registers it.
@@ -0,0 +1,10 @@
---
description: "A lazy extension left out of the ext: flip stays muted."
extensions: "./ext-lazy.mjs, ./ext-alpha.mjs"
tools: "*, ext:ext-alpha.mjs"
expect_tools_present: "read, bash, alpha_read, alpha_write"
expect_tools_absent: "lazy_tool"
---
e2e template: the mirror of lazy-ext-selected — admitting late tools must not
mean admitting ALL late tools. ext-lazy loads and its session_start handler runs,
but the `ext:` flip did not select it, so `lazy_tool` never becomes active.
@@ -0,0 +1,9 @@
---
description: "A tool registered at session_start reaches the subagent (#125)."
extensions: "./ext-lazy.mjs"
tools: "*"
expect_tools_present: "read, bash, lazy_tool"
---
e2e template: ext-lazy registers `lazy_tool` from `session_start`, i.e. AFTER
loader.reload() has already run. Any scoping that snapshots the tool set at
construction drops it permanently — this asserts it survives.
@@ -0,0 +1,25 @@
---
description: "memory + disallowed_tools — a denied write tool must not count as write capability."
memory: project
tools: read, write
disallowed_tools: write
expect_tools_present: "read"
expect_tools_absent: "write, edit, bash, grep"
expect_prompt_contains: "Agent Memory (read-only), Memory scope: project"
expect_prompt_absent: "persistent memory directory"
---
README: "The `disallowed_tools` field is respected when determining write
capability — an agent with `tools: write` + `disallowed_tools: write` correctly
gets read-only memory."
This is the combination neither `memory-readonly.md` (no write tool at all) nor
`memory-readwrite.md` (write tool, nothing denied) exercises: the write tool IS
in the `tools:` set, so a naive capability check sees it and hands the agent the
read-write memory prompt — which also appends `write`/`edit` to the tool names,
widening the set the agent asked for. The denylist still filters the registry, so
the visible symptom is an agent instructed to write memory files with no tool to
do it; the invisible one is the tool-name widening.
Scope `project` is safe here precisely because the read-only branch creates no
memory directory — if this fixture ever flips to the read-write branch, it would
also start writing into the repo, which is the loudest possible signal.
@@ -0,0 +1,13 @@
---
description: "memory read-only — an agent without write tools gets a read-only memory block."
memory: project
tools: read, grep
expect_tools_present: "read, grep"
expect_tools_absent: "write, edit"
expect_prompt_contains: "Agent Memory (read-only), Memory scope: project"
expect_prompt_absent: "persistent memory directory"
---
A read-only memory agent. Per the README, agents without write/edit tools
auto-get a read-only memory fallback: existing memory is injected, no write
access is granted, and no memory directory is created (so `project` scope is
safe here — nothing is written into the repo).
@@ -0,0 +1,12 @@
---
description: "memory read-write — an agent with write tools gets a writable memory block."
memory: user
tools: read, write
expect_tools_present: "read, write, edit"
expect_prompt_contains: "persistent memory directory, Memory scope: user"
expect_prompt_absent: "(read-only)"
---
A write-capable memory agent. Per the README, agents with write/edit tools get
full read-write memory; the memory tool set is completed (edit is auto-added) and
a writable memory block is injected into the system prompt. Scope `user` so the
memory dir is created under the hermetic HOME, never in the repo.
+9
View File
@@ -0,0 +1,9 @@
---
description: "Minimal agent — only description and body; every other field omitted."
expect_tools_present: "read, bash, edit, write, grep, find, ls"
expect_tools_absent: "alpha_read, alpha_write, beta_tool"
---
A minimal agent. Per the README defaults: omitted `tools` => all 7 built-ins;
omitted `extensions` => true (all *discovered* extensions load — none exist in
this hermetic fixture, so no extension tools surface). expect_* are test-harness
annotations and are ignored by the agent loader.
@@ -0,0 +1,9 @@
---
description: "Narrows alpha to a single tool via ext:ext-alpha.mjs/alpha_read."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs/alpha_read"
expect_tools_present: "read, alpha_read"
expect_tools_absent: "alpha_write, beta_tool"
---
e2e template: ext:<ext>/<tool> narrows alpha to just alpha_read; alpha_write
and the unselected beta extension are both muted.
@@ -0,0 +1,8 @@
---
description: "No ext: selector, so all loaded extensions' tools surface."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*"
expect_tools_present: "read, alpha_read, alpha_write, beta_tool"
---
e2e template: with no ext: entry there is no flip, so every loaded extension's
tools surface alongside the built-ins.
@@ -0,0 +1,8 @@
---
description: "prompt_mode append — body appended to the parent's prompt."
prompt_mode: append
expect_tools_present: "read"
expect_prompt_contains: "PARENT_PROMPT_MARKER, APPEND_BODY_MARKER"
---
APPEND_BODY_MARKER — in append mode the parent prompt flows in verbatim, so the
real session's system prompt contains BOTH the parent marker and this body.
@@ -0,0 +1,9 @@
---
description: "prompt_mode replace (default) — body is the full system prompt."
prompt_mode: replace
expect_tools_present: "read"
expect_prompt_contains: "REPLACE_BODY_MARKER"
expect_prompt_absent: "PARENT_PROMPT_MARKER"
---
REPLACE_BODY_MARKER — in replace mode the parent prompt is NOT inherited, so the
real session's system prompt contains this body but not the parent's marker.
@@ -0,0 +1,8 @@
---
description: "skills: preloads a named skill into the system prompt."
skills: probe-skill
expect_tools_present: "read"
expect_prompt_contains: "Preloaded Skill: probe-skill, SKILL_BODY_MARKER"
---
A skill-preloading agent. The `skills: probe-skill` entry must inject
test/fixtures/.pi/skills/probe-skill.md into the real session's system prompt.
+9
View File
@@ -0,0 +1,9 @@
---
description: "tools narrowed to two built-ins; extensions omitted (=> true)."
tools: read, grep
expect_tools_present: "read, grep"
expect_tools_absent: "bash, edit, write, find, ls, alpha_read, alpha_write, beta_tool"
---
e2e template: a plain built-in allowlist narrows to exactly the listed tools.
extensions is omitted (defaults to true); none are discovered here, so no
extension tools surface.
+9
View File
@@ -0,0 +1,9 @@
---
description: "tools:none => zero built-ins; loaded extension tools still surface."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: none
expect_tools_present: "alpha_read, alpha_write, beta_tool"
expect_tools_absent: "read, bash, edit, write, grep, find, ls"
---
e2e template: `tools: none` yields zero built-ins. With extensions loaded and no
ext: selector, all extension tools still surface — only the built-ins are dropped.
@@ -0,0 +1,7 @@
---
description: "tools omitted (=> all built-ins) with extensions explicitly loaded."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
expect_tools_present: "read, bash, edit, write, grep, find, ls, alpha_read, alpha_write, beta_tool"
---
e2e template: omitting `tools` yields all 7 built-ins; with extensions loaded and
no ext: selector there is no flip, so every loaded extension tool also surfaces.
+5
View File
@@ -0,0 +1,5 @@
# Probe Skill
SKILL_BODY_MARKER — distinctive content used by the template-driven e2e to
prove that a `skills:` frontmatter entry is preloaded into the real subagent
system prompt.
+25
View File
@@ -0,0 +1,25 @@
/**
* Real extension fixture for the end-to-end test. Loaded by pi-mono's actual
* DefaultResourceLoader via `additionalExtensionPaths`. Registers one tool,
* `e2e_probe`, that writes a marker file when executed so the test can prove
* the model was actually able to call it (not just that it appeared in a list).
*
* Plain ESM (.mjs) so node imports it without any TS transform step.
*/
import { writeFileSync } from "node:fs";
import { Type } from "@sinclair/typebox";
export default function (pi) {
pi.registerTool({
name: "e2e_probe",
label: "E2E Probe",
description: "Writes a marker file. Used only by the end-to-end test.",
parameters: Type.Object({
marker: Type.String({ description: "Absolute path of the marker file to write." }),
}),
async execute(_id, params) {
writeFileSync(params.marker, "probed");
return { content: [{ type: "text", text: `wrote ${params.marker}` }] };
},
});
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Real extension fixture "alpha" for the template-driven e2e runner.
* Registers two tools so narrowing (ext:ext-alpha.mjs/alpha_read) can be
* distinguished from exposing the whole extension. Plain ESM so node imports
* it without a TS transform; lives inside the repo tree so `@sinclair/typebox`
* resolves. Tools are never invoked by the runner — it only inspects the
* session's active tool set — so execute() is a trivial stub.
*/
import { Type } from "@sinclair/typebox";
export default function (pi) {
for (const name of ["alpha_read", "alpha_write"]) {
pi.registerTool({
name,
label: name,
description: `Alpha extension tool ${name} (e2e fixture).`,
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: name }] };
},
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Real extension fixture "beta" for the template-driven e2e runner.
* Registers a single tool, used to prove that the `ext:` allowlist flip mutes
* a loaded-but-unselected extension. See ext-alpha.mjs for the conventions.
*/
import { Type } from "@sinclair/typebox";
export default function (pi) {
pi.registerTool({
name: "beta_tool",
label: "beta_tool",
description: "Beta extension tool (e2e fixture).",
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: "beta_tool" }] };
},
});
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Real extension fixture "lazy" for the template-driven e2e runner.
*
* Registers its tool from `session_start` rather than at load — the shape that
* broke subagents in issue #125. pi-mcp does exactly this (it can only enumerate
* tools once its MCP servers connect), and eagerly connecting at load time would
* orphan child processes on pi's non-agent code paths.
*
* The point of the fixture is the TIMING: at `loader.reload()` this extension
* contributes no tools at all, so any scoping that snapshots the tool set then
* will drop `lazy_tool` permanently. See ext-alpha.mjs for the conventions.
*/
import { Type } from "@sinclair/typebox";
export default function (pi) {
pi.on("session_start", () => {
pi.registerTool({
name: "lazy_tool",
label: "lazy_tool",
description: "Lazily-registered extension tool (e2e fixture).",
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: "lazy_tool" }] };
},
});
});
}
+553
View File
@@ -0,0 +1,553 @@
import { Editor, visibleWidth } from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import type { AgentManager } from "../src/agent-manager.js";
import { registerAgents } from "../src/agent-types.js";
import type { AgentConfig, AgentRecord } from "../src/types.js";
import { type AgentActivity, getDisplayName } from "../src/ui/agent-widget.js";
import { FleetList, type FleetUICtx, formatFleetElapsed, formatFleetTokens } from "../src/ui/fleet-list.js";
// ---- Key sequences (see node_modules/@earendil-works/pi-tui/dist/keys.js) ----
const DOWN = "\x1b[B";
const UP = "\x1b[A";
const LEFT = "\x1b[D";
const RIGHT = "\x1b[C";
const ESC = "\x1b";
const ENTER = "\r";
// Kitty-protocol key-RELEASE for ↓ (event type 3) — listeners receive these too.
const DOWN_RELEASE = "\x1b[1;1:3B";
const theme = { fg: (c: string, s: string) => `<${c}>${s}</${c}>`, bold: (s: string) => `*${s}*` };
/** An agent that renders as a badge — no default agent configures a color. */
const BADGED_TYPE = "colored-reviewer";
const PURPLE_BACKGROUND = "\u001b[48;2;130;125;189m";
const BADGED_CONFIG: AgentConfig = {
name: BADGED_TYPE,
displayName: "Code Reviewer",
color: "purple",
description: "Reviews code",
extensions: false,
skills: false,
systemPrompt: "Review code.",
promptMode: "replace",
};
/**
* Visible text of a rendered row: ANSI stripped, along with this theme's fake
* `<color>` / `*bold*` markers — all three stand in for zero-width escapes.
*/
function plain(row: string): string {
return row.replace(/\u001b\[[0-9;]*m/g, "").replace(/<\/?[a-zA-Z]+>|\*/g, "");
}
/** A no-op session so a record is "openable" by default (the list hides session-less agents). */
const FAKE_SESSION = { subscribe: () => () => {}, messages: [] };
function makeRecord(over: Partial<AgentRecord> = {}): AgentRecord {
return {
id: "a1",
type: "general-purpose",
description: "Sleep then report 1",
status: "running",
toolUses: 0,
startedAt: Date.now(),
session: FAKE_SESSION as any,
lifetimeUsage: { input: 13100, output: 0, cacheWrite: 0 },
compactionCount: 0,
...over,
} as AgentRecord;
}
/** Fake manager exposing only what FleetList touches. */
function fakeManager(agents: AgentRecord[]): AgentManager {
return {
listAgents: () => agents,
abort: () => true,
steer: vi.fn(() => true),
} as unknown as AgentManager;
}
interface Harness {
fleet: FleetList;
ui: FleetUICtx;
manager: AgentManager;
/** The overlay component (a real ConversationViewer) once one is opened. */
overlayComponent: () => { handleInput(data: string): void } | undefined;
/** Feed a key to the registered input handler; returns the consume result. */
press: (data: string) => { consume?: boolean } | undefined;
/** Render the currently-registered below-editor widget at the given width. */
render: (width?: number) => string[];
setEditorText: (t: string) => void;
/** Whether an overlay has been opened. */
overlayOpened: () => boolean;
/** Whether the most recently opened overlay's `done` was invoked (closed). */
overlayClosed: () => boolean;
/** Simulate the viewer closing itself (Esc → done); flushes the close microtask. */
closeOverlay: () => Promise<void>;
/** The fake `tui` handed to the widget factory; tests set `focusedComponent` on it. */
widgetTui: { requestRender(): void; focusedComponent?: unknown };
}
function harness(agents: AgentRecord[]): Harness {
let inputHandler: ((data: string) => { consume?: boolean } | undefined) | undefined;
let widgetFactory: ((tui: any, theme: any) => { render(w: number): string[] }) | undefined;
let editorText = "";
let opened = false;
let closed = false;
let overlayDone: ((r: undefined) => void) | undefined;
let overlayComponent: { handleInput(data: string): void } | undefined;
const fakeTui = { requestRender: () => {}, terminal: { columns: 120, rows: 40 } };
const ui: FleetUICtx = {
setWidget: (_key, content) => { widgetFactory = content as any; },
onTerminalInput: (h) => { inputHandler = h; return () => { inputHandler = undefined; }; },
getEditorText: () => editorText,
notify: () => {},
custom: ((factory: any) => {
opened = true;
return new Promise<undefined>((resolve) => {
const done = (r: undefined) => { closed = true; overlayDone = undefined; resolve(r); };
overlayDone = done;
// Construct the overlay component so the controller wires viewerClose,
// and keep it so tests can drive the real ConversationViewer's input.
overlayComponent = factory(fakeTui, theme, undefined, done);
});
}) as FleetUICtx["custom"],
};
const manager = fakeManager(agents);
const fleet = new FleetList(manager, new Map());
fleet.setUICtx(ui);
fleet.update();
return {
fleet,
ui,
manager,
overlayComponent: () => overlayComponent,
press: (data) => inputHandler?.(data),
render: (width = 120) => (widgetFactory ? widgetFactory(fakeTui, theme).render(width) : []),
setEditorText: (t) => { editorText = t; },
overlayOpened: () => opened,
overlayClosed: () => closed,
closeOverlay: async () => { overlayDone?.(undefined); await Promise.resolve(); },
widgetTui: fakeTui,
};
}
describe("formatFleetElapsed", () => {
it("renders integer seconds (no decimal, no suffix)", () => {
expect(formatFleetElapsed(0)).toBe("0s");
expect(formatFleetElapsed(11_000)).toBe("11s");
expect(formatFleetElapsed(11_400)).toBe("11s");
expect(formatFleetElapsed(11_600)).toBe("12s");
});
it("floors negatives to 0s", () => {
expect(formatFleetElapsed(-500)).toBe("0s");
});
});
describe("formatFleetTokens", () => {
it("prefixes a down-arrow and uses plural 'tokens'", () => {
expect(formatFleetTokens(13_100)).toBe("↓ 13.1k tokens");
expect(formatFleetTokens(950)).toBe("↓ 950 tokens");
expect(formatFleetTokens(1_200_000)).toBe("↓ 1.2M tokens");
});
});
describe("FleetList navigation", () => {
it("does not register a widget when there are no agents", () => {
const h = harness([]);
expect(h.render()).toEqual([]);
});
it("hides nested child records from the coordinator fleet", () => {
const h = harness([
makeRecord({ id: "top", description: "top-level" }),
makeRecord({ id: "nested", description: "nested-child", parentAgentId: "top" }),
]);
const output = h.render().join("\n");
expect(output).toContain("top-level");
expect(output).not.toContain("nested-child");
});
it("activates on ↓ at an empty prompt, consuming the key", () => {
const h = harness([makeRecord()]);
const res = h.press(DOWN);
expect(res).toEqual({ consume: true });
// main selected, list active → nav hint shown
expect(h.render().some(l => l.includes("enter view"))).toBe(true);
});
it("also activates on ← (matches the '← for agents' hint)", () => {
const h = harness([makeRecord()]);
expect(h.press(LEFT)).toEqual({ consume: true });
});
it("does NOT activate when the prompt is non-empty (typing is preserved)", () => {
const h = harness([makeRecord()]);
h.setEditorText("hello");
expect(h.press(DOWN)).toBeUndefined();
});
it("ignores key-release events so one tap moves exactly one row", () => {
const h = harness([
makeRecord({ id: "a1", description: "one" }),
makeRecord({ id: "a2", description: "two" }),
]);
h.press(DOWN); // activate → selection on main (idx 0)
h.press(DOWN_RELEASE); // release half of the SAME tap — must be a no-op
expect(h.render().find(l => l.includes("main"))).toContain("●");
h.press(DOWN); // a real second tap → first agent
h.press(DOWN_RELEASE);
expect(h.render().find(l => l.includes("one"))).toContain("●");
expect(h.render().find(l => l.includes("two"))).toContain("○");
});
it("renders the whole selected row in the theme's primary text color (#230)", () => {
const h = harness([
makeRecord({ id: "a1", description: "one" }),
makeRecord({ id: "a2", description: "two" }),
]);
h.press(DOWN); // activate → main
h.press(DOWN); // → a1
const selected = h.render().find(l => l.includes("one"))!;
// Selection marker keeps accent color; row content uses primary text color.
expect(selected).toContain("<accent>●</accent>");
expect(selected).toContain("<text>one</text>");
expect(selected).toMatch(/<text>\d+s · ↓ [\d.]+k? tokens<\/text>/);
// Agent display name rendered with the text token too (this type has no badge).
expect(selected).toContain(`<text>${getDisplayName("general-purpose")}</text>`);
// Inactive rows keep the muted/dim treatment.
const unselected = h.render().find(l => l.includes("two"))!;
expect(unselected).toContain("<dim>○</dim>");
expect(unselected).toMatch(/<dim>\d+s · ↓ [\d.]+k? tokens<\/dim>/);
expect(unselected).not.toContain("<text>");
});
it("keeps a color badge on the selected row, bolded, without shifting it (#230)", () => {
registerAgents(new Map([[BADGED_TYPE, BADGED_CONFIG]]));
try {
const h = harness([
makeRecord({ id: "a1", type: BADGED_TYPE, description: "one" }),
makeRecord({ id: "a2", type: BADGED_TYPE, description: "two" }),
]);
h.press(DOWN); // activate → main
const before = h.render().find(l => l.includes("one"))!;
expect(before).toContain(`${PURPLE_BACKGROUND}`);
expect(before).toContain(` ${BADGED_CONFIG.displayName} `);
h.press(DOWN); // → a1
const selected = h.render().find(l => l.includes("one"))!;
// Selection bolds the badge rather than repainting it (Claude Code's FleetView) …
expect(selected).toContain(PURPLE_BACKGROUND);
expect(selected).toContain(`* ${BADGED_CONFIG.displayName} *`);
expect(selected).not.toContain(`<text>${BADGED_CONFIG.displayName}`);
// … so the description stays in the same column as when unselected.
expect(plain(selected).indexOf("one")).toBe(plain(before).indexOf("one"));
} finally {
registerAgents(new Map());
}
});
it("moves selection down/up and clamps at the ends", () => {
const agents = [
makeRecord({ id: "a1", description: "one" }),
makeRecord({ id: "a2", description: "two" }),
];
const h = harness(agents);
h.press(DOWN); // activate → index 0 (main)
h.press(DOWN); // → 1 (a1)
expect(h.render().find(l => l.includes("one"))).toContain("●");
h.press(DOWN); // → 2 (a2)
h.press(DOWN); // clamp at 2
expect(h.render().find(l => l.includes("two"))).toContain("●");
expect(h.render().find(l => l.includes("one"))).toContain("○");
});
it("↑ above 'main' deactivates (returns to the prompt)", () => {
const h = harness([makeRecord()]);
h.press(DOWN); // activate, index 0
expect(h.press(UP)).toEqual({ consume: true });
// back to inactive hint
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
});
it("Esc deactivates", () => {
const h = harness([makeRecord()]);
h.press(DOWN);
expect(h.press(ESC)).toEqual({ consume: true });
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
});
it("passes non-nav keys through and cancels navigation", () => {
const h = harness([makeRecord()]);
h.press(DOWN);
expect(h.press(RIGHT)).toBeUndefined();
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
});
it("ignores all input while disabled and hides the widget", () => {
const h = harness([makeRecord()]);
h.fleet.setEnabled(false);
expect(h.press(DOWN)).toBeUndefined();
expect(h.render()).toEqual([]);
});
it("re-arms the refresh timer when the list is re-shown (toggle off→on)", () => {
vi.useFakeTimers();
try {
const agents = [makeRecord({ id: "a1" })];
const listAgents = vi.fn(() => agents);
const manager = { listAgents, abort: () => true } as unknown as AgentManager;
const fleet = new FleetList(manager, new Map());
fleet.setUICtx({
setWidget: () => {}, onTerminalInput: () => () => {}, getEditorText: () => "",
notify: () => {}, custom: (() => new Promise<undefined>(() => {})) as FleetUICtx["custom"],
});
fleet.update(); // shows list, arms the timer
fleet.setEnabled(false); // hides, clears the timer
fleet.setEnabled(true); // re-shows — must re-arm the timer
const before = listAgents.mock.calls.length;
vi.advanceTimersByTime(250); // a tick should fire and re-read the roster
expect(listAgents.mock.calls.length).toBeGreaterThan(before);
fleet.dispose();
} finally {
vi.useRealTimers();
}
});
});
describe("FleetList vs other focused components (#123)", () => {
// pi dispatches terminal input to extension listeners BEFORE the focused
// component (pi-tui TUI.handleInput), and ctx.ui.select/confirm/input swap
// the prompt editor out of the editor container while getEditorText() still
// reads the detached (empty) editor. So while another component owns the
// keyboard — another extension's selector (rpiv-ask-user-question), pi's own
// menus, our /agents settings — the list must not consume its keys.
/** A minimal real Editor — what pi focuses at the prompt (CustomEditor extends it). */
function realEditor(): Editor {
const fakeTui = { requestRender: () => {} };
const theme = { borderColor: (s: string) => s, selectList: {} };
return new Editor(fakeTui as any, theme as any);
}
/** Hand the fleet list its `tui` (happens on first widget render in pi) with the given focus. */
function focusInHarness(h: Harness, focused: unknown): void {
h.widgetTui.focusedComponent = focused;
h.render();
}
it("does not steal ↓ from a focused selector (activation)", () => {
const h = harness([makeRecord()]);
focusInHarness(h, { kind: "selector" }); // e.g. ExtensionSelectorComponent
expect(h.press(DOWN)).toBeUndefined(); // must flow through to the selector
});
it("does not steal navigation keys from a selector opened while the list was active", () => {
const h = harness([makeRecord()]);
focusInHarness(h, realEditor());
expect(h.press(DOWN)).toEqual({ consume: true }); // activate at the prompt
focusInHarness(h, { kind: "selector" }); // a dialog takes focus
expect(h.press(DOWN)).toBeUndefined();
expect(h.press(ENTER)).toBeUndefined();
expect(h.press(ESC)).toBeUndefined();
// and the list dropped back to its inactive hint
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
});
it("still activates when the prompt editor has focus", () => {
const h = harness([makeRecord()]);
focusInHarness(h, realEditor());
expect(h.press(DOWN)).toEqual({ consume: true });
});
it("assumes the editor when focus is unknowable (no tui yet / nothing focused)", () => {
const h = harness([makeRecord()]);
// No render yet → the list has never seen a tui: activation must still work.
expect(h.press(DOWN)).toEqual({ consume: true });
});
});
describe("FleetList rendering", () => {
it("renders main + agent rows with markers, type, description and right-aligned stats", () => {
const h = harness([makeRecord({ description: "Sleep then report 1" })]);
const lines = h.render(120);
// hint + blank + main + one agent
expect(lines[0]).toContain("← for agents");
expect(lines.find(l => l.includes("main"))).toContain("●"); // main selected by default
const agentLine = lines.find(l => l.includes("Sleep then report 1"))!;
expect(agentLine).toContain("○");
expect(agentLine).toContain(getDisplayName("general-purpose"));
expect(agentLine).toContain("↓ 13.1k tokens");
expect(agentLine).toMatch(/\d+s · ↓/); // "<seconds>s · ↓ ..." (timing-agnostic)
});
it("orders agents earliest-launched first (top)", () => {
const agents = [
makeRecord({ id: "new", description: "newest", startedAt: 2000 }),
makeRecord({ id: "old", description: "oldest", startedAt: 1000 }),
];
const lines = harness(agents).render();
const oldIdx = lines.findIndex(l => l.includes("oldest"));
const newIdx = lines.findIndex(l => l.includes("newest"));
expect(oldIdx).toBeGreaterThanOrEqual(0);
expect(oldIdx).toBeLessThan(newIdx); // earliest sits above the later one
});
it("hides agents that have no session yet (pending)", () => {
const agents = [
makeRecord({ id: "live", description: "running one" }),
makeRecord({ id: "pending", description: "queued one", status: "queued", session: undefined }),
];
const lines = harness(agents).render();
expect(lines.some(l => l.includes("running one"))).toBe(true);
expect(lines.some(l => l.includes("queued one"))).toBe(false);
});
it("collapses overflow into a '↓ N more' indicator", () => {
const agents = Array.from({ length: 8 }, (_, i) =>
makeRecord({ id: `a${i}`, description: `report ${i}` }));
const h = harness(agents);
const lines = h.render(120);
// 8 agents, cap 5 visible → "↓ 3 more"
expect(lines.some(l => l.includes("↓ 3 more"))).toBe(true);
});
it("never emits a line wider than the terminal (guards wrap-induced flicker)", () => {
const agents = Array.from({ length: 8 }, (_, i) =>
makeRecord({ id: `a${i}`, description: `a very long agent description number ${i} that keeps going` }));
const h = harness(agents);
for (const w of [4, 8, 12, 20, 40, 80, 200]) {
for (const line of h.render(w)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(w);
}
}
});
it("windows the visible agents so the selection stays on screen", () => {
const agents = Array.from({ length: 8 }, (_, i) =>
makeRecord({ id: `a${i}`, description: `report ${i}` }));
const h = harness(agents);
h.press(DOWN); // activate (main)
// step down to the last agent (8 agents → roster index 8)
for (let i = 0; i < 8; i++) h.press(DOWN);
const lines = h.render(120);
expect(lines.find(l => l.includes("report 7"))).toContain("●");
expect(lines.some(l => l.includes("↑"))).toBe(true); // hidden-above indicator
});
});
describe("FleetList overlay lifecycle", () => {
it("Enter on 'main' just deactivates (no overlay)", () => {
const h = harness([makeRecord()]);
h.press(DOWN); // active, index 0 (main)
h.press(ENTER);
expect(h.overlayOpened()).toBe(false); // never opened an overlay
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
});
it("keeps the cursor on the viewed agent after closing, even if the list reordered", async () => {
const fakeSession = { subscribe: () => () => {}, messages: [] };
const agents = [
makeRecord({ id: "a1", description: "one", session: fakeSession as any }),
makeRecord({ id: "a2", description: "two", session: fakeSession as any }),
makeRecord({ id: "a3", description: "three", session: fakeSession as any }),
];
const h = harness(agents);
h.press(DOWN); // activate (main, idx 0)
h.press(DOWN); // a1 (idx 1)
h.press(DOWN); // a2 (idx 2)
h.press(ENTER); // open a2
// a1 finishes and drops out while viewing → a2 shifts from idx 2 to idx 1.
agents.splice(0, 1);
await h.closeOverlay();
// Selection follows a2 ("two") to its new position, not whatever is at idx 2 now.
expect(h.render().find(l => l.includes("two"))).toContain("●");
expect(h.render().find(l => l.includes("three"))).toContain("○");
});
it("wires the viewer's steer composer to manager.steer with the agent id", () => {
const agents = [makeRecord({ id: "live", description: "the one" })];
const h = harness(agents);
h.press(DOWN); // activate (main)
h.press(DOWN); // → the agent
h.press(ENTER); // open the conversation viewer
const viewer = h.overlayComponent();
expect(viewer).toBeDefined();
viewer!.handleInput("\r"); // Enter → open composer
for (const ch of "go left") viewer!.handleInput(ch);
viewer!.handleInput("\r"); // Enter → send
expect(h.manager.steer).toHaveBeenCalledWith("live", "go left");
});
it("does NOT auto-close when the viewed agent finishes (final output stays readable)", () => {
const agents = [makeRecord({ id: "live", description: "the one" })];
const h = harness(agents);
h.press(DOWN); // active (main)
h.press(DOWN); // → the agent
h.press(ENTER); // opens overlay
expect(h.overlayOpened()).toBe(true);
// The agent finishes, well past the linger window...
agents[0] = makeRecord({ id: "live", description: "the one", status: "completed", completedAt: Date.now() - 60_000 });
h.fleet.onAgentFinished("live");
expect(h.overlayClosed()).toBe(false); // viewer stays open
expect(h.render().some(l => l.includes("the one"))).toBe(true); // and stays listed while viewed
});
it("lingers a finished agent in the list, then drops it after the window", () => {
const recent = makeRecord({ id: "r", description: "recent done", status: "completed", completedAt: Date.now() });
expect(harness([recent]).render().some(l => l.includes("recent done"))).toBe(true);
const old = makeRecord({ id: "o", description: "old done", status: "completed", completedAt: Date.now() - 60_000 });
expect(harness([old]).render().some(l => l.includes("old done"))).toBe(false);
});
});
describe("FleetList cost display", () => {
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
function row(showCost: boolean, cost: number, activity?: Map<string, AgentActivity>): string {
const record = makeRecord({ lifetimeUsage: { input: 13100, output: 0, cacheWrite: 0, cost } });
const fleet = new FleetList(fakeManager([record]), activity ?? new Map(), () => showCost);
let factory: any;
fleet.setUICtx({
setWidget: (_k: string, c: any) => { factory = c; },
onTerminalInput: () => () => {},
getEditorText: () => "",
notify: () => {},
custom: (() => new Promise(() => {})) as any,
} as any);
fleet.update();
return factory({ requestRender: () => {}, terminal: { columns: 120, rows: 40 } }, theme).render(120).join("\n");
}
it("appends the cost after the token count when enabled", () => {
const out = row(true, 0.0042);
expect(out).toContain("13.1k tokens");
expect(out).toContain("~$0.0042");
});
it("shows no cost when disabled, and none for an unpriced model", () => {
expect(row(false, 0.0042)).not.toContain("$");
expect(row(true, 0)).not.toContain("$");
});
it("reads the record, so the figures do not change when the agent finishes", () => {
// Spend used to come from the live activity tracker while an agent ran and
// from its record once the tracker was deleted. The two disagree: only the
// record carries a nested child's spend (nested-tools folds it into every
// ancestor), so the number jumped upward at completion.
// The stale shape on purpose: an activity entry carrying figures of its own
// is what the old fallback preferred, so a row that still renders the
// record's numbers proves the tracker is no longer consulted for spend.
const tracked = new Map<string, AgentActivity>([["a1", {
activeTools: new Map(), toolUses: 0, responseText: "", turnCount: 1,
lifetimeUsage: { input: 1, output: 1, cacheWrite: 0, cost: 0.9 },
} as unknown as AgentActivity]]);
expect(row(true, 0.0042, tracked)).toBe(row(true, 0.0042));
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* fleet-wiring.test.ts — end-to-end wiring of the FleetView through the REAL
* extension (src/index.ts), not the FleetList class in isolation.
*
* The unit tests in fleet-list.test.ts drive FleetList with a fake ui/manager.
* These prove the bits only the extension can: that `tool_execution_start`
* hands the fleet the live UI (so it captures input), that spawning a background
* agent actually registers the `belowEditor` widget once the agent has a session,
* and that `session_shutdown` tears it down. runAgent is mocked (no LLM); the
* manager, settings load, completion routing, and lifecycle handlers are real.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
/** A UI context with the surfaces the widget + fleet touch; setWidget is spied. */
function uiCtx() {
return {
setStatus: vi.fn(),
setWidget: vi.fn(),
notify: vi.fn(),
onTerminalInput: vi.fn(() => vi.fn()),
getEditorText: vi.fn(() => ""),
custom: vi.fn(),
};
}
function ctxWith(ui: ReturnType<typeof uiCtx>) {
return {
hasUI: true,
ui,
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: () => "s1", getBranch: () => [] },
getSystemPrompt: () => "parent",
} as any;
}
const textOf = (r: any): string => r.content[0].text;
const flush = async () => {
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
};
describe("FleetView wiring (real extension lifecycle)", () => {
let tmpDir: string;
let agentDir: string;
let prevCwd: string;
let prevAgentDir: string | undefined;
let prevHome: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-fleet-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-fleet-agentdir-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
prevHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
prevCwd = process.cwd();
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
// async join → completion routes straight to sendIndividualNudge (no batch
// debounce), so fleet.onAgentFinished fires synchronously on the result.
writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false, defaultJoinMode: "async" }));
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(prevCwd);
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(tmpDir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("captures terminal input on tool_execution_start (fleet hooked into the UI)", async () => {
const { pi, lifecycle } = makePi();
subagentsExtension(pi);
const ui = uiCtx();
await lifecycle.get("tool_execution_start")?.({}, ctxWith(ui));
expect(ui.onTerminalInput).toHaveBeenCalled();
});
it("registers the belowEditor widget once a spawned agent has a session, then clears it on shutdown", async () => {
vi.mocked(runAgent).mockResolvedValue({
responseText: "done",
session: { dispose: vi.fn() } as any,
aborted: false,
steered: false,
});
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const ui = uiCtx();
await lifecycle.get("tool_execution_start")?.({}, ctxWith(ui)); // fleet captures THIS ui
const spawn = await tools.get("Agent").execute(
"tc",
{ prompt: "go", description: "live one", subagent_type: "general-purpose", run_in_background: true },
undefined,
undefined,
ctxWith(uiCtx()),
);
expect(textOf(spawn)).toMatch(/Agent ID:/);
await flush(); // completion → fleet.onAgentFinished → update → widget registers
const fleetRegs = ui.setWidget.mock.calls.filter(c => c[0] === "fleet" && typeof c[1] === "function");
expect(fleetRegs.length, "fleet widget should register with a render factory").toBeGreaterThan(0);
await lifecycle.get("session_shutdown")?.({}, ctxWith(uiCtx()));
expect(ui.setWidget).toHaveBeenCalledWith("fleet", undefined); // dispose cleared it
});
});
@@ -0,0 +1,220 @@
/**
* foreground-result-retrieval.test.ts — issue #174, via the REAL Agent tool +
* the REAL get_subagent_result tool.
*
* Report: a FOREGROUND agent that wraps up at max_turns returns its partial
* result inline, but a get_subagent_result for "that agent ID" immediately
* afterwards answers `Agent not found ... It may have been cleaned up.` — with
* no /new, /resume or session switch in between.
*
* Tracing says the premise can't hold, and these tests pin both halves of why:
*
* 1. The record is NOT cleaned up. Foreground completion mutates the record
* in place (agent-manager.ts startAgent's .then) — nothing deletes it. So
* a lookup with the REAL id succeeds.
* 2. The model never HAD the real id. Foreground returns the result text
* only; the id travels in `details`, which is renderer metadata and never
* reaches the API (only `content` is serialized). The background path is
* the one that puts `Agent ID: ...` in the text.
*
* Together: whatever id the reporter's model passed, it wasn't one we issued,
* and "not found" was the correct answer. Test 3 pins the eviction rule that
* DOES apply, so the two are not confused again.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn(),
on: vi.fn(() => vi.fn()),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
function ctx() {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
const textOf = (r: any): string => r.content[0].text;
/**
* Run a FOREGROUND agent that wraps up at the turn limit — the exact #174
* shape. `steered: true` is what agent-manager turns into status "steered",
* which is what produces the reporter's "(wrapped up at the turn limit —
* output may be partial)" note.
*
* Returns the tool result plus the id read out of `details` — the only place
* a foreground id exists, which is the point of test 2.
*/
async function runForegroundSteeredAgent(tools: Map<string, any>) {
vi.mocked(runAgent).mockResolvedValue({
responseText: "THE-RESULT-PAYLOAD",
session: { dispose: vi.fn() } as any,
aborted: false,
steered: true,
});
const res = await tools.get("Agent").execute(
"tc-fg",
{
prompt: "Perform a very thorough read-only codebase exploration.",
description: "Locate organization-scope changes",
subagent_type: "Explore",
max_turns: 20,
run_in_background: false,
},
undefined,
undefined,
ctx(),
);
const id = (res as any).details?.agentId as string | undefined;
expect(id, "foreground spawn should have produced a record id in details").toBeTruthy();
return { res, id: id as string };
}
describe("issue #174: foreground agent that hits max_turns", () => {
let tmpDir: string;
let agentDir: string;
let prevCwd: string;
let prevAgentDir: string | undefined;
let prevHome: string | undefined;
beforeEach(() => {
// Hermetic cwd + global dir, scheduling off — same isolation as
// clear-completed-wiring.test.ts, so session_start doesn't spin a
// scheduler or touch the dev's filesystem.
tmpDir = mkdtempSync(join(tmpdir(), "pi-174-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-174-agentdir-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
prevHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
prevCwd = process.cwd();
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false }));
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(prevCwd);
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(tmpDir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("is NOT cleaned up — get_subagent_result with the real id still resolves it", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const { res, id } = await runForegroundSteeredAgent(tools);
// The inline result is the turn-limit wrap-up the reporter described.
expect(textOf(res)).toContain("wrapped up at the turn limit");
// No /new, no /resume, no session switch — exactly the reporter's sequence.
const read = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx());
const out = textOf(read);
expect(out).not.toContain("Agent not found");
expect(out).toContain("THE-RESULT-PAYLOAD");
await lifecycle.get("session_shutdown")?.({}, ctx());
});
it("never hands the model an agent id — the id lives only in renderer details", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const { res, id } = await runForegroundSteeredAgent(tools);
// `content` is the only thing serialized to the API. If the id isn't here,
// the model cannot have obtained it — any id it passes is invented.
expect(textOf(res)).not.toContain(id);
expect(textOf(res)).not.toMatch(/Agent ID:/);
// An invented id is correctly rejected — this is the reporter's error,
// reproduced WITHOUT any record having been cleaned up.
const bogus = await tools.get("get_subagent_result").execute(
"tc-bogus",
{ agent_id: "3f1320a7-74ec-422" },
undefined,
undefined,
ctx(),
);
expect(textOf(bogus)).toContain("Agent not found");
await lifecycle.get("session_shutdown")?.({}, ctx());
});
it("survives a subagent session's OWN activation lifecycle (adversarial: cross-activation eviction)", async () => {
// The one mechanism that could produce the reported symptom with no
// user-visible session change: a second activation of this extension in the
// same process. Child sessions no longer reach it — activation returns early
// under `inChildSessionContext()` — but any other in-process activation still
// can, and if its session_start / session_shutdown reached the PARENT's
// manager, that activation ending would wipe the parent's records.
const parent = makePi();
subagentsExtension(parent.pi);
await parent.lifecycle.get("session_start")?.({}, ctx());
const { id } = await runForegroundSteeredAgent(parent.tools);
// A child activation runs its full lifecycle, as a subagent session does.
const child = makePi();
subagentsExtension(child.pi);
await child.lifecycle.get("session_start")?.({}, ctx());
await child.lifecycle.get("session_shutdown")?.({}, ctx());
// The parent's record must be untouched — separate manager per activation.
const read = await parent.tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx());
const out = textOf(read);
expect(out).not.toContain("Agent not found");
expect(out).toContain("THE-RESULT-PAYLOAD");
await parent.lifecycle.get("session_shutdown")?.({}, ctx());
});
it("IS evicted by a session switch — its result was already delivered inline", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const { id } = await runForegroundSteeredAgent(tools);
// Foreground results count as consumed the moment they're returned inline,
// so clearCompleted(true)'s #108 preservation deliberately does not cover
// them. This is the ONLY path that makes a foreground id stop resolving.
await lifecycle.get("session_before_switch")?.();
const read = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx());
expect(textOf(read)).toContain("Agent not found");
await lifecycle.get("session_shutdown")?.({}, ctx());
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* group-join.test.ts — Behavior of GroupJoinManager's state machine and timers.
*
* Uses fake timers to assert deterministic timeout behavior without flakiness.
* The class itself is exercised directly with real records — no mocks beyond
* a spy on the delivery callback.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GroupJoinManager } from "../src/group-join.js";
import type { AgentRecord } from "../src/types.js";
function makeRecord(id: string, overrides: Partial<AgentRecord> = {}): AgentRecord {
return {
id,
type: "general-purpose",
description: "test",
status: "completed",
toolUses: 0,
startedAt: 0,
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
...overrides,
};
}
describe("GroupJoinManager", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("returns 'pass' for unregistered agents and never invokes the callback", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver);
expect(mgr.onAgentComplete(makeRecord("a"))).toBe("pass");
expect(deliver).not.toHaveBeenCalled();
expect(mgr.isGrouped("a")).toBe(false);
});
it("holds the first completion and arms the join timeout", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver, 30_000);
mgr.registerGroup("g", ["a", "b"]);
expect(mgr.isGrouped("a")).toBe(true);
expect(mgr.onAgentComplete(makeRecord("a"))).toBe("held");
vi.advanceTimersByTime(29_999);
expect(deliver).not.toHaveBeenCalled();
});
it("delivers all records (partial=false) when the final completion arrives in time", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver);
mgr.registerGroup("g", ["a", "b"]);
mgr.onAgentComplete(makeRecord("a", { result: "A" }));
expect(mgr.onAgentComplete(makeRecord("b", { result: "B" }))).toBe("delivered");
expect(deliver).toHaveBeenCalledTimes(1);
const [records, partial] = deliver.mock.calls[0];
expect(records.map((r: AgentRecord) => r.id).sort()).toEqual(["a", "b"]);
expect(partial).toBe(false);
// Group is cleaned up — no future deliveries can fire from these ids
expect(mgr.isGrouped("a")).toBe(false);
expect(mgr.isGrouped("b")).toBe(false);
});
it("delivers partial=true on timeout and re-arms the group for stragglers", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver, 30_000);
mgr.registerGroup("g", ["a", "b", "c"]);
mgr.onAgentComplete(makeRecord("a"));
vi.advanceTimersByTime(30_000);
expect(deliver).toHaveBeenCalledTimes(1);
const [records, partial] = deliver.mock.calls[0];
expect(records.map((r: AgentRecord) => r.id)).toEqual(["a"]);
expect(partial).toBe(true);
// 'a' was delivered and is dropped from the group; 'b' and 'c' remain as stragglers
expect(mgr.isGrouped("a")).toBe(false);
expect(mgr.isGrouped("b")).toBe(true);
expect(mgr.isGrouped("c")).toBe(true);
});
it("uses the shorter straggler timeout (15s) regardless of the configured group timeout", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver, 30_000);
mgr.registerGroup("g", ["a", "b", "c"]);
// First batch: 'a' alone, partial-delivered after 30s
mgr.onAgentComplete(makeRecord("a"));
vi.advanceTimersByTime(30_000);
expect(deliver).toHaveBeenCalledTimes(1);
// Straggler 'b' arrives — fires at 15s, not 30s
mgr.onAgentComplete(makeRecord("b"));
vi.advanceTimersByTime(14_999);
expect(deliver).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(deliver).toHaveBeenCalledTimes(2);
expect(deliver.mock.calls[1][0].map((r: AgentRecord) => r.id)).toEqual(["b"]);
expect(deliver.mock.calls[1][1]).toBe(true);
expect(mgr.isGrouped("c")).toBe(true); // 'c' is the remaining straggler now
});
it("delivers stragglers as a complete batch (partial=false) when all complete before their timeout", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver, 30_000);
mgr.registerGroup("g", ["a", "b", "c"]);
mgr.onAgentComplete(makeRecord("a"));
vi.advanceTimersByTime(30_000); // partial: 'a'
expect(deliver).toHaveBeenCalledTimes(1);
mgr.onAgentComplete(makeRecord("b"));
expect(mgr.onAgentComplete(makeRecord("c"))).toBe("delivered");
expect(deliver).toHaveBeenCalledTimes(2);
expect(deliver.mock.calls[1][0].map((r: AgentRecord) => r.id).sort()).toEqual(["b", "c"]);
expect(deliver.mock.calls[1][1]).toBe(false);
});
it("returns 'pass' for late completions arriving after a group is already delivered", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver);
mgr.registerGroup("g", ["a", "b"]);
mgr.onAgentComplete(makeRecord("a"));
mgr.onAgentComplete(makeRecord("b")); // full delivery
expect(deliver).toHaveBeenCalledTimes(1);
// A duplicate/late completion must not trigger a second delivery
expect(mgr.onAgentComplete(makeRecord("a"))).toBe("pass");
expect(deliver).toHaveBeenCalledTimes(1);
});
it("dispose() clears pending timers so a partial delivery never fires post-dispose", () => {
const deliver = vi.fn();
const mgr = new GroupJoinManager(deliver, 30_000);
mgr.registerGroup("g", ["a", "b"]);
mgr.onAgentComplete(makeRecord("a")); // arms 30s timeout
mgr.dispose();
vi.advanceTimersByTime(60_000);
expect(deliver).not.toHaveBeenCalled();
expect(mgr.isGrouped("a")).toBe(false);
expect(mgr.isGrouped("b")).toBe(false);
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* boot-extension.ts — the mock `pi` / `ctx` pair the wiring tests use to boot the
* REAL extension (src/index.ts) and drive its registered tools directly.
*
* The 14 pre-existing wiring tests each inline their own near-identical copy of
* this. They are deliberately NOT migrated: `test/` is outside the tsconfig
* `include`, so that churn would be unchecked by `tsc`, and those copies have
* small divergences that would have to be reconciled blind. New files use this.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
export interface BootedPi {
pi: any;
tools: Map<string, any>;
lifecycle: Map<string, any>;
}
/** A mock ExtensionAPI that records every tool and lifecycle handler registered. */
export function makePi(): BootedPi {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn(),
on: vi.fn(() => vi.fn()),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
/** A mock ExtensionContext — the second half of what a tool's `execute` receives. */
export function ctx(overrides: Record<string, unknown> = {}) {
return {
// The interactive mode extensions normally run in. Set explicitly because
// the `@handle` input hook is TUI-only, so an absent mode would make every
// mention test exercise the headless fall-through instead.
mode: "tui",
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn(), addAutocompleteProvider: vi.fn() },
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
...overrides,
} as any;
}
/** Text of a tool result. */
export const textOf = (r: any): string => r.content[0].text;
/** Let queued microtasks run — enough for the manager's internal chaining. */
export const flush = async () => {
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
};
export interface Hermetic {
dir: string;
restore: () => void;
}
/**
* Redirect cwd, `PI_CODING_AGENT_DIR` and `HOME` into a fresh temp dir, so the
* developer's real settings and agent files can't reach the extension under
* test. Call BEFORE instantiating the extension — settings are read at boot.
*/
export function hermeticDir(opts: {
settings?: Record<string, unknown>;
agentFiles?: Record<string, string>;
} = {}): Hermetic {
const dir = mkdtempSync(join(tmpdir(), "pi-boot-"));
const agentDir = mkdtempSync(join(tmpdir(), "pi-boot-agentdir-"));
const prevCwd = process.cwd();
const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
const prevHome = process.env.HOME;
mkdirSync(join(dir, ".pi"), { recursive: true });
if (opts.settings) {
writeFileSync(join(dir, ".pi", "subagents.json"), JSON.stringify(opts.settings));
}
if (opts.agentFiles) {
mkdirSync(join(dir, ".pi", "agents"), { recursive: true });
for (const [name, content] of Object.entries(opts.agentFiles)) {
writeFileSync(join(dir, ".pi", "agents", `${name}.md`), content);
}
}
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
process.chdir(dir);
return {
dir,
restore() {
process.chdir(prevCwd);
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(dir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
},
};
}
@@ -0,0 +1,70 @@
/**
* faux-model-backend.ts — the model/auth plumbing a faux-provider session needs,
* in one place.
*
* `registerFauxProvider` scripts the *responses*, but a session still has to get
* past model lookup and auth before it streams anything, and where that check
* lives moved with Pi 0.80.8:
* - Pi < 0.80.8: `createAgentSession({ modelRegistry })`, auth via
* `getApiKeyAndHeaders()`.
* - Pi >= 0.80.8: `createAgentSession({ modelRuntime })` — the registry option
* is gone entirely — auth via `getAuth()`/`hasConfiguredAuth()`, and the
* turn itself streams through `modelRuntime.streamSimple`.
*
* Passing BOTH spans the supported range: each Pi ignores the option it no
* longer knows. Structural fakes (not real instances) keep the suites hermetic —
* no auth.json, no network, no local login state.
*/
import type { Model } from "@earendil-works/pi-ai";
import { streamSimple } from "./pi-ai.js";
/** Both option shapes for `createAgentSession`, for the given faux model. */
export function fauxModelBackend(model: Model<string>): {
modelRegistry: any;
modelRuntime: any;
} {
return {
modelRegistry: {
find: () => model,
getAll: () => [model],
getAvailable: () => [model],
hasConfiguredAuth: () => true,
isUsingOAuth: () => false,
// createAgentSession's injected streamFn checks `auth.ok` and throws
// Error(auth.error) otherwise — so the `ok: true` flag is mandatory, not
// cosmetic. Without it the turn dies before streaming (empty error message).
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux", headers: {} }),
registerProvider: () => {},
unregisterProvider: () => {},
},
modelRuntime: {
getModel: () => model,
getModels: () => [model],
getProvider: () => undefined,
getProviders: () => [],
getAvailable: async () => [model],
getAvailableSnapshot: () => [model],
getError: () => undefined,
hasConfiguredAuth: () => true,
checkAuth: async () => ({ ok: true }),
isUsingOAuth: () => false,
isUsingSubscription: () => false,
// Shape mirrors ModelRuntime.getAuth: the session reads `auth.apiKey` /
// `auth.headers` and throws "No API key found" when both are absent.
getAuth: async () => ({ auth: { apiKey: "faux", headers: {} } }),
getProviderAuthStatus: () => "configured",
getCompatibilityRequestConfig: () => ({}),
getRegisteredProviderIds: () => [],
getRegisteredProviderConfig: () => undefined,
getRegisteredNativeProvider: () => undefined,
registerProvider: () => {},
registerNativeProvider: () => {},
unregisterProvider: () => {},
refresh: async () => ({}),
// The faux provider registers itself in pi-ai's global api-provider
// registry, so compat's dispatcher reaches it by `model.api`.
stream: streamSimple,
streamSimple,
},
};
}
+7
View File
@@ -0,0 +1,7 @@
/**
* pi-ai.ts — single import point for the two test helpers that pi-ai ≥0.80
* exports only from the `/compat` subpath (all lived on the package root in
* ≤0.75.x). Upstream deletes `/compat` with its coding-agent ModelManager
* migration; the replacement then is `fauxProvider()` + `createModels()`.
*/
export { getModel, registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat";
@@ -0,0 +1,616 @@
/**
* print-mode-runner.ts — a headless ("print mode") host runner for driving the
* pi-subagents extension through REAL end-to-end subagent runs.
*
* WHY THIS EXISTS
* ---------------
* The other e2e suites (agent-runner-e2e, ext-templates-e2e) assert on the
* *gated tool set captured at construction* — they never drive a turn, so they
* never actually spawn a subagent or exercise the background hold condition.
* This runner closes that gap: it boots a real headless pi session with the
* pi-subagents extension loaded, drives a real assistant turn that calls the
* `Agent` tool, and lets the extension spawn a real child session through the
* real `runAgent` path — then waits for it to finish exactly like a production
* print-mode host does.
*
* It is the pi-subagents analogue of pi-chonky-step's `src/agent.ts` headless
* runner: same shape (DefaultResourceLoader → createAgentSession → prompt loop),
* and crucially it replicates pi-chonky-step's SUBAGENT HOLD CONDITION — the
* `dequeueFollowUpMessages` monkey-patch that blocks the parent agent loop until
* background subagents complete (via the `Symbol.for("pi-subagents:manager")`
* global the extension publishes). Without that patch, `session.prompt()`
* resolves and the parent finishes before background children report back.
*
* MODEL BACKEND (faux default, real opt-in)
* -----------------------------------------
* - Faux (default): a scripted `registerFauxProvider` model drives both the
* parent and the spawned child deterministically — no network, CI-safe. You
* supply a `respond(context)` function (or raw `steps`) that emits the
* `Agent` tool call on the parent and a reply on the child. `routeBySession`
* does the parent/child branching for the common single-spawn case.
* - Live (opt-in): set `PI_E2E_LIVE=1` or pass `live: {provider, model}`. A real
* model drives the turn; `respond`/`steps` are ignored. Non-deterministic,
* needs creds. With no explicit model pin, it resolves the model from your
* local `pi` config (settings default → first authed model), so a logged-in
* `pi` is picked up automatically — no PI_PROVIDER/PI_MODEL needed.
*
* ONE PARAMETERIZED RUNNER
* ------------------------
* The same `runPrintMode()` covers built-in agent types, `.pi/agents/*.md` /
* `.agents/agents/*.md` frontmatter agents, and inline-instruction agents — the difference is purely
* what you register in `beforeRun` and which `subagent_type` the `Agent` call
* names. See `test/subagents-print-mode-e2e.test.ts` for usage.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
type AssistantMessage,
type Context,
type FauxContentBlock,
type FauxResponseStep,
fauxAssistantMessage,
fauxText,
fauxToolCall,
type Model,
type ToolCall,
} from "@earendil-works/pi-ai";
import {
type AgentSession,
type AgentSessionEvent,
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { fauxModelBackend } from "./faux-model-backend.js";
import { getModel, registerFauxProvider } from "./pi-ai.js";
/** Path to the pi-subagents extension entrypoint (repo `src/index.ts`). */
const EXTENSION_PATH = fileURLToPath(new URL("../../src/index.ts", import.meta.url));
/** The cross-package handle the extension publishes on a global Symbol. */
const MANAGER_KEY = Symbol.for("pi-subagents:manager");
export interface ManagerHandle {
waitForAll(): Promise<void>;
hasRunning(): boolean;
getRecord(id: string): unknown;
}
/** A faux reply in any convenient shape; normalized to an AssistantMessage. */
export type FauxReply = string | FauxContentBlock | FauxContentBlock[] | AssistantMessage;
/**
* A context-branching responder. Invoked once per model call (parent OR child)
* with that call's own `Context`, so it can decide what to emit from the prompt
* it sees — order-independent, unlike a flat FIFO `steps` list.
*/
export type FauxResponder = (
context: Context,
state: { callCount: number },
) => FauxReply | Promise<FauxReply>;
export interface RunPrintModeOptions {
/** The user prompt that kicks off the parent turn. */
prompt: string;
/**
* Working directory for the run. Defaults to a fresh temp dir that `dispose()`
* removes. Pass a fixtures dir to make project custom agents discoverable.
*/
cwd?: string;
/** Parent host system prompt. Default: a minimal orchestrator prompt. */
systemPrompt?: string;
/**
* Faux mode: context-branching responder (padded to `maxModelCalls` calls).
* Ignored in live mode. Mutually exclusive with `steps` (steps wins).
*/
respond?: FauxResponder;
/** Faux mode: explicit FIFO response steps. Overrides `respond`. */
steps?: FauxResponseStep[];
/** Faux mode: how many model calls to pad the queue for. Default 16. */
maxModelCalls?: number;
/**
* Honor the subagent hold condition — block the parent agent loop until
* background subagents finish (the pi-chonky-step monkey-patch). Default true.
*/
hold?: boolean;
/**
* Run before the parent turn, after globals are isolated — e.g.
* `registerAgents(loadCustomAgents(cwd))` to install frontmatter agents.
*/
beforeRun?: () => void | Promise<void>;
/**
* Isolate global discovery (PI_CODING_AGENT_DIR + HOME → temp) so the dev's
* real agents/extensions can't bleed into the run. Default true in faux mode,
* false in live mode (so real auth/config resolve). Restored on `dispose()`.
*/
isolateGlobals?: boolean;
/** Wall-clock guard for the whole run. Default 30_000ms. */
timeoutMs?: number;
/** Abort the parent (and forwarded children) externally. */
signal?: AbortSignal;
/**
* Force live mode against a specific provider/model (overrides PI_E2E_LIVE).
* When omitted, live mode is on iff `PI_E2E_LIVE` is truthy. In live mode, if
* neither this nor `PI_PROVIDER`+`PI_MODEL` is set, the model is left for pi to
* resolve from your local config (settings default → first authed model) — i.e.
* it picks up whatever your `pi` install is logged into, no env required.
*
* `false` pins the run faux even under `PI_E2E_LIVE=1`. A suite whose whole
* point is a scripted response — a provider error with no content, a
* three-level delegation chain — has nothing to gain from a real model and
* cannot assert anything once one answers instead. Without this, running the
* documented pre-publish smoke turns those suites red on a healthy tree,
* which is worse than not running them: it hides a real regression in noise.
*/
live?: { provider: string; model: string } | false;
}
export interface PrintModeRun {
/** Last assistant text the parent produced (the "printed" answer). */
responseText: string;
/** The live parent session (history, tool calls, etc.). */
parentSession: AgentSession;
/** The extension's manager handle (undefined if the extension didn't load). */
manager: ManagerHandle | undefined;
/** Snapshot of all subagent records the manager knew about at the end. */
subagents: Array<Record<string, unknown>>;
/** Faux model call count (0 in live mode). */
modelCalls: number;
/**
* Tear down: emit session_shutdown (so extensions clear timers), dispose the
* session, unregister faux, restore cwd/env, rm temp dir. Async — await it.
*/
dispose: () => Promise<void>;
}
// --------------------------------------------------------------------------
// Faux scripting helpers
// --------------------------------------------------------------------------
/**
* Build an `Agent` tool call for a faux assistant turn. `subagent_type` defaults
* to "general-purpose"; everything else is passed straight through as tool args.
*/
export function agentCall(
args: {
prompt: string;
description: string;
subagent_type?: string;
run_in_background?: boolean;
[k: string]: unknown;
},
opts?: { id?: string },
): ToolCall {
return fauxToolCall("Agent", { subagent_type: "general-purpose", ...args }, opts);
}
function resolveReply(
reply: FauxReply | ((ctx: Context) => FauxReply),
ctx: Context,
): FauxReply {
return typeof reply === "function" ? (reply as (c: Context) => FauxReply)(ctx) : reply;
}
/**
* The common single-spawn flow as a responder. Routes by inspecting the calling
* session's own context:
* - PARENT (its tool set includes `Agent`):
* · `parentInitial` until an `Agent` tool result is in history (the spawn),
* · then `parentFinal` (the answer after the child reports back).
* - SUBAGENT (no `Agent` tool): `subagent`.
* Each route may be a value or a `(ctx) => value` function.
*/
export function routeBySession(routes: {
parentInitial: FauxReply | ((ctx: Context) => FauxReply);
parentFinal?: FauxReply | ((ctx: Context) => FauxReply);
subagent: FauxReply | ((ctx: Context) => FauxReply);
}): FauxResponder {
return (context) => {
const isParent = (context.tools ?? []).some((t) => t.name === "Agent");
if (!isParent) return resolveReply(routes.subagent, context);
const spawned = context.messages.some(
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
);
if (spawned) {
return routes.parentFinal != null
? resolveReply(routes.parentFinal, context)
: "Done.";
}
return resolveReply(routes.parentInitial, context);
};
}
/** Normalize any FauxReply into a faux AssistantMessage (tool calls ⇒ stopReason "toolUse"). */
function toAssistantMessage(reply: FauxReply): AssistantMessage {
if (reply && typeof reply === "object" && "role" in reply) {
return reply as AssistantMessage;
}
const content: FauxContentBlock[] =
typeof reply === "string" ? [fauxText(reply)] : Array.isArray(reply) ? reply : [reply];
const hasToolCall = content.some((b) => (b as { type?: string }).type === "toolCall");
return fauxAssistantMessage(content, { stopReason: hasToolCall ? "toolUse" : "stop" });
}
// --------------------------------------------------------------------------
// The runner
// --------------------------------------------------------------------------
const DEFAULT_SYSTEM_PROMPT =
"You are a headless orchestrator. Use the Agent tool to delegate, then report the result.";
function isLive(options: RunPrintModeOptions): boolean {
// An explicit `false` wins over the env var — the env var is a blanket switch,
// and a suite that pins itself faux is stating something the switch can't know.
if (options.live === false) return false;
return Boolean(options.live) || /^(1|true|yes)$/i.test(process.env.PI_E2E_LIVE ?? "");
}
export async function runPrintMode(options: RunPrintModeOptions): Promise<PrintModeRun> {
const live = isLive(options);
const isolateGlobals = options.isolateGlobals ?? !live;
const timeoutMs = options.timeoutMs ?? 30_000;
// --- working dir (own it only if we created it) ---
const ownsCwd = options.cwd == null;
const cwd = options.cwd ?? mkdtempSync(join(tmpdir(), "subagents-print-"));
// chdir into cwd: the extension discovers project custom agents from process.cwd()
// (not ctx.cwd), and re-reads them on every Agent invocation — so a custom agent
// is only spawnable if process.cwd() points at the dir holding it. Restored on
// dispose. (Vitest isolates test files per process, so this doesn't race.)
const prevCwd = process.cwd();
process.chdir(cwd);
// --- isolate global discovery so the dev env can't bleed in ---
const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
const prevHome = process.env.HOME;
let hermeticDir: string | undefined;
if (isolateGlobals) {
hermeticDir = mkdtempSync(join(tmpdir(), "subagents-print-home-"));
process.env.PI_CODING_AGENT_DIR = hermeticDir;
process.env.HOME = hermeticDir;
}
// --- model backend ---
let faux: ReturnType<typeof registerFauxProvider> | undefined;
let model: Model<string> | undefined;
let modelRegistry: unknown;
let modelRuntime: unknown;
if (live) {
// Explicit pin wins (options.live or PI_PROVIDER + PI_MODEL). Otherwise leave
// `model` undefined: createAgentSession then calls findInitialModel() against
// the real, auth-backed registry + your local settings default — i.e. it
// picks up whatever your `pi` install is logged into, no env needed.
// `live: false` never reaches here (isLive returned false), but narrow it
// away rather than asserting: the pin is a plain option, not a type-level fact.
const pin = options.live || undefined;
const provider = pin?.provider ?? process.env.PI_PROVIDER;
const modelId = pin?.model ?? process.env.PI_MODEL;
if (provider && modelId) {
// getModel's overloads need the concrete provider literal; cast through.
// Since pi-ai 0.80 it is a static builtin-catalog lookup that returns
// undefined for unknown models — fail fast instead of letting
// createAgentSession silently substitute another model.
model = (getModel as (p: string, m: string) => Model<string> | undefined)(provider, modelId);
if (!model) {
throw new Error(
`runPrintMode (live mode): model "${provider}/${modelId}" not found in the builtin catalog`,
);
}
}
// Let createAgentSession build the real, auth-backed registry/runtime.
modelRegistry = undefined;
modelRuntime = undefined;
} else {
if (!options.steps && !options.respond) {
throw new Error("runPrintMode (faux mode): provide `respond` or `steps`");
}
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
model = faux.getModel();
// Structural faux registry + runtime (see faux-model-backend.ts): the parent
// session uses `model` directly; subagents inherit it via ctx.model since
// resolveDefaultModel falls back to the parent model when no model is pinned.
({ modelRegistry, modelRuntime } = fauxModelBackend(model));
// Pad the response queue: one context-branching responder per expected model
// call. The queue is a single FIFO shared by parent + child, but every entry
// is the same responder that decides from its own context, so interleaving
// order doesn't matter.
if (options.steps) {
faux.setResponses(options.steps);
} else {
const respond = options.respond;
if (!respond) {
throw new Error("runPrintMode (faux mode): provide `respond` or `steps`");
}
const max = options.maxModelCalls ?? 16;
const factory: FauxResponseStep = async (context, _opts, state) =>
toAssistantMessage(await respond(context, state));
faux.setResponses(Array.from({ length: max }, () => factory));
}
}
// --- build the parent host session with the extension loaded ---
// Resolved after globals are isolated, so it honors the hermetic dir.
const agentDir = getAgentDir();
const loader = new DefaultResourceLoader({
cwd,
agentDir,
additionalExtensionPaths: [EXTENSION_PATH],
systemPromptOverride: () => options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
appendSystemPromptOverride: () => [],
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await loader.reload();
// Run any test-supplied registration (e.g. loadCustomAgents) now that globals
// are isolated but before the parent turn spawns anything.
await options.beforeRun?.();
const { session } = await createAgentSession({
cwd,
agentDir,
model,
// Structural faux registry/runtime in faux mode; undefined in live mode (defaults).
modelRegistry: modelRegistry as any,
modelRuntime: modelRuntime as any,
resourceLoader: loader,
sessionManager: SessionManager.inMemory(cwd),
// Live: real settings so an omitted model resolves to your local default
// (settingsManager.getDefaultModel) and retries/compaction match your config.
// Faux: in-memory, deterministic, no disk.
settingsManager: live
? SettingsManager.create(cwd, agentDir)
: SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }),
});
session.setSessionName("print-mode-host");
// Binding fires session_start so the extension initializes and publishes its
// manager on the global Symbol.
await session.bindExtensions({});
const manager = (globalThis as Record<symbol, unknown>)[MANAGER_KEY] as
| ManagerHandle
| undefined;
// --- subagent hold condition (the pi-chonky-step monkey-patch) ---
// Block the parent agent loop while background subagents are still running, so
// their completion nudges land before the parent's final turn.
const hold = options.hold ?? true;
if (hold && manager) {
// dequeueFollowUpMessages is internal — reach through with a cast.
const agent = (session as any).agent;
if (agent?.dequeueFollowUpMessages) {
const original = agent.dequeueFollowUpMessages.bind(agent);
agent.dequeueFollowUpMessages = function patched() {
const messages = original();
if (messages.length > 0) return messages;
if (manager.hasRunning()) {
// Returning a Promise is auto-unwrapped by the async loop config —
// the loop blocks here until all subagents finish and queue nudges.
return manager.waitForAll().then(() => original());
}
return messages;
};
}
}
// --- collect the parent's last assistant text ---
let responseText = "";
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "message_start") responseText = "";
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
responseText += event.assistantMessageEvent.delta;
}
});
// --- forward external abort ---
const onAbort = () => session.abort();
options.signal?.addEventListener("abort", onAbort, { once: true });
const dispose = async () => {
// Emit session_shutdown FIRST so extensions tear down cleanly — in live mode
// the real env loads global extensions (e.g. a status-bar) whose background
// timers would otherwise fire after dispose() invalidates the ctx and surface
// as unhandled "stale ctx" rejections. dispose() itself does the invalidation,
// so shutdown has to happen before it.
try {
await session.extensionRunner?.emit({ type: "session_shutdown", reason: "quit" });
} catch {
/* ignore */
}
try {
session.dispose?.();
} catch {
/* ignore */
}
faux?.unregister();
delete (globalThis as Record<symbol, unknown>)[MANAGER_KEY];
// Restore cwd before removing the temp dir (can't rm the dir you're in).
try {
process.chdir(prevCwd);
} catch {
/* ignore */
}
if (isolateGlobals) {
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
if (hermeticDir) rmSync(hermeticDir, { recursive: true, force: true });
}
if (ownsCwd) rmSync(cwd, { recursive: true, force: true });
};
// --- drive the turn under a wall-clock guard ---
let timer: ReturnType<typeof setTimeout> | undefined;
let failed = false;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
const stillRunning = manager?.hasRunning() ? " (background subagents still running)" : "";
reject(new Error(`print-mode runner timed out after ${timeoutMs}ms${stillRunning}`));
}, timeoutMs);
});
try {
await Promise.race([
(async () => {
await session.prompt(options.prompt);
// Fallback for when the hold patch is unavailable: catch any subagents
// still running after prompt() returns and process their results.
if (hold) {
while (!failed && manager?.hasRunning()) {
await manager.waitForAll();
// prompt() resolves (not rejects) on abort, so after a timeout this
// orphaned race arm keeps running — never re-prompt a torn-down session.
if (failed) break;
await session.prompt("Background agents have completed. Process their results.");
}
}
})(),
timeout,
]);
} catch (err) {
// On timeout (or any turn failure) we throw, so the caller never receives
// the dispose handle — without this, a live session and its background
// subagents would keep streaming after the test already failed. Subagents
// are aborted by dispose()'s session_shutdown emit (the extension's
// shutdown handler calls manager.abortAll()).
failed = true;
try {
session.abort();
} catch {
/* ignore */
}
try {
await dispose();
} catch {
/* ignore — the turn error below is the diagnostic that matters */
}
throw err;
} finally {
clearTimeout(timer);
unsubscribe();
options.signal?.removeEventListener("abort", onAbort);
}
if (!responseText.trim()) {
responseText = lastAssistantText(session);
}
// Snapshot subagent records (manager exposes them via the extension session,
// but the cross-package handle only exposes getRecord — read listAgents off
// the underlying manager if reachable, else fall back to an empty list).
const subagents = snapshotSubagents(manager);
return {
responseText: responseText.trim(),
parentSession: session,
manager,
subagents,
modelCalls: faux?.state.callCount ?? 0,
dispose,
};
}
/**
* Extract the text of every `Agent` tool result in a session's history. This is
* the real end-to-end observable: for a foreground spawn it contains the child's
* own output; for a background spawn it's the "started in background" envelope.
*/
export function agentToolResults(session: AgentSession): string[] {
const out: string[] = [];
for (const msg of session.messages) {
if (msg.role !== "toolResult") continue;
if ((msg as { toolName?: string }).toolName !== "Agent") continue;
const text = (msg.content as Array<{ type?: string; text?: string }>)
.map((b) => (b.type === "text" ? (b.text ?? "") : ""))
.join("");
out.push(text);
}
return out;
}
/**
* All text across the whole conversation — assistant turns, user/nudge messages,
* and every tool result. Use this to assert a child's output *materialized
* somewhere* (a foreground tool result, a get_subagent_result result, a held
* nudge), rather than only in the parent's final message which may summarize it.
*/
export function conversationText(session: AgentSession): string {
const parts: string[] = [];
for (const msg of session.messages) {
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const block of content as Array<{ type?: string; text?: string }>) {
if (block.type === "text" && block.text) parts.push(block.text);
}
}
return parts.join("\n");
}
/** Names of every tool the assistant actually invoked (in order). */
export function invokedToolNames(session: AgentSession): string[] {
const out: string[] = [];
for (const msg of session.messages) {
if (msg.role !== "assistant") continue;
for (const block of msg.content as Array<{ type?: string; name?: string }>) {
if (block.type === "toolCall" && block.name) out.push(block.name);
}
}
return out;
}
/**
* The arguments of every `Agent` tool call the model actually made — lets a live
* smoke assert which feature was exercised (e.g. `run_in_background`,
* `subagent_type`) rather than just that *some* spawn happened.
*/
export function agentToolCalls(session: AgentSession): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];
for (const msg of session.messages) {
if (msg.role !== "assistant") continue;
for (const block of msg.content as Array<{ type?: string; name?: string; arguments?: unknown }>) {
if (block.type === "toolCall" && block.name === "Agent") {
out.push((block.arguments ?? {}) as Record<string, unknown>);
}
}
}
return out;
}
/** Walk session history backward for the last non-empty assistant text. */
function lastAssistantText(session: AgentSession): string {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
const text = msg.content
.map((b) => ((b as { type?: string; text?: string }).type === "text" ? (b as { text?: string }).text ?? "" : ""))
.join("")
.trim();
if (text) return text;
}
return "";
}
/** Best-effort snapshot of the manager's agent records for assertions. */
function snapshotSubagents(manager: ManagerHandle | undefined): Array<Record<string, unknown>> {
if (!manager) return [];
// The published handle is minimal; the real manager (with listAgents) is the
// same object the extension constructed. Try listAgents if present.
const m = manager as unknown as { listAgents?: () => Array<Record<string, unknown>> };
try {
return m.listAgents ? m.listAgents() : [];
} catch {
return [];
}
}
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { resolveAgentInvocationConfig, resolveJoinMode } from "../src/invocation-config.js";
import type { AgentConfig } from "../src/types.js";
function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
return {
name: "Explore",
description: "Explore",
builtinToolNames: ["read"],
extensions: false,
skills: false,
systemPrompt: "Test agent",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
...overrides,
};
}
describe("resolveAgentInvocationConfig", () => {
it("prefers agent config over tool-call params for locked fields", () => {
const resolved = resolveAgentInvocationConfig(
makeConfig({
model: "provider/config-model",
thinking: "high",
maxTurns: 42,
inheritContext: false,
runInBackground: false,
isolated: false,
isolation: "worktree",
}),
{
model: "provider/param-model",
thinking: "minimal",
max_turns: 1,
inherit_context: true,
run_in_background: true,
isolated: true,
isolation: "worktree",
},
);
expect(resolved.modelInput).toBe("provider/config-model");
expect(resolved.modelFromParams).toBe(false);
expect(resolved.thinking).toBe("high");
expect(resolved.maxTurns).toBe(42);
expect(resolved.inheritContext).toBe(false);
expect(resolved.runInBackground).toBe(false);
expect(resolved.isolated).toBe(false);
expect(resolved.isolation).toBe("worktree");
});
it("uses tool-call params when no agent config is available", () => {
const resolved = resolveAgentInvocationConfig(undefined, {
model: "provider/param-model",
thinking: "minimal",
max_turns: 3,
inherit_context: true,
run_in_background: true,
isolated: true,
isolation: "worktree",
});
expect(resolved.modelInput).toBe("provider/param-model");
expect(resolved.modelFromParams).toBe(true);
expect(resolved.thinking).toBe("minimal");
expect(resolved.maxTurns).toBe(3);
expect(resolved.inheritContext).toBe(true);
expect(resolved.runInBackground).toBe(true);
expect(resolved.isolated).toBe(true);
expect(resolved.isolation).toBe("worktree");
});
it("lets parent fill in booleans when config leaves them undefined", () => {
const resolved = resolveAgentInvocationConfig(
makeConfig({
inheritContext: undefined,
runInBackground: undefined,
isolated: undefined,
}),
{
inherit_context: true,
run_in_background: true,
isolated: true,
},
);
expect(resolved.inheritContext).toBe(true);
expect(resolved.runInBackground).toBe(true);
expect(resolved.isolated).toBe(true);
});
it("defaults booleans to false when neither config nor params set them", () => {
const resolved = resolveAgentInvocationConfig(
makeConfig({
inheritContext: undefined,
runInBackground: undefined,
isolated: undefined,
}),
{},
);
expect(resolved.inheritContext).toBe(false);
expect(resolved.runInBackground).toBe(false);
expect(resolved.isolated).toBe(false);
});
// "off" exists so a model that cannot bring itself to omit an optional field
// has a legal way to say no (#231). It is an input spelling only — the
// resolver collapses it to undefined so no consumer downstream grows a branch.
it('collapses a param isolation of "off" to undefined', () => {
const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: undefined }), { isolation: "off" });
expect(resolved.isolation).toBeUndefined();
});
// Agent config outranks tool-call params, so "off" in frontmatter is the only
// way to veto a caller's worktree — before #231 no value could do this.
it('lets a config isolation of "off" veto a param "worktree"', () => {
const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: "off" }), { isolation: "worktree" });
expect(resolved.isolation).toBeUndefined();
});
it('still honours a param "worktree" when the config leaves isolation unset', () => {
const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: undefined }), { isolation: "worktree" });
expect(resolved.isolation).toBe("worktree");
});
it("drops worktree isolation when the project disallows it", () => {
const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: "worktree" }), { isolation: "worktree" }, { worktreeAllowed: false });
expect(resolved.isolation).toBeUndefined();
});
it("keeps worktree isolation when the project allows it", () => {
const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: "worktree" }), {}, { worktreeAllowed: true });
expect(resolved.isolation).toBe("worktree");
});
});
describe("resolveJoinMode", () => {
it("returns the global default for background agents", () => {
expect(resolveJoinMode("smart", true)).toBe("smart");
expect(resolveJoinMode("async", true)).toBe("async");
});
it("ignores join mode for foreground agents", () => {
expect(resolveJoinMode("smart", false)).toBeUndefined();
expect(resolveJoinMode("group", false)).toBeUndefined();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isolationParam } from "../src/invocation-config.js";
/**
* The `isolation` parameter's *shape* is the fix for #231, so it is worth
* asserting directly rather than only through behaviour.
*
* As a single-value optional literal it gave models that fill every optional
* parameter nothing harmless to fill it with: the session log on #231 shows one
* emitting `isolation: "worktree"` on three consecutive calls — alongside
* `resume: ""` and `schedule: ""` — while its own reasoning, its message to the
* user, and two explicit user instructions all said to omit the field. A second
* legal value is what lets it comply.
*/
describe("isolationParam", () => {
function schema(enabled: boolean) {
const built = isolationParam(enabled);
return built.isolation as { anyOf?: { const?: string }[] } | undefined;
}
it("offers a value meaning 'no isolation', not just 'worktree'", () => {
const values = schema(true)?.anyOf?.map(v => v.const);
expect(values).toContain("off");
expect(values).toContain("worktree");
});
// A model that fills optional fields tends to reach for what it reads first,
// so the inert value leads. This is the whole mitigation — if the order flips,
// the schema stops steering and the bug is back in practice.
it("lists the inert value first", () => {
expect(schema(true)?.anyOf?.[0]?.const).toBe("off");
});
it("warns that a worktree cannot see uncommitted work", () => {
// The specific trap in #231: the subagent reviewed an empty `git diff
// --cached` in a fresh copy and returned nothing, three times.
const described = JSON.stringify(schema(true));
expect(described).toMatch(/uncommitted or staged/);
});
it("omits the parameter entirely when the project disabled worktrees", () => {
// Nothing to pass beats accepting it and quietly downgrading — and it costs
// the model no context in disabled mode, as `scheduleParam` already does.
expect(isolationParam(false)).toEqual({});
});
});
@@ -0,0 +1,164 @@
/**
* manager-registry-guard.test.ts — the Symbol.for("pi-subagents:manager")
* global registry across multiple activations in one process.
*
* Subagent sessions re-activate this extension in the same process
* (session.bindExtensions in agent-runner.ts). The old code let every
* activation overwrite the global slot — pointing cross-package consumers at
* a short-lived child manager — and every child's session_shutdown DELETED
* the slot, so the root session's entry was lost as soon as any subagent ran.
*
* The fix: the first activation claims the slot, later activations leave it
* alone, and only the owner's shutdown releases it.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
const MANAGER_KEY = Symbol.for("pi-subagents:manager");
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn(),
on: vi.fn(() => vi.fn()),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
function ctx() {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
const textOf = (r: any): string => r.content[0].text;
async function spawnBackground(tools: Map<string, any>): Promise<string> {
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); // never resolves
const r = await tools.get("Agent").execute(
"tc-spawn",
{ prompt: "go", description: "registry test agent", subagent_type: "general-purpose", run_in_background: true },
undefined,
undefined,
ctx(),
);
return /Agent ID: (\S+)/.exec(textOf(r))![1];
}
// Restore the global slot around every test.
const priorGlobal = (globalThis as any)[MANAGER_KEY];
afterEach(() => {
if (priorGlobal === undefined) delete (globalThis as any)[MANAGER_KEY];
else (globalThis as any)[MANAGER_KEY] = priorGlobal;
vi.mocked(runAgent).mockReset();
});
describe("Symbol.for manager registry across activations", () => {
it("child activation does not overwrite the root entry; child shutdown does not delete it", async () => {
delete (globalThis as any)[MANAGER_KEY];
// Root session activates first and owns the registry.
const root = makePi();
subagentsExtension(root.pi);
const rootEntry = (globalThis as any)[MANAGER_KEY];
expect(rootEntry).toBeDefined();
// Spawn a background agent through the ROOT so its record is findable.
const id = await spawnBackground(root.tools);
expect(rootEntry.getRecord(id)).toBeDefined();
// A child agent session re-activates the extension in-process.
const child = makePi();
subagentsExtension(child.pi);
// Registry still points at the root's entry (child did not clobber it) …
expect((globalThis as any)[MANAGER_KEY]).toBe(rootEntry);
expect((globalThis as any)[MANAGER_KEY].getRecord(id)).toBeDefined();
// … and the child's shutdown does not delete the root's entry.
await child.lifecycle.get("session_shutdown")?.();
expect((globalThis as any)[MANAGER_KEY]).toBe(rootEntry);
// The root's own shutdown releases the slot.
await root.lifecycle.get("session_shutdown")?.();
expect((globalThis as any)[MANAGER_KEY]).toBeUndefined();
});
});
// The registry's `spawn` is reachable from any package in the process, so its
// options are attacker-controlled in the only sense that matters here: nothing
// downstream re-checks them. Four are internal capabilities the extension
// issues to itself, and a forged value for each buys something real.
describe("the registry spawn strips internal capabilities", () => {
/** Boot a fresh owner and spawn through the registry with forged options. */
function forge(options: Record<string, unknown>) {
delete (globalThis as any)[MANAGER_KEY];
const root = makePi();
subagentsExtension(root.pi);
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any);
const entry = (globalThis as any)[MANAGER_KEY];
const id = entry.spawn(root.pi, ctx(), "general-purpose", "go", {
description: "forged", isBackground: true, ...options,
});
return { entry, id, root, runOpts: () => vi.mocked(runAgent).mock.calls[0][3] as any };
}
it("refuses a forged nesting, so the agent cannot hide under someone else's id", async () => {
// A nested record is filtered out of every top-level surface and inherits
// its parent's delegation budget.
const { entry, id, root } = forge({ parentAgentId: "victim-agent-id", depth: 9, maxSubagentDepth: 99 });
expect(entry.getRecord(id)).toMatchObject({
parentAgentId: undefined, depth: 1, maxSubagentDepth: undefined,
});
await root.lifecycle.get("session_shutdown")?.();
});
it("refuses a forged transcript directory and config root", async () => {
// rootSessionId names a directory the transcript is written into, and
// configCwd names where agent files and memory are resolved from.
const { entry, id, root, runOpts } = forge({ rootSessionId: "../../elsewhere", configCwd: "/etc" });
expect(entry.getRecord(id).rootSessionId).toBeUndefined();
expect(runOpts().configCwd).toBeUndefined();
await root.lifecycle.get("session_shutdown")?.();
});
it("refuses a forged session file, which would replay someone else's conversation", async () => {
const { root, runOpts } = forge({ resumeSessionFile: "/home/victim/.pi/agent/sessions/private.jsonl" });
expect(runOpts().resumeSessionFile).toBeUndefined();
await root.lifecycle.get("session_shutdown")?.();
});
it("refuses a forged reclaim and allocates a handle the ordinary way", async () => {
// reclaim bypasses assignHandle, so a forged value could duplicate a live
// agent's name and make `@handle` resolve to either of two records.
const { entry, id, root } = forge({ reclaim: { handle: "explore", alias: "auth-audit" } });
expect(entry.getRecord(id)).toMatchObject({ handle: "general-purpose", alias: undefined });
await root.lifecycle.get("session_shutdown")?.();
});
});
@@ -0,0 +1,55 @@
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
assertMandatoryExtensionsLoaded,
extensionPathMatches,
normalizeMandatoryExtensionPaths,
} from "../src/mandatory-extensions.js";
function fixturePath(name = "permission-system.ts"): string {
const dir = mkdtempSync(join(tmpdir(), "pi-subagents-mandatory-"));
const path = join(dir, name);
writeFileSync(path, "export default () => {};\n");
return path;
}
describe("mandatory extension paths", () => {
it("requires absolute existing extension entries and canonicalizes duplicates", () => {
const path = fixturePath();
expect(normalizeMandatoryExtensionPaths([path, path])).toEqual([
realpathSync.native(path),
]);
expect(() => normalizeMandatoryExtensionPaths(["relative.ts"]))
.toThrow("must be absolute");
expect(() => normalizeMandatoryExtensionPaths([join(tmpdir(), "missing-extension.ts")]))
.toThrow("is unavailable");
});
it("accepts extension directories", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-subagents-mandatory-dir-"));
const extensionDir = join(dir, "permission-system");
mkdirSync(extensionDir);
expect(normalizeMandatoryExtensionPaths([extensionDir])).toEqual([
realpathSync.native(extensionDir),
]);
});
it("matches the exact canonical entry rather than a same-named impostor", () => {
const trusted = fixturePath();
const impostor = fixturePath();
const [canonical] = normalizeMandatoryExtensionPaths([trusted]);
expect(extensionPathMatches(trusted, canonical)).toBe(true);
expect(extensionPathMatches(impostor, canonical)).toBe(false);
});
it("fails closed when the exact mandatory entry did not survive reload", () => {
const trusted = fixturePath();
const impostor = fixturePath();
const mandatory = normalizeMandatoryExtensionPaths([trusted]);
expect(() => assertMandatoryExtensionsLoaded([impostor], mandatory))
.toThrow(`Mandatory extension failed to load: ${mandatory[0]}`);
expect(() => assertMandatoryExtensionsLoaded([trusted], mandatory)).not.toThrow();
});
});
@@ -0,0 +1,81 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock homedir so the legacy ~/.pi/agent-memory fallback can be exercised
// against a temp directory instead of the real home. The default return must
// be a valid string: pi-coding-agent evaluates getAgentDir() at module load
// (before beforeEach runs), so an undefined homedir would throw at import.
const mockHomedir = vi.hoisted(() => vi.fn(() => "/tmp"));
vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return { ...actual, homedir: mockHomedir };
});
import { resolveMemoryDir } from "../src/memory.js";
describe("resolveMemoryDir user-scope legacy fallback", () => {
let tmpDir: string;
let fakeHome: string;
let agentDir: string;
let originalEnv: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-mem-legacy-test-"));
fakeHome = join(tmpDir, "home");
agentDir = join(tmpDir, "agent-dir");
mkdirSync(fakeHome, { recursive: true });
mockHomedir.mockReturnValue(fakeHome);
originalEnv = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
});
afterEach(() => {
if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalEnv;
rmSync(tmpDir, { recursive: true, force: true });
});
it("uses the agent dir location when no legacy memory exists", () => {
const dir = resolveMemoryDir("chronicler", "user", "/workspace");
expect(dir).toBe(join(agentDir, "agent-memory", "chronicler"));
});
it("falls back to legacy ~/.pi/agent-memory when it exists and the new location doesn't", () => {
const legacy = join(fakeHome, ".pi", "agent-memory", "chronicler");
mkdirSync(legacy, { recursive: true });
const dir = resolveMemoryDir("chronicler", "user", "/workspace");
expect(dir).toBe(legacy);
});
it("prefers the new location once it exists, even if legacy also exists", () => {
const legacy = join(fakeHome, ".pi", "agent-memory", "chronicler");
const current = join(agentDir, "agent-memory", "chronicler");
mkdirSync(legacy, { recursive: true });
mkdirSync(current, { recursive: true });
const dir = resolveMemoryDir("chronicler", "user", "/workspace");
expect(dir).toBe(current);
});
it("ignores a symlinked legacy directory", () => {
const target = join(tmpDir, "elsewhere");
mkdirSync(target, { recursive: true });
mkdirSync(join(fakeHome, ".pi", "agent-memory"), { recursive: true });
symlinkSync(target, join(fakeHome, ".pi", "agent-memory", "chronicler"));
const dir = resolveMemoryDir("chronicler", "user", "/workspace");
expect(dir).toBe(join(agentDir, "agent-memory", "chronicler"));
});
it("scopes the fallback per agent name", () => {
const legacyOther = join(fakeHome, ".pi", "agent-memory", "other-agent");
mkdirSync(legacyOther, { recursive: true });
// "other-agent" has legacy memory; "chronicler" doesn't — only the former falls back.
expect(resolveMemoryDir("other-agent", "user", "/workspace")).toBe(legacyOther);
expect(resolveMemoryDir("chronicler", "user", "/workspace")).toBe(join(agentDir, "agent-memory", "chronicler"));
});
});
+343
View File
@@ -0,0 +1,343 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock homedir so the user-scope legacy fallback check (~/.pi/agent-memory)
// resolves against a controlled temp home rather than the developer's real
// ~/.pi state. The default return must be a valid string: pi-coding-agent
// evaluates getAgentDir() at module load, so an undefined homedir throws at import.
const mockHomedir = vi.hoisted(() => vi.fn(() => "/tmp"));
vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return { ...actual, homedir: mockHomedir };
});
import { buildMemoryBlock, buildReadOnlyMemoryBlock, ensureMemoryDir, isSymlink, isUnsafeName, readMemoryIndex, resolveMemoryDir, safeReadFile } from "../src/memory.js";
describe("memory", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-mem-test-"));
// Point homedir at a clean temp home with no legacy agent-memory dirs, so
// user-scope resolution deterministically returns the agent-dir location.
const fakeHome = join(tmpDir, "home");
mkdirSync(fakeHome, { recursive: true });
mockHomedir.mockReturnValue(fakeHome);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("resolveMemoryDir", () => {
it("resolves project scope to .pi/agent-memory/<name>", () => {
const dir = resolveMemoryDir("auditor", "project", "/workspace");
expect(dir).toBe("/workspace/.pi/agent-memory/auditor");
});
it("resolves local scope to .pi/agent-memory-local/<name>", () => {
const dir = resolveMemoryDir("auditor", "local", "/workspace");
expect(dir).toBe("/workspace/.pi/agent-memory-local/auditor");
});
it("resolves user scope under the agent dir (honors PI_CODING_AGENT_DIR)", () => {
const originalEnv = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = join(tmpDir, "custom-agent-dir");
try {
const dir = resolveMemoryDir("auditor", "user", "/workspace");
expect(dir).toBe(join(tmpDir, "custom-agent-dir", "agent-memory", "auditor"));
expect(dir).not.toContain("/workspace");
} finally {
if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalEnv;
}
});
it("throws on names with path traversal (..)", () => {
expect(() => resolveMemoryDir("../../etc/evil", "project", "/workspace")).toThrow("Unsafe agent name");
});
it("throws on names with forward slash", () => {
expect(() => resolveMemoryDir("foo/bar", "project", "/workspace")).toThrow("Unsafe agent name");
});
it("throws on names with backslash", () => {
expect(() => resolveMemoryDir("foo\\bar", "project", "/workspace")).toThrow("Unsafe agent name");
});
it("throws on names with null byte", () => {
expect(() => resolveMemoryDir("foo\0bar", "project", "/workspace")).toThrow("Unsafe agent name");
});
it("throws on empty name", () => {
expect(() => resolveMemoryDir("", "project", "/workspace")).toThrow("Unsafe agent name");
});
it("throws on names starting with dot", () => {
expect(() => resolveMemoryDir(".hidden", "project", "/workspace")).toThrow("Unsafe agent name");
});
it("throws on names with spaces", () => {
expect(() => resolveMemoryDir("foo bar", "project", "/workspace")).toThrow("Unsafe agent name");
});
it("allows hyphens, underscores, and dots in names", () => {
expect(() => resolveMemoryDir("my-agent_v2.1", "project", "/workspace")).not.toThrow();
});
});
describe("isUnsafeName (whitelist validation)", () => {
it("rejects empty string", () => {
expect(isUnsafeName("")).toBe(true);
});
it("rejects names longer than 128 chars", () => {
expect(isUnsafeName("a".repeat(129))).toBe(true);
});
it("rejects path traversal", () => {
expect(isUnsafeName("../../etc")).toBe(true);
});
it("rejects names starting with dot", () => {
expect(isUnsafeName(".hidden")).toBe(true);
});
it("rejects names with spaces", () => {
expect(isUnsafeName("foo bar")).toBe(true);
});
it("rejects names with special characters", () => {
expect(isUnsafeName("foo;bar")).toBe(true);
expect(isUnsafeName("foo|bar")).toBe(true);
expect(isUnsafeName("foo`bar")).toBe(true);
});
it("allows valid names", () => {
expect(isUnsafeName("my-agent")).toBe(false);
expect(isUnsafeName("agent_v2")).toBe(false);
expect(isUnsafeName("Agent123")).toBe(false);
expect(isUnsafeName("my-agent.v2")).toBe(false);
});
});
describe("ensureMemoryDir", () => {
it("creates directory if it doesn't exist", () => {
const dir = join(tmpDir, "agent-memory", "test");
expect(existsSync(dir)).toBe(false);
ensureMemoryDir(dir);
expect(existsSync(dir)).toBe(true);
});
it("no-ops if directory already exists", () => {
const dir = join(tmpDir, "agent-memory", "test");
mkdirSync(dir, { recursive: true });
ensureMemoryDir(dir); // should not throw
expect(existsSync(dir)).toBe(true);
});
it("throws on symlinked directory", () => {
const realDir = join(tmpDir, "real-dir");
const linkDir = join(tmpDir, "symlink-dir");
mkdirSync(realDir, { recursive: true });
symlinkSync(realDir, linkDir);
expect(() => ensureMemoryDir(linkDir)).toThrow("symlinked memory directory");
});
});
describe("isSymlink", () => {
it("returns false for regular file", () => {
const file = join(tmpDir, "regular.txt");
writeFileSync(file, "content");
expect(isSymlink(file)).toBe(false);
});
it("returns true for symlink", () => {
const file = join(tmpDir, "real.txt");
const link = join(tmpDir, "link.txt");
writeFileSync(file, "content");
symlinkSync(file, link);
expect(isSymlink(link)).toBe(true);
});
it("returns false for nonexistent path", () => {
expect(isSymlink(join(tmpDir, "nope"))).toBe(false);
});
});
describe("safeReadFile", () => {
it("reads regular files", () => {
const file = join(tmpDir, "regular.txt");
writeFileSync(file, "hello");
expect(safeReadFile(file)).toBe("hello");
});
it("rejects symlinked files", () => {
const file = join(tmpDir, "real.txt");
const link = join(tmpDir, "link.txt");
writeFileSync(file, "secret");
symlinkSync(file, link);
expect(safeReadFile(link)).toBeUndefined();
});
it("returns undefined for nonexistent files", () => {
expect(safeReadFile(join(tmpDir, "nope.txt"))).toBeUndefined();
});
});
describe("readMemoryIndex", () => {
it("returns undefined when MEMORY.md doesn't exist", () => {
const result = readMemoryIndex(tmpDir);
expect(result).toBeUndefined();
});
it("reads MEMORY.md content", () => {
writeFileSync(join(tmpDir, "MEMORY.md"), "# Memories\n- Item 1\n- Item 2");
const result = readMemoryIndex(tmpDir);
expect(result).toBe("# Memories\n- Item 1\n- Item 2");
});
it("rejects symlinked memory directory", () => {
const realDir = join(tmpDir, "real-mem");
const linkDir = join(tmpDir, "link-mem");
mkdirSync(realDir, { recursive: true });
writeFileSync(join(realDir, "MEMORY.md"), "# Secret");
symlinkSync(realDir, linkDir);
expect(readMemoryIndex(linkDir)).toBeUndefined();
});
it("rejects symlinked MEMORY.md file", () => {
const realFile = join(tmpDir, "secret.md");
writeFileSync(realFile, "# Secret");
const memDir = join(tmpDir, "mem-dir");
mkdirSync(memDir);
symlinkSync(realFile, join(memDir, "MEMORY.md"));
expect(readMemoryIndex(memDir)).toBeUndefined();
});
it("truncates content beyond 200 lines", () => {
const lines = Array.from({ length: 250 }, (_, i) => `Line ${i + 1}`);
writeFileSync(join(tmpDir, "MEMORY.md"), lines.join("\n"));
const result = readMemoryIndex(tmpDir)!;
expect(result).toContain("Line 200");
expect(result).not.toContain("Line 201");
expect(result).toContain("truncated at 200 lines");
});
});
describe("buildMemoryBlock", () => {
it("builds memory block with no existing MEMORY.md", () => {
const block = buildMemoryBlock("test-agent", "project", tmpDir);
expect(block).toContain("Agent Memory");
expect(block).toContain("agent-memory/test-agent");
expect(block).toContain("No MEMORY.md exists yet");
expect(block).toContain("Memory Instructions");
});
it("builds memory block with existing MEMORY.md", () => {
const memDir = join(tmpDir, ".pi", "agent-memory", "test-agent");
mkdirSync(memDir, { recursive: true });
writeFileSync(join(memDir, "MEMORY.md"), "# Existing\n- recall this");
const block = buildMemoryBlock("test-agent", "project", tmpDir);
expect(block).toContain("Existing");
expect(block).toContain("recall this");
expect(block).not.toContain("No MEMORY.md exists yet");
});
it("creates memory directory if it doesn't exist", () => {
const memDir = join(tmpDir, ".pi", "agent-memory", "new-agent");
expect(existsSync(memDir)).toBe(false);
buildMemoryBlock("new-agent", "project", tmpDir);
expect(existsSync(memDir)).toBe(true);
});
it("includes Read/Write/Edit instructions", () => {
const block = buildMemoryBlock("test-agent", "project", tmpDir);
expect(block).toContain("Read, Write, and Edit tools");
});
it("uses correct directory for local scope", () => {
const block = buildMemoryBlock("test-agent", "local", tmpDir);
expect(block).toContain("agent-memory-local/test-agent");
});
it("uses correct directory for user scope", () => {
// Pin the agent dir to a temp location so the test doesn't create
// directories in the real home / agent dir (buildMemoryBlock mkdirs).
const originalEnv = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = join(tmpDir, "agent-dir");
try {
const block = buildMemoryBlock("test-agent", "user", join(tmpDir, "workspace"));
expect(block).toContain(join(tmpDir, "agent-dir", "agent-memory", "test-agent"));
expect(block).not.toContain(join(tmpDir, "workspace"));
} finally {
if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalEnv;
}
});
it("includes scope label in header", () => {
expect(buildMemoryBlock("a", "project", tmpDir)).toContain("Memory scope: project");
expect(buildMemoryBlock("a", "local", tmpDir)).toContain("Memory scope: local");
expect(buildMemoryBlock("a", "user", tmpDir)).toContain("Memory scope: user");
});
});
describe("buildReadOnlyMemoryBlock", () => {
it("returns read-only instructions without write/edit mention", () => {
const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir);
expect(block).toContain("read-only");
expect(block).not.toContain("Write");
expect(block).not.toContain("Edit");
expect(block).not.toContain("Memory Instructions");
});
it("does NOT create the memory directory", () => {
const memDir = join(tmpDir, ".pi", "agent-memory", "ro-agent");
expect(existsSync(memDir)).toBe(false);
buildReadOnlyMemoryBlock("ro-agent", "project", tmpDir);
expect(existsSync(memDir)).toBe(false);
});
it("includes existing MEMORY.md content", () => {
const memDir = join(tmpDir, ".pi", "agent-memory", "test-agent");
mkdirSync(memDir, { recursive: true });
writeFileSync(join(memDir, "MEMORY.md"), "# Existing\n- recall this");
const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir);
expect(block).toContain("Existing");
expect(block).toContain("recall this");
});
it("returns 'no memory available' when no MEMORY.md exists", () => {
const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir);
expect(block).toContain("No memory is available yet");
expect(block).not.toContain("Create one");
});
it("includes scope label in header", () => {
expect(buildReadOnlyMemoryBlock("a", "project", tmpDir)).toContain("Memory scope: project");
expect(buildReadOnlyMemoryBlock("a", "local", tmpDir)).toContain("Memory scope: local");
expect(buildReadOnlyMemoryBlock("a", "user", tmpDir)).toContain("Memory scope: user");
});
it("does not mention memory directory path for write access", () => {
const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir);
expect(block).not.toContain("persistent memory directory at:");
expect(block).not.toContain("Create one at");
});
it("rejects symlinked memory directory in read-only mode", () => {
const realDir = join(tmpDir, ".pi", "agent-memory", "test-agent");
mkdirSync(realDir, { recursive: true });
writeFileSync(join(realDir, "MEMORY.md"), "# Secret");
const linkDir = join(tmpDir, ".pi", "agent-memory", "linked-agent");
mkdirSync(join(tmpDir, ".pi", "agent-memory"), { recursive: true });
symlinkSync(realDir, linkDir);
// Should not read through the symlink
const block = buildReadOnlyMemoryBlock("linked-agent", "project", tmpDir);
expect(block).toContain("No memory is available yet");
});
});
});
+401
View File
@@ -0,0 +1,401 @@
/**
* mention-clone.test.ts — the off-screen conversation clone that starts a
* mentioned agent.
*
* Everything here guards one property: the clone is a throwaway, but the agent
* it starts must be indistinguishable from one the main model launched. That
* breaks in quiet ways — a spawn attributed to the fork's session id files its
* transcript in the wrong place, a tool-call id the real session never issued
* puts a dangling `<tool-use-id>` in the completion notification. Neither
* surfaces as an error, so each is pinned below.
*
* The other half is the fallback contract: `runMentionClone` never rejects,
* because the caller starts the agent directly on `spawned: false` and a
* rejection would instead lose the mention entirely.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
// Hoisted: vi.mock's factory is lifted above the imports, so it cannot close
// over ordinary top-level consts.
const { buildSessionContext, createAgentSession, inMemory } = vi.hoisted(() => ({
buildSessionContext: vi.fn(),
createAgentSession: vi.fn(),
inMemory: vi.fn(),
}));
vi.mock("@earendil-works/pi-coding-agent", async () => {
const actual = await vi.importActual<any>("@earendil-works/pi-coding-agent");
return {
...actual,
buildSessionContext,
createAgentSession,
SessionManager: { ...actual.SessionManager, inMemory },
};
});
import { agentMentionReminder } from "../src/mention.js";
import { runMentionClone } from "../src/mention-clone.js";
/** One user turn and its reply, as buildSessionContext resolves them. */
const CONVERSATION = [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
] as any[];
beforeEach(() => {
createAgentSession.mockReset();
inMemory.mockReset();
inMemory.mockReturnValue({ kind: "in-memory-session-manager" } as any);
buildSessionContext.mockReset();
buildSessionContext.mockReturnValue({ messages: CONVERSATION, thinkingLevel: "high", model: null } as any);
});
/** The main session's context — the one the spawn must be attributed to. */
function mainCtx(overrides: Record<string, unknown> = {}) {
return {
cwd: "/repo",
model: { id: "main-model" },
thinkingLevel: "high",
modelRegistry: { runtime: { kind: "runtime" } },
getSystemPrompt: vi.fn(() => "the live system prompt"),
sessionManager: {
getEntries: vi.fn(() => [{ type: "message" }] as any[]),
getLeafId: vi.fn(() => "leaf-1"),
},
...overrides,
} as any;
}
/** The registered Agent tool, whose handler the clone is supposed to reuse. */
function agentTool() {
return {
name: "Agent",
execute: vi.fn(async () => ({ content: [{ type: "text", text: "Agent ID: a1" }], details: {} })),
} as any;
}
/**
* Pi's own tool-visibility rule, reproduced from `sdk.js` + `agent-session.js`:
* an allowlist is derived once (`tools`, or the empty list when `noTools:
* "all"`), and EVERY tool — built-in, extension and custom alike — is dropped
* from the registry unless the allowlist names it. So `noTools: "all"` does not
* mean "no built-ins, keep my custom tool": it means the clone is handed
* nothing, answers in prose, and the mention falls back to a direct start.
*/
function visibleTools(opts: any): any[] {
const allowed = opts.tools ?? (opts.noTools === "all" ? [] : undefined);
const allowedSet = allowed ? new Set<string>(allowed) : undefined;
const excluded = new Set<string>(opts.excludeTools ?? []);
return (opts.customTools ?? []).filter(
(tool: any) => (!allowedSet || allowedSet.has(tool.name)) && !excluded.has(tool.name),
);
}
/**
* Stand in for `createAgentSession`. `turn` receives the clone's single custom
* tool and plays the part of the model deciding what to do with it — and only
* the tools Pi would really expose reach it, so a clone built with an allowlist
* that hides its own tool prompts a model with nothing to call.
*/
function cloneSession(turn?: (tool: any) => Promise<void> | void) {
const session = {
agent: { state: { systemPrompt: "rebuilt-from-cwd", messages: [] as any[] } },
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
} as any;
createAgentSession.mockImplementation(async (opts: any) => {
const tools = visibleTools(opts);
session.prompt.mockImplementation(async () => {
// No tool, no tool call: the model can only answer in prose.
if (tools.length === 0) return;
await turn?.(tools[0]);
});
session.createdWith = opts;
return { session };
});
return session;
}
/** What the model does when it plays along: one Agent call. */
const callsAgent = (params: Record<string, unknown> = { subagent_type: "Explore", prompt: "go" }) =>
async (tool: any) => {
await tool.execute("clone-tool-call-1", params, undefined, undefined, { cwd: "/fork" });
};
const opts = (over: Record<string, unknown> = {}) => ({
ctx: mainCtx(),
type: "Explore",
message: "find the flaky test",
agentTool: agentTool(),
...over,
}) as any;
describe("cloning the conversation", () => {
it("carries the conversation's own messages, not a rendering of them", async () => {
// The whole point: the copy reasons over what the main model can see.
const session = cloneSession(callsAgent());
await runMentionClone(opts());
expect(session.agent.state.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
]);
});
it("takes the conversation from memory, never from the session file", async () => {
// SessionManager withholds every write until the first assistant message,
// so a file-based copy is empty for the whole of the first turn — which is
// exactly when someone types their first mention.
const o = opts();
cloneSession(callsAgent());
await runMentionClone(o);
expect(buildSessionContext).toHaveBeenCalledWith([{ type: "message" }], "leaf-1");
expect(createAgentSession.mock.calls[0][0].sessionManager).toEqual({
kind: "in-memory-session-manager",
});
});
it("thinks at the level the session is really on", async () => {
cloneSession(callsAgent());
await runMentionClone(opts());
expect(createAgentSession.mock.calls[0][0].thinkingLevel).toBe("high");
});
it("omits the level rather than taking buildSessionContext's, which lies", async () => {
// getSessionContextSettings starts at "off" and moves only on an explicit
// thinking_level_change entry, so a session where nobody ran /think reports
// "off". Passing that would silently think less than the user asked for;
// omitting it lets createAgentSession resolve the real level from settings.
// Also the Pi <0.82.0 path, where ctx has no thinkingLevel at all.
buildSessionContext.mockReturnValue({ messages: CONVERSATION, thinkingLevel: "off", model: null } as any);
const o = opts({ ctx: mainCtx({ thinkingLevel: undefined }) });
cloneSession(callsAgent());
await runMentionClone(o);
expect(createAgentSession.mock.calls[0][0]).not.toHaveProperty("thinkingLevel");
});
it("clones a conversation that has not started yet", async () => {
// First input of a fresh session. There is no history to carry, which is an
// answer and not a failure — the copy still runs on the main model and
// system prompt, and still makes the call.
buildSessionContext.mockReturnValue({ messages: [], thinkingLevel: "medium", model: null } as any);
const o = opts();
const session = cloneSession(callsAgent());
const result = await runMentionClone(o);
expect(result).toEqual({ spawned: true });
expect(session.agent.state.messages).toEqual([]);
expect(session.agent.state.systemPrompt).toBe("the live system prompt");
});
it("carries the live system prompt rather than the one it rebuilt", async () => {
// createAgentSession derives a prompt from cwd and agentDir. Close, but not
// what the user's model is working under — extensions add to it per turn.
const session = cloneSession(callsAgent());
await runMentionClone(opts());
expect(session.agent.state.systemPrompt).toBe("the live system prompt");
});
it("inherits the parent's model, thinking level and providers", async () => {
cloneSession(callsAgent());
await runMentionClone(opts());
const built = createAgentSession.mock.calls[0][0];
expect(built.model).toEqual({ id: "main-model" });
expect(built.thinkingLevel).toBe("high");
expect(built.modelRuntime).toEqual({ kind: "runtime" });
});
it("gives the clone the Agent tool and nothing else", async () => {
// It runs where nobody is watching. A full toolset would let an invisible
// turn read, write or run things on the user's behalf.
cloneSession(callsAgent());
await runMentionClone(opts());
const built = createAgentSession.mock.calls[0][0];
expect(built.customTools).toHaveLength(1);
expect(built.customTools[0].name).toBe("Agent");
expect(visibleTools(built).map((tool: any) => tool.name)).toEqual(["Agent"]);
});
it("names its own tool in the allowlist, or Pi hands it nothing", async () => {
// `noTools: "all"` reads like "no built-ins, keep my custom tool" and is
// not: it sets an EMPTY allowlist, which strips the custom tool from the
// registry too (agent-session.js `isAllowedTool`). The clone then has
// nothing to call, every mention falls through to the direct start, and the
// user sees "Started @x directly — the conversation clone did not start it"
// on every single one. Naming the tool is what makes it reachable.
cloneSession(callsAgent());
const result = await runMentionClone(opts());
const built = createAgentSession.mock.calls[0][0];
expect(built.tools).toEqual(["Agent"]);
expect(built.noTools).toBeUndefined();
expect(result).toEqual({ spawned: true });
});
it("prompts it with the message, then the reminder", async () => {
const session = cloneSession(callsAgent());
await runMentionClone(opts());
expect(session.prompt).toHaveBeenCalledWith(
`find the flaky test\n\n${agentMentionReminder("Explore")}`,
);
});
});
describe("attributing the spawn to the real session", () => {
it("runs the real Agent handler with the MAIN context, not the fork's", async () => {
// The handler reads cwd, model and sessionManager.getSessionId() off this
// to place the .output transcript and rootSessionId. The clone's own
// context would file both under a session that is about to be discarded.
const tool = agentTool();
const o = opts({ agentTool: tool });
cloneSession(callsAgent());
await runMentionClone(o);
expect(tool.execute).toHaveBeenCalledTimes(1);
expect(tool.execute.mock.calls[0][4]).toBe(o.ctx);
});
it("passes no tool-call id, since the real session issued none", async () => {
// Left on the record it becomes a <tool-use-id> in the completion
// notification pointing at a call the main conversation never made.
const tool = agentTool();
cloneSession(callsAgent());
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][0]).toBeUndefined();
});
it("forwards the parameters the clone chose", async () => {
const tool = agentTool();
cloneSession(callsAgent({ subagent_type: "Plan", prompt: "sketch the migration" }));
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][1]).toEqual({
subagent_type: "Plan",
prompt: "sketch the migration",
run_in_background: true,
});
});
it("forces the spawn into the background — a foreground result goes nowhere", async () => {
// `run_in_background` defaults to false, and a foreground agent returns its
// answer as the TOOL RESULT: AgentManager marks the record `resultConsumed`
// precisely so the completion notification is skipped as redundant. Here
// that tool result lands in the throwaway clone, which is disposed moments
// later — so the agent runs to completion, shows up in the widget and the
// fleet, and its answer reaches nobody. The main conversation is not part
// of the clone's turn, so background delivery is the only way back.
const tool = agentTool();
cloneSession(callsAgent({ subagent_type: "Explore", prompt: "go" }));
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][1]).toMatchObject({ run_in_background: true });
});
it("overrides a clone that explicitly asked for a foreground run", async () => {
// Nothing tells the clone's model that its own turn is discarded, so an
// explicit `false` is a reasonable thing for it to emit. It must not decide
// this one.
const tool = agentTool();
cloneSession(callsAgent({ subagent_type: "Explore", prompt: "go", run_in_background: false }));
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][1]).toMatchObject({ run_in_background: true });
});
it("refuses a second spawn from the same mention", async () => {
// One handle, one agent. A clone that decides to also launch something else
// would do it unseen and unasked.
const tool = agentTool();
cloneSession(async (t) => {
await callsAgent()(t);
await callsAgent()(t);
});
const result = await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute).toHaveBeenCalledTimes(1);
expect(result).toEqual({ spawned: true });
});
it("tells the clone why the second call was refused", async () => {
const captured: any[] = [];
cloneSession(async (t) => {
await callsAgent()(t);
captured.push(await t.execute("c2", { subagent_type: "Explore", prompt: "again" }, undefined, undefined, {}));
});
await runMentionClone(opts());
expect(captured[0].isError).toBe(true);
expect(captured[0].content[0].text).toContain("Already started an agent");
});
});
describe("when the clone cannot deliver", () => {
it("reports a turn that never called the tool", async () => {
// The model answering in prose is a real outcome, and a silent one — the
// caller needs it to fall back rather than leave the mention unanswered.
cloneSession();
const result = await runMentionClone(opts());
expect(result.spawned).toBe(false);
expect(result.error).toContain("did not start it");
});
it("returns a thrown error rather than rejecting", async () => {
// The caller's fallback runs off the resolved value. A rejection here would
// land in an unhandled promise and lose the mention.
createAgentSession.mockRejectedValue(new Error("no provider configured"));
await expect(runMentionClone(opts())).resolves.toEqual({
spawned: false,
error: "no provider configured",
});
});
it("keeps a spawn that already happened when the turn then fails", async () => {
// The agent is running. Reporting spawned:false would start a second one.
cloneSession(async (tool) => {
await callsAgent()(tool);
throw new Error("turn aborted");
});
const result = await runMentionClone(opts());
expect(result).toEqual({ spawned: true, error: "turn aborted" });
});
it("disposes the clone even when the turn throws", async () => {
const session = cloneSession(() => {
throw new Error("turn aborted");
});
await runMentionClone(opts());
expect(session.dispose).toHaveBeenCalled();
});
});
@@ -0,0 +1,108 @@
/**
* mention-start-notification.test.ts — does an agent STARTED by a mention
* report back to the main conversation?
*
* The resume path is pinned in agent-mention-wiring.test.ts ("relays the
* resumed answer through the ordinary completion notification"). The start path
* — `@handle msg` naming a type with no live instance — has no equivalent, and
* it is the path every first mention takes.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() };
});
vi.mock("../src/mention-clone.js", () => ({ runMentionClone: vi.fn() }));
import { resumeAgent, runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
import { runMentionClone } from "../src/mention-clone.js";
import { ctx, type Hermetic, hermeticDir, makePi } from "./helpers/boot-extension.js";
let hermetic: Hermetic | undefined;
let booted: Map<string, any> | undefined;
beforeEach(() => {
vi.mocked(runAgent).mockReset();
vi.mocked(resumeAgent).mockReset();
vi.mocked(runMentionClone).mockReset();
});
afterEach(async () => {
await booted?.get("session_shutdown")?.();
delete (globalThis as any)[Symbol.for("pi-subagents:manager")];
booted = undefined;
hermetic?.restore();
hermetic = undefined;
});
function fakeSession() {
return {
steer: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(() => () => {}),
messages: [],
getActiveToolNames: vi.fn(() => []),
} as any;
}
function boot(settings: Record<string, unknown> = {}) {
hermetic = hermeticDir({ settings: { outputTranscript: false, ...settings } });
const b = makePi();
subagentsExtension(b.pi);
booted = b.lifecycle;
return b;
}
const send = (lifecycle: Map<string, any>, text: string) =>
lifecycle.get("input")({ type: "input", text, source: "interactive" }, ctx());
describe("an agent started by a mention", () => {
it("relays its answer through the ordinary completion notification (direct mode)", async () => {
const { pi, lifecycle } = boot({ agentMentions: "direct" });
vi.mocked(runAgent).mockResolvedValue({
responseText: "found four planted bugs",
session: fakeSession(),
aborted: false,
steered: false,
failure: undefined,
} as any);
await send(lifecycle, "@Explore find the planted bugs in src/");
await new Promise(r => setTimeout(r, 500));
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "subagent-notification",
content: expect.stringContaining("found four planted bugs"),
}),
expect.objectContaining({ triggerTurn: true }),
);
});
it("relays it when the clone fell back to a direct start (model mode)", async () => {
// What the user hits today: the clone reports it could not start the agent,
// index.ts starts it directly, and the answer still has to come back.
const { pi, lifecycle } = boot();
vi.mocked(runMentionClone).mockResolvedValue({ spawned: false, error: "the conversation clone did not start it" });
vi.mocked(runAgent).mockResolvedValue({
responseText: "cyan, obviously",
session: fakeSession(),
aborted: false,
steered: false,
failure: undefined,
} as any);
await send(lifecycle, "@Explore whats your favorite color");
await new Promise(r => setTimeout(r, 500));
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "subagent-notification",
content: expect.stringContaining("cyan, obviously"),
}),
expect.objectContaining({ triggerTurn: true }),
);
});
});
+202
View File
@@ -0,0 +1,202 @@
/**
* mention.test.ts — the `@handle` grammar.
*
* Both halves are load-bearing in a way that fails silently. A handle that
* isn't `[\w-]` can never be typed back (the trigger regex would not match it),
* and a collision that reuses a name makes an older sibling permanently
* unreachable. On the parse side, every rejection here is a case where being
* too eager would swallow input the user meant for the main model — a leading
* file path, a bare handle, a mention mid-sentence.
*/
import { describe, expect, it } from "vitest";
import { agentMentionReminder, assignHandle, describeMention, handleBase, isReservedHandle, MENTION_TRIGGER, parseMention, resolveHandleToType, stripAgentPrefix } from "../src/mention.js";
describe("handleBase", () => {
it("lowercases so the handle matches how it is typed", () => {
expect(handleBase("Explore")).toBe("explore");
});
it("keeps a hyphenated type as-is", () => {
expect(handleBase("general-purpose")).toBe("general-purpose");
});
it("reduces anything outside [\\w-] to hyphens, without leaving edge hyphens", () => {
expect(handleBase("Code Review!")).toBe("code-review");
expect(handleBase(" spaced out ")).toBe("spaced-out");
});
it("always produces something typeable", () => {
// A type made entirely of stripped characters would otherwise slug to "",
// and `@` alone can address nothing.
expect(handleBase("!!!")).toBe("agent");
expect(handleBase("")).toBe("agent");
});
it("caps a long name so one agent can't own an unreadable row", () => {
const slug = handleBase("x".repeat(200));
expect(slug).toHaveLength(64);
});
it("never leaves a trailing hyphen the cap sliced into", () => {
// 63 chars, then a hyphen run: a naive slice(0, 64) keeps that hyphen.
expect(handleBase(`${"x".repeat(63)} tail`).endsWith("-")).toBe(false);
});
it("only ever produces handles the suggestion trigger can match", () => {
for (const type of ["Explore", "general-purpose", "Code Review!", "!!!", "デバッグ"]) {
expect(MENTION_TRIGGER.test(`@${handleBase(type)}`)).toBe(true);
}
});
});
describe("assignHandle", () => {
it("takes the plain base when it is free", () => {
expect(assignHandle("explore", new Set())).toBe("explore");
});
it("numbers from 2 on the first collision", () => {
expect(assignHandle("explore", new Set(["explore"]))).toBe("explore-2");
});
it("keeps counting past every taken form", () => {
expect(assignHandle("explore", new Set(["explore", "explore-2"]))).toBe("explore-3");
});
it("never hands out the reserved main handle", () => {
// `@main` addresses the main conversation. An agent holding that name
// would silently swallow the one escape hatch out of the mention grammar.
expect(assignHandle("main", new Set())).toBe("main-2");
});
it("skips a gap rather than reusing a live handle", () => {
// explore-2 finished and was evicted; reusing it is fine, but explore-3
// is still running and must not be shadowed.
expect(assignHandle("explore", new Set(["explore", "explore-3"]))).toBe("explore-2");
});
});
describe("resolveHandleToType", () => {
const TYPES = ["general-purpose", "Explore", "Code Review!"];
it("finds the type a handle was derived from, whatever its casing", () => {
expect(resolveHandleToType("explore", TYPES)).toBe("Explore");
expect(resolveHandleToType("EXPLORE", TYPES)).toBe("Explore");
});
it("resolves a type whose slug differs from its name", () => {
expect(resolveHandleToType("code-review", TYPES)).toBe("Code Review!");
});
it("is exact, not a prefix match — a partial handle must not start an agent", () => {
expect(resolveHandleToType("ex", TYPES)).toBeUndefined();
expect(resolveHandleToType("explore-2", TYPES)).toBeUndefined();
});
it("round-trips every registered type", () => {
for (const type of TYPES) expect(resolveHandleToType(handleBase(type), TYPES)).toBe(type);
});
it("refuses to resolve the reserved handle, even to a type named for it", () => {
// Otherwise `@main do this` would start an agent instead of reaching the
// main model — and `assignHandle` already denies its instances that name,
// so resolving the type here would promise something unreachable.
expect(resolveHandleToType("main", ["main", ...TYPES])).toBeUndefined();
});
});
describe("isReservedHandle", () => {
it("recognizes main whatever its casing", () => {
expect(isReservedHandle("main")).toBe(true);
expect(isReservedHandle("MAIN")).toBe(true);
});
it("leaves every ordinary handle alone", () => {
for (const handle of ["explore", "mainframe", "main-2", "ma"]) {
expect(isReservedHandle(handle)).toBe(false);
}
});
});
describe("stripAgentPrefix", () => {
it("unwraps Claude Code's manual @agent-<type> spelling", () => {
expect(stripAgentPrefix("agent-explore")).toBe("explore");
});
it("keeps the remainder intact when it is itself prefixed", () => {
expect(stripAgentPrefix("agent-agent-foo")).toBe("agent-foo");
});
it("returns nothing when there is no prefix or nothing behind it", () => {
expect(stripAgentPrefix("explore")).toBeUndefined();
expect(stripAgentPrefix("agent-")).toBeUndefined();
expect(stripAgentPrefix("agentexplore")).toBeUndefined();
});
it("only unwraps a prefix at the very start", () => {
// `@sub-agent-explore` names an agent called `sub-agent-explore`. Matching
// `agent-` anywhere would silently redirect it to `@explore`.
expect(stripAgentPrefix("sub-agent-explore")).toBeUndefined();
});
});
describe("describeMention", () => {
it("uses the message as the agent's short label", () => {
expect(describeMention("find every retry marker")).toBe("find every retry marker");
});
it("takes the first line and collapses whitespace", () => {
expect(describeMention(" audit the RPC path\nthen report back ")).toBe("audit the RPC path");
});
it("clips a long message rather than putting a paragraph in every agent surface", () => {
const label = describeMention("x".repeat(200));
expect(label).toHaveLength(40);
expect(label.endsWith("…")).toBe(true);
});
});
describe("parseMention", () => {
it("splits a leading handle from its message", () => {
expect(parseMention("@explore check the RPC path")).toEqual({
handle: "explore",
message: "check the RPC path",
});
});
it("trims the message and accepts a newline as the separator", () => {
expect(parseMention("@explore spaced ")).toEqual({ handle: "explore", message: "spaced" });
expect(parseMention("@explore\nline1\nline2")).toEqual({ handle: "explore", message: "line1\nline2" });
});
it("rejects a bare handle — that belongs to the main model", () => {
expect(parseMention("@explore")).toBeNull();
expect(parseMention("@explore ")).toBeNull();
expect(parseMention("@explore \t ")).toBeNull();
});
it("rejects a leading file path so pi's @-attachment keeps working", () => {
expect(parseMention("@src/index.ts summarize this")).toBeNull();
expect(parseMention("@README.md what changed")).toBeNull();
});
it("rejects a mention that is not at the start of the input", () => {
expect(parseMention("hey @explore look at this")).toBeNull();
expect(parseMention(" @explore look at this")).toBeNull();
});
});
describe("agentMentionReminder", () => {
it("is Claude Code's string, byte for byte", () => {
// Ported from the 2.1.233 bundle rather than paraphrased, so the model gets
// the wording it was trained against. Asserted whole — including the
// trailing space before the closing newline, which is in the original
// template literal and is exactly the kind of thing a tidy-up would drop.
expect(agentMentionReminder("code-review")).toBe(
'<system-reminder>\nThe user has expressed a desire to invoke the agent "code-review". Please invoke the agent appropriately, passing in the required context to it. \n</system-reminder>',
);
});
it("names the agent it was given", () => {
expect(agentMentionReminder("Plan")).toContain('invoke the agent "Plan"');
});
});
+259
View File
@@ -0,0 +1,259 @@
import { describe, expect, it } from "vitest";
import { type ModelRegistry, resolveModel } from "../src/model-resolver.js";
// Mock model entries matching typical pi model registry shape
const MODELS = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", provider: "anthropic" },
{ id: "gpt-4o", name: "GPT-4o", provider: "openai" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" },
];
function makeRegistry(models = MODELS, available?: typeof MODELS): ModelRegistry {
return {
find(provider: string, modelId: string) {
return models.find(m => m.provider === provider && m.id === modelId);
},
getAll() {
return models;
},
getAvailable: available ? () => available : undefined,
};
}
describe("resolveModel", () => {
describe("exact match (provider/modelId)", () => {
it("resolves exact provider/modelId", () => {
const result = resolveModel("anthropic/claude-opus-4-6", makeRegistry());
expect(result).toEqual(MODELS[0]);
});
it("resolves another exact provider/modelId", () => {
const result = resolveModel("openai/gpt-4o", makeRegistry());
expect(result).toEqual(MODELS[3]);
});
it("falls through to fuzzy when exact provider/modelId not found", () => {
// "anthropic/haiku" is not an exact match, but fuzzy should find it
const result = resolveModel("anthropic/haiku", makeRegistry());
expect(result).toEqual(MODELS[2]); // haiku
});
});
describe("fuzzy match — exact id", () => {
it("matches exact model id without provider", () => {
const result = resolveModel("claude-opus-4-6", makeRegistry());
expect(result).toEqual(MODELS[0]);
});
it("is case-insensitive", () => {
const result = resolveModel("Claude-Opus-4-6", makeRegistry());
expect(result).toEqual(MODELS[0]);
});
it("matches exact id for non-anthropic models", () => {
const result = resolveModel("gpt-4o", makeRegistry());
expect(result).toEqual(MODELS[3]);
});
});
describe("fuzzy match — substring", () => {
it("matches 'haiku' to claude-haiku model", () => {
const result = resolveModel("haiku", makeRegistry());
expect(result).toEqual(MODELS[2]);
});
it("matches 'sonnet' to claude-sonnet model", () => {
const result = resolveModel("sonnet", makeRegistry());
expect(result).toEqual(MODELS[1]);
});
it("matches 'opus' to claude-opus model", () => {
const result = resolveModel("opus", makeRegistry());
expect(result).toEqual(MODELS[0]);
});
it("matches 'gemini' to gemini model", () => {
const result = resolveModel("gemini", makeRegistry());
expect(result).toEqual(MODELS[4]);
});
it("is case-insensitive for substring", () => {
const result = resolveModel("HAIKU", makeRegistry());
expect(result).toEqual(MODELS[2]);
});
});
describe("fuzzy match — separator equivalence (dash vs dot)", () => {
// id uses dashes and the name carries no version number — the case that
// failed before separators were normalized (the "4.5" token couldn't be
// found anywhere, so the dotted query matched nothing).
const HAIKU = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "anthropic" };
const dashReg = makeRegistry([HAIKU]);
it("matches a dotted query to a dashed id", () => {
expect(resolveModel("claude-haiku-4.5", dashReg)).toEqual(HAIKU);
});
it("matches a dotted provider/id query to a dashed id", () => {
expect(resolveModel("anthropic/claude-haiku-4.5", dashReg)).toEqual(HAIKU);
});
it("matches a dashed query to a dotted id", () => {
expect(resolveModel("gemini-2-5-pro", makeRegistry())).toEqual(MODELS[4]);
});
});
describe("fuzzy match — trailing date-stamp is optional", () => {
// A date-pinned config (e.g. an agent's frontmatter, or a shipped default)
// should still resolve when the registry lists the model without the stamp.
const HAIKU_DASH = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "anthropic" };
const HAIKU_DOT = { id: "claude-haiku-4.5", name: "Claude Haiku", provider: "anthropic" };
it("matches a dated provider/id config to an undated registry id", () => {
expect(resolveModel("anthropic/claude-haiku-4-5-20251001", makeRegistry([HAIKU_DASH]))).toEqual(HAIKU_DASH);
});
it("matches a dated config to an undated *dotted* registry id (date + separator)", () => {
expect(resolveModel("anthropic/claude-haiku-4-5-20251001", makeRegistry([HAIKU_DOT]))).toEqual(HAIKU_DOT);
});
it("still prefers an exact dated id when the registry has it", () => {
const dated = { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", provider: "anthropic" };
expect(resolveModel("anthropic/claude-haiku-4-5-20251001", makeRegistry([dated]))).toEqual(dated);
});
});
describe("provider fallback (prefer named provider, else any)", () => {
const gatewayHaiku = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "openrouter" };
const anthropicHaiku = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "anthropic" };
it("falls back to another provider when the named one lacks the model", () => {
expect(resolveModel("anthropic/claude-haiku-4-5", makeRegistry([gatewayHaiku]))).toEqual(gatewayHaiku);
});
it("prefers the named provider when it has the model", () => {
expect(resolveModel("anthropic/claude-haiku-4-5", makeRegistry([gatewayHaiku, anthropicHaiku]))).toEqual(anthropicHaiku);
});
it("still errors when no provider has the model", () => {
expect(typeof resolveModel("anthropic/nonexistent-xyz", makeRegistry([gatewayHaiku]))).toBe("string");
});
});
describe("fuzzy match — name contains", () => {
it("matches 'Opus 4.6' via model name", () => {
const result = resolveModel("Opus 4.6", makeRegistry());
expect(result).toEqual(MODELS[0]);
});
it("matches 'Haiku 4.5' via model name", () => {
const result = resolveModel("Haiku 4.5", makeRegistry());
expect(result).toEqual(MODELS[2]);
});
});
describe("fuzzy match — multi-part", () => {
it("matches 'anthropic opus' across provider and id", () => {
const result = resolveModel("anthropic opus", makeRegistry());
expect(result).toEqual(MODELS[0]);
});
it("matches 'google pro' across provider and id", () => {
const result = resolveModel("google pro", makeRegistry());
expect(result).toEqual(MODELS[4]);
});
});
describe("fuzzy match — prefers tighter matches", () => {
it("prefers exact id over substring", () => {
const result = resolveModel("gpt-4o", makeRegistry());
expect(result).toEqual(MODELS[3]);
});
it("substring match prefers shorter model id (tighter fit)", () => {
// Both opus and sonnet contain their query as substring, but "opus" is a tighter match
// for "opus" than "sonnet" is for "sonnet" — each should resolve to itself
expect(resolveModel("opus", makeRegistry())).toEqual(MODELS[0]);
expect(resolveModel("sonnet", makeRegistry())).toEqual(MODELS[1]);
});
});
describe("no match", () => {
it("returns error string for unknown model", () => {
const result = resolveModel("nonexistent-model", makeRegistry());
expect(typeof result).toBe("string");
expect(result).toContain('Model not found: "nonexistent-model"');
expect(result).toContain("Available models:");
});
it("error lists available models", () => {
const result = resolveModel("xyz", makeRegistry());
expect(result).toContain("anthropic/claude-opus-4-6");
expect(result).toContain("openai/gpt-4o");
});
it("empty string matches a model (multi-part vacuous truth)", () => {
// Empty string splits to empty parts; every() on empty array is true
// This is fine — callers guard against empty input
const result = resolveModel("", makeRegistry());
expect(typeof result).toBe("object");
});
});
describe("getAvailable filtering", () => {
it("uses getAvailable when present (filters to configured models)", () => {
const available = [MODELS[0], MODELS[2]]; // only opus and haiku
const result = resolveModel("sonnet", makeRegistry(MODELS, available));
// sonnet is in getAll but not in getAvailable — should not fuzzy match
expect(typeof result).toBe("string");
expect(result).toContain("Model not found");
});
it("exact match fails when model is not in getAvailable (no auth)", () => {
const available = [MODELS[0]]; // only opus available
const result = resolveModel("anthropic/claude-sonnet-4-6", makeRegistry(MODELS, available));
expect(typeof result).toBe("string");
expect(result).toContain("Model not found");
});
it("fuzzy matches against available models only", () => {
const available = [MODELS[2]]; // only haiku available
const result = resolveModel("haiku", makeRegistry(MODELS, available));
expect(result).toEqual(MODELS[2]);
});
});
describe("ambiguous matches", () => {
const SIMILAR_MODELS = [
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" },
{ id: "claude-sonnet-4-5-20241022", name: "Claude Sonnet 4.5", provider: "anthropic" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", provider: "anthropic" },
];
it("'sonnet' prefers tighter id match (shorter id)", () => {
const result = resolveModel("sonnet", makeRegistry(SIMILAR_MODELS));
// "sonnet" is a larger fraction of "claude-sonnet-4-6" than "claude-sonnet-4-5-20241022"
expect(result).toEqual(SIMILAR_MODELS[0]);
});
it("'sonnet 4.5' resolves to the 4.5 model via name", () => {
const result = resolveModel("sonnet 4.5", makeRegistry(SIMILAR_MODELS));
expect(result).toEqual(SIMILAR_MODELS[1]);
});
it("'4-6' picks the 4.6 model", () => {
const result = resolveModel("4-6", makeRegistry(SIMILAR_MODELS));
expect(result).toEqual(SIMILAR_MODELS[0]);
});
});
describe("empty registry", () => {
it("returns error with empty available list", () => {
const result = resolveModel("haiku", makeRegistry([]));
expect(typeof result).toBe("string");
expect(result).toContain("Model not found");
});
});
});
+147
View File
@@ -0,0 +1,147 @@
// src/model-scope.ts had no test file. Its `error` verdict was reachable only
// indirectly, through one nested-tools case; the `warn` verdict — the whole
// reason the policy is a three-way split rather than a boolean — was never
// exercised at all.
//
// The split is the point: a model the ORCHESTRATOR picked at runtime is refused,
// because it can pick again; a model the USER pinned in frontmatter (or that was
// inherited from the parent) only warns, because refusing it would break every
// pinned agent the moment someone enables the setting. Collapsing the two in
// either direction is a one-line edit with no test in the way.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ModelRegistryRef } from "../src/enabled-models.js";
import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "../src/model-scope.js";
const MODELS = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" },
];
function makeRegistry(models = MODELS): ModelRegistryRef {
return { getAll() { return models; }, getAvailable: undefined };
}
const HAIKU = { provider: "anthropic", id: "claude-haiku-4-5" };
const OPUS = { provider: "anthropic", id: "claude-opus-4-6" };
describe("checkModelScope", () => {
let projectDir: string;
let agentDir: string;
let prevAgentDir: string | undefined;
let prevEnabled: boolean;
beforeEach(() => {
// resolveEnabledModels memoizes on (patterns, mtime+size of both settings
// files). A fresh project dir per test keeps one case's allowlist from
// being served to the next.
projectDir = mkdtempSync(join(tmpdir(), "pi-scope-project-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-scope-global-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
prevEnabled = isScopeModelsEnabled();
});
afterEach(() => {
setScopeModelsEnabled(prevEnabled); // module-global — restore for other suites
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
rmSync(projectDir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
});
function setEnabledModels(models: string[]) {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ enabledModels: models }));
}
function check(overrides: Partial<Parameters<typeof checkModelScope>[0]> = {}) {
return checkModelScope({
model: OPUS,
cwd: projectDir,
modelRegistry: makeRegistry(),
callerSupplied: true,
agentLabel: "scout",
...overrides,
});
}
it("is a no-op while the feature is off, even for an out-of-scope model", () => {
setScopeModelsEnabled(false);
setEnabledModels(["anthropic/claude-haiku-4-5"]);
expect(check().kind).toBe("ok");
});
it("is a no-op when no model was resolved", () => {
setScopeModelsEnabled(true);
setEnabledModels(["anthropic/claude-haiku-4-5"]);
expect(check({ model: undefined }).kind).toBe("ok");
});
it("is a no-op when the user has no enabledModels configured", () => {
setScopeModelsEnabled(true);
expect(check().kind).toBe("ok");
});
it("is a no-op when enabledModels resolves to nothing usable", () => {
// Globs and bare ids are deliberately unsupported; an unresolvable list
// must disable the check rather than lock the user out of every model.
setScopeModelsEnabled(true);
setEnabledModels(["anthropic/*", "haiku"]);
expect(check().kind).toBe("ok");
});
it("passes an in-scope model", () => {
setScopeModelsEnabled(true);
setEnabledModels(["anthropic/claude-opus-4-6"]);
expect(check().kind).toBe("ok");
});
describe("out of scope", () => {
beforeEach(() => {
setScopeModelsEnabled(true);
setEnabledModels(["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"]);
});
it("refuses a caller-supplied choice and lists what is allowed", () => {
const verdict = check({ callerSupplied: true, modelInput: "anthropic/claude-opus-4-6" });
expect(verdict.kind).toBe("error");
const message = (verdict as { message: string }).message;
expect(message).toContain('"anthropic/claude-opus-4-6"');
expect(message).toContain(" anthropic/claude-haiku-4-5");
expect(message).toContain(" anthropic/claude-sonnet-4-6");
});
it("only warns for a frontmatter-pinned choice, so the spawn still proceeds", () => {
const verdict = check({ callerSupplied: false, modelInput: "anthropic/claude-opus-4-6" });
expect(verdict.kind).toBe("warn");
expect((verdict as { message: string }).message)
.toBe('Agent "scout" using out-of-scope model "anthropic/claude-opus-4-6"');
});
it("names the resolved model in the warning when there was no raw input", () => {
// Parent-inherited: nothing was typed, so the label falls back to the
// resolved provider/id rather than rendering "undefined".
const verdict = check({ callerSupplied: false, modelInput: undefined });
expect(verdict.kind).toBe("warn");
expect((verdict as { message: string }).message).toContain("anthropic/claude-opus-4-6");
expect((verdict as { message: string }).message).not.toContain("undefined");
});
it("stops enforcing as soon as the setting is turned back off", () => {
expect(check().kind).toBe("error");
setScopeModelsEnabled(false);
expect(check().kind).toBe("ok");
});
it("treats scope as case-insensitive on both sides", () => {
setEnabledModels(["Anthropic/Claude-Opus-4-6"]);
expect(check({ model: OPUS }).kind).toBe("ok");
expect(check({ model: HAIKU }).kind).toBe("error");
});
});
});
@@ -0,0 +1,264 @@
/**
* nested-delegation-e2e.test.ts — regression for opt-in nested delegation, run
* through the real stack: real pi loader + real extension + real runAgent + two
* real child sessions, on a faux model.
*
* Everything else that covers nesting stops short of a real session — the tool
* unit tests use a fake manager, and the runner tests assert against a mocked
* pi. That leaves the load-bearing integration facts unproven: that pi actually
* admits the injected `customTools` into a child session's ACTIVE tool set
* (they collide with EXCLUDED_TOOL_NAMES by design, so a registry gate could
* silently drop them), and that a grandchild's output travels back up two hops.
* Those are exactly the things that break quietly, so they are pinned here.
*
* Deliberately faux, not live: `PI_E2E_LIVE=1` cannot drive a three-level chain
* deterministically, and a live model choosing not to delegate would look like
* a passing test. Each run therefore pins `live: false` rather than trusting the
* env var to leave it alone — the pre-publish smoke sets it globally.
*/
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type Context, fauxToolCall } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerAgents } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js";
import { encodeCwd } from "../src/output-file.js";
import {
agentCall,
type FauxReply,
type PrintModeRun,
runPrintMode,
} from "./helpers/print-mode-runner.js";
vi.setConfig({ testTimeout: 30_000 });
/** Marker the deepest agent emits — it must survive two hops back to the parent. */
const WORKER_MARKER = "WORKER-REACHED-THE-TOP";
/** First user message of a session — the only stable way to tell three faux sessions apart. */
function firstUserText(context: Context): string {
const first = context.messages.find((m) => m.role === "user");
const content = first?.content;
if (typeof content === "string") return content;
return ((content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join("");
}
/** Tool results in a session context, newest last, with their tool names. */
function toolResultTexts(context: Context): Array<{ name: string; text: string }> {
const out: Array<{ name: string; text: string }> = [];
for (const m of context.messages) {
if (m.role !== "toolResult") continue;
const name = (m as { toolName?: string }).toolName ?? "";
const text = ((m.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join("");
out.push({ name, text });
}
return out;
}
/** Every `.output` transcript beneath a root, at any session/tasks depth. */
function findOutputFiles(root: string): string[] {
let entries: string[];
try { entries = readdirSync(root); } catch { return []; }
return entries.flatMap((e) => {
const full = join(root, e);
if (statSync(full).isDirectory()) return findOutputFiles(full);
return full.endsWith(".output") ? [full] : [];
});
}
function writeAgents(cwd: string): void {
const dir = join(cwd, ".pi", "agents");
mkdirSync(dir, { recursive: true });
// Opts into nesting, restricted to one type — the allowlist path, not `all`.
writeFileSync(
join(dir, "orchestrator.md"),
"---\ndescription: Delegating orchestrator\ntools: read\nextensions: false\nallowed_subagents: worker\n---\nDelegate to worker.\n",
);
writeFileSync(
join(dir, "worker.md"),
"---\ndescription: Leaf worker\ntools: read\nextensions: false\n---\nDo the work.\n",
);
}
describe("nested delegation e2e (real pi-mono, faux model)", () => {
let run: PrintModeRun | undefined;
const tmpDirs: string[] = [];
afterEach(async () => {
await run?.dispose();
run = undefined;
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
it("a child with allowed_subagents spawns its own child, and the output travels back up", async () => {
const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-"));
tmpDirs.push(cwd);
writeAgents(cwd);
/** Tool names each session was actually offered, keyed by who it is. */
const toolsSeen = new Map<string, string[]>();
const respond = (context: Context): FauxReply => {
const text = firstUserText(context);
const names = (context.tools ?? []).map((t) => t.name);
// Leaf: no nested tools (it never opted in) — just answer.
if (text.includes("Do the leaf work")) {
toolsSeen.set("worker", names);
return WORKER_MARKER;
}
// Middle: opted in, so pi must have admitted the injected Agent tool.
if (text.includes("Delegate this downward")) {
toolsSeen.set("orchestrator", names);
const alreadySpawned = context.messages.some(
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
);
if (alreadySpawned) {
const result = [...context.messages]
.reverse()
.find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent");
const inner = ((result?.content ?? []) as Array<{ text?: string }>)
.map((b) => b.text ?? "")
.join("");
// Echo the child's own text: if it never arrived, the marker is absent
// and the top-level assertion fails rather than passing vacuously.
return `orchestrator saw: ${inner}`;
}
return agentCall({
subagent_type: "worker",
description: "leaf work",
prompt: "Do the leaf work.",
});
}
// Top-level parent.
toolsSeen.set("parent", names);
const spawned = context.messages.some(
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
);
if (spawned) {
const result = [...context.messages]
.reverse()
.find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent");
const inner = ((result?.content ?? []) as Array<{ text?: string }>)
.map((b) => b.text ?? "")
.join("");
return `parent saw: ${inner}`;
}
return agentCall({
subagent_type: "orchestrator",
description: "delegate",
prompt: "Delegate this downward.",
// Foreground: this test reads the parent's inline Agent tool result.
run_in_background: false,
});
};
run = await runPrintMode({
prompt: "Delegate the work.",
cwd,
respond,
live: false,
beforeRun: () => { registerAgents(loadCustomAgents(cwd)); },
});
// pi admitted the injected nested tools into the opted-in child's active set,
// despite their names colliding with the ones stripped from every subagent.
expect(toolsSeen.get("orchestrator")).toContain("Agent");
expect(toolsSeen.get("orchestrator")).toContain("get_subagent_result");
expect(toolsSeen.get("orchestrator")).toContain("steer_subagent");
// The leaf never opted in, so it must not have them.
expect(toolsSeen.get("worker")).toBeDefined();
expect(toolsSeen.get("worker")).not.toContain("Agent");
// Two hops home: worker → orchestrator → parent.
expect(run.responseText).toContain(WORKER_MARKER);
});
it("backgrounds a nested child, polls it by id, and streams its transcript", async () => {
const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-bg-"));
tmpDirs.push(cwd);
writeAgents(cwd);
const transcriptRoot = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`, encodeCwd(cwd));
rmSync(transcriptRoot, { recursive: true, force: true });
const respond = (context: Context): FauxReply => {
const text = firstUserText(context);
if (text.includes("Do the leaf work")) return WORKER_MARKER;
if (text.includes("Delegate this downward")) {
const results = toolResultTexts(context);
const spawned = results.find((r) => r.name === "Agent")?.text ?? "";
const polled = results.find((r) => r.name === "get_subagent_result")?.text;
// Third turn: the poll came back — echo it so a lost result fails loudly.
if (polled !== undefined) return `orchestrator polled: ${polled}`;
// Second turn: the spawn returned an id; fetch by exactly that id, which
// also exercises the manager's ownership check from inside a child.
if (spawned) {
const id = /Agent ID:\s*(\S+)/.exec(spawned)?.[1];
expect(id).toBeTruthy();
return fauxToolCall("get_subagent_result", { agent_id: id, wait: true });
}
return agentCall({
subagent_type: "worker",
description: "leaf work",
prompt: "Do the leaf work.",
run_in_background: true,
});
}
if (toolResultTexts(context).some((r) => r.name === "Agent")) return "parent done";
return agentCall({
subagent_type: "orchestrator",
description: "delegate",
prompt: "Delegate this downward.",
// Foreground: this test reads the parent's inline Agent tool result.
run_in_background: false,
});
};
try {
run = await runPrintMode({
prompt: "Delegate the work.",
cwd,
respond,
live: false,
beforeRun: () => { registerAgents(loadCustomAgents(cwd)); },
});
// The background child ran and its output came back through the id the
// spawn handed out — so it was never queued behind its waiting parent.
const orchestratorResult = run.parentSession.messages
.filter((m) => m.role === "toolResult")
.flatMap((m) => (m.content as Array<{ text?: string }>).map((b) => b.text ?? ""))
.join("\n");
expect(orchestratorResult).toContain("orchestrator polled");
expect(orchestratorResult).toContain(WORKER_MARKER);
// Only the REAL manager wires onSessionCreated → streamToOutputFile for a
// nested spawn, and only real rootSessionId propagation puts the file under
// this root. Identify the WORKER's own transcript by the prompt in its
// initial entry — matching the marker alone would also match the
// orchestrator's transcript, which merely echoes it, and would pass even
// with nested transcripts switched off entirely.
// Match on the FIRST line — writeInitialEntry seeds each transcript with the
// prompt that agent was given. Searching the whole file would also match the
// orchestrator's, which records the same string inside its Agent tool-call
// arguments, and would pass with nested transcripts switched off entirely.
const transcripts = findOutputFiles(transcriptRoot).map((f) => readFileSync(f, "utf-8"));
const workerTranscript = transcripts.find((t) => {
const first = JSON.parse(t.split("\n")[0]) as { message?: { content?: unknown } };
return first.message?.content === "Do the leaf work.";
});
expect(workerTranscript).toBeDefined();
// ...and it streamed the child's own turn, not just the seeded prompt.
expect(workerTranscript).toContain(WORKER_MARKER);
} finally {
rmSync(transcriptRoot, { recursive: true, force: true });
}
});
});
+543
View File
@@ -0,0 +1,543 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAvailableTypes, registerAgents, setFallbackSubagent } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js";
import { setScopeModelsEnabled } from "../src/model-scope.js";
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager, setMaxSubagentDepth } from "../src/nested-tools.js";
import { encodeCwd } from "../src/output-file.js";
let cwd: string;
let manager: NestedAgentManager;
let records: Map<string, any>;
let spawn: ReturnType<typeof vi.fn>;
let spawnAndWait: ReturnType<typeof vi.fn>;
function writeAgent(name: string, extra = "") {
const dir = join(cwd, ".pi", "agents");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${name}.md`), `---\ndescription: ${name}\ntools: read\n${extra}---\n${name}\n`);
}
const MODELS = [
{ id: "allowed", name: "Allowed", provider: "anthropic" },
{ id: "blocked", name: "Blocked", provider: "anthropic" },
];
function ctx(executionCwd = cwd) {
return {
cwd: executionCwd,
model: undefined,
modelRegistry: {
find: (provider: string, id: string) => ({ provider, id }),
getAvailable: () => MODELS,
getAll: () => MODELS,
},
} as any;
}
function tools(
allowedSubagents: "all" | string[] = "all",
depth = 1,
maxSubagentDepth = 2,
configCwd = cwd,
) {
return createNestedSubagentTools({
manager,
pi: {} as any,
parentAgentId: "parent-1",
depth,
maxSubagentDepth,
allowedSubagents,
configCwd,
});
}
async function execute(tool: any, params: Record<string, unknown>, executionCwd = cwd) {
return tool.execute("call-1", params, undefined, undefined, ctx(executionCwd));
}
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "nested-tools-test-"));
writeAgent("scout");
writeAgent("reviewer");
registerAgents(loadCustomAgents(cwd));
records = new Map();
spawn = vi.fn((_pi, _ctx, type, _prompt, options) => {
const id = `child-${records.size + 1}`;
records.set(id, { id, type, status: "running", parentAgentId: options.parentAgentId });
return id;
});
spawnAndWait = vi.fn(async (_pi, _ctx, type, _prompt, options) => {
const id = `child-${records.size + 1}`;
const record = { id, type, status: "completed", result: "done", parentAgentId: options.parentAgentId };
records.set(id, record);
return { id, record };
});
manager = {
spawn,
spawnAndWait,
getRecord: (id: string) => records.get(id),
resume: vi.fn(),
} as any;
});
afterEach(() => {
setScopeModelsEnabled(false);
rmSync(cwd, { recursive: true, force: true });
});
describe("child-safe nested Agent tools", () => {
it("allows any enabled agent when allowed_subagents is omitted", async () => {
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "reviewer",
description: "review evidence",
prompt: "Review it",
});
expect(result.isError).toBe(false);
expect(spawnAndWait).toHaveBeenCalledWith(
expect.anything(), expect.anything(), "reviewer", "Review it",
expect.objectContaining({
depth: 2,
parentAgentId: "parent-1",
maxSubagentDepth: 2,
configCwd: cwd,
}),
expect.any(Function), // onSpawned — attaches the child's transcript
);
});
it("keeps agent discovery rooted in inherited config, not the working directory", async () => {
const workCwd = mkdtempSync(join(tmpdir(), "nested-tools-work-"));
const workAgentDir = join(workCwd, ".pi", "agents");
mkdirSync(workAgentDir, { recursive: true });
writeFileSync(join(workAgentDir, "intruder.md"), "---\ndescription: intruder\n---\nintruder\n");
try {
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "intruder",
description: "untrusted agent",
prompt: "Do work",
}, workCwd);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown or disabled");
expect(spawnAndWait).not.toHaveBeenCalled();
} finally {
rmSync(workCwd, { recursive: true, force: true });
}
});
it("enforces a narrow allowlist", async () => {
const [limited] = tools(["scout"]);
const denied = await execute(limited, {
subagent_type: "reviewer",
description: "review evidence",
prompt: "Review it",
});
expect(denied.isError).toBe(true);
expect(denied.content[0].text).toContain("not allowed");
expect(spawnAndWait).not.toHaveBeenCalled();
const allowed = await execute(limited, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
});
expect(allowed.isError).toBe(false);
expect(spawnAndWait).toHaveBeenCalledTimes(1);
});
it("resolves nested types without touching the process-global registry", async () => {
// A worktree-isolated parent hands its own config root down. Resolving from
// it must not swap the registry the main session and every other agent read.
const otherCwd = mkdtempSync(join(tmpdir(), "nested-tools-config-"));
const otherAgentDir = join(otherCwd, ".pi", "agents");
mkdirSync(otherAgentDir, { recursive: true });
writeFileSync(join(otherAgentDir, "branch-only.md"), "---\ndescription: branch-only\n---\nbranch-only\n");
const before = getAvailableTypes();
try {
const [agent] = tools("all", 1, 2, otherCwd);
const result = await execute(agent, {
subagent_type: "branch-only",
description: "branch agent",
prompt: "Do work",
});
// Resolved from the inherited root...
expect(result.isError).toBe(false);
// ...without leaking it into the shared registry.
expect(getAvailableTypes()).toEqual(before);
expect(getAvailableTypes()).not.toContain("branch-only");
} finally {
rmSync(otherCwd, { recursive: true, force: true });
}
});
it("applies the scopeModels allowlist to a caller-supplied model", async () => {
writeFileSync(
join(cwd, ".pi", "settings.json"),
JSON.stringify({ enabledModels: ["anthropic/allowed"] }),
);
setScopeModelsEnabled(true);
const [agent] = tools();
const blocked = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
model: "anthropic/blocked",
});
expect(blocked.isError).toBe(true);
expect(blocked.content[0].text).toContain("Model not in scope");
expect(spawnAndWait).not.toHaveBeenCalled();
const inScope = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
model: "anthropic/allowed",
});
expect(inScope.isError).toBe(false);
});
it("queues a steer for an owned child whose session is not ready yet", async () => {
const [, , steer] = tools();
const record: Record<string, unknown> = {
id: "child-1",
status: "running",
parentAgentId: "parent-1",
};
records.set("child-1", record);
const result = await execute(steer, { agent_id: "child-1", message: "focus on tests" });
expect(result.isError).toBe(false);
expect(record.pendingSteers).toEqual(["focus on tests"]);
});
it("blocks delegation at the inherited depth cap", async () => {
const [agent] = tools("all", 2, 2);
const result = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("depth=2, max=2");
expect(spawnAndWait).not.toHaveBeenCalled();
});
it("rejects unknown or disabled nested agent types instead of falling back", async () => {
writeAgent("disabled", "enabled: false\n");
registerAgents(loadCustomAgents(cwd));
const [agent] = tools();
for (const subagentType of ["missing", "disabled"]) {
const result = await execute(agent, {
subagent_type: subagentType,
description: "invalid agent",
prompt: "Do work",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown or disabled");
}
});
it("supports background launches and ownership-scopes result, resume, and steer", async () => {
const [agent, getResult, steer] = tools(["scout"]);
const launched = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
run_in_background: true,
});
expect(launched.content[0].text).toContain("child-1");
expect(spawn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), "scout", "Find them",
expect.objectContaining({ isBackground: true, depth: 2, parentAgentId: "parent-1" }),
);
const own = await execute(getResult, { agent_id: "child-1" });
expect(own.isError).toBe(false);
records.set("foreign", {
id: "foreign",
status: "running",
result: "secret",
parentAgentId: "other",
session: { steer: vi.fn() },
});
expect((await execute(getResult, { agent_id: "foreign" })).isError).toBe(true);
expect((await execute(steer, { agent_id: "foreign", message: "stop" })).isError).toBe(true);
expect((await execute(agent, {
resume: "foreign",
subagent_type: "scout",
description: "resume foreign",
prompt: "Continue",
})).isError).toBe(true);
expect(manager.resume).not.toHaveBeenCalled();
});
it("waits for a queued owned child to start and settle", async () => {
const [, getResult] = tools();
const record = {
id: "queued-child",
status: "queued",
parentAgentId: "parent-1",
promise: undefined as Promise<unknown> | undefined,
result: undefined as string | undefined,
};
records.set(record.id, record);
setTimeout(() => {
record.status = "running";
record.promise = Promise.resolve().then(() => {
record.status = "completed";
record.result = "queued done";
});
}, 10);
const result = await execute(getResult, { agent_id: record.id, wait: true });
expect(result.content[0].text).toBe("queued done");
});
it("aborts a nested result wait without aborting the owned child", async () => {
const [, getResult] = tools();
let settleChild: (() => void) | undefined;
const record = {
id: "running-child",
status: "running",
parentAgentId: "parent-1",
promise: new Promise<void>(resolve => { settleChild = resolve; }),
};
records.set(record.id, record);
const controller = new AbortController();
const outcome = getResult
.execute("call-abort", { agent_id: record.id, wait: true }, controller.signal, undefined, ctx())
.then(() => "resolved", (e: unknown) => (e instanceof Error ? e.name : String(e)));
controller.abort();
const settled = await Promise.race([
outcome,
new Promise(r => setTimeout(() => r("timed-out"), 100)),
]);
expect(settled).toBe("AbortError");
// The wait was cancelled but the child was never aborted or consumed.
expect(record.status).toBe("running");
settleChild?.();
});
it("still rejects unknown types when the project configures a fallback", async () => {
// The contract nested delegation documents is "rejected rather than falling
// back" — a top-level fallback must not hand a nested caller an agent its
// allowlist never named.
setFallbackSubagent("scout");
try {
const [agent] = tools(["scout"]);
const result = await execute(agent, {
subagent_type: "definitely-missing",
description: "typo",
prompt: "Do work",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown or disabled nested agent type");
expect(spawnAndWait).not.toHaveBeenCalled();
} finally {
setFallbackSubagent(undefined);
}
});
it("hands the branch cap down to the child it spawns", async () => {
const [agent] = tools("all", 1, 3);
const result = await execute(agent, {
subagent_type: "scout",
description: "child",
prompt: "Do work",
});
expect(result.isError).toBe(false);
expect(spawnAndWait).toHaveBeenCalledWith(
expect.anything(), expect.anything(), "scout", "Do work",
expect.objectContaining({ depth: 2, maxSubagentDepth: 3 }),
expect.any(Function),
);
});
it("flags a truncated child run instead of passing partial output off as complete", async () => {
spawnAndWait.mockImplementation(async () => ({
id: "child-1",
record: { id: "child-1", status: "steered", result: "half an answer", parentAgentId: "parent-1" },
}));
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "scout",
description: "truncated",
prompt: "Do work",
});
expect(result.isError).toBe(false);
// Foreground: the whole output is inline and no id came back, so the note
// must not invite a get_subagent_result call the parent cannot make (#174).
expect(result.content[0].text).toContain("everything the agent produced is above");
expect(result.content[0].text).not.toContain("output is partial");
// The warning leads, so it can't read as part of the child's own answer.
expect(result.content[0].text.indexOf("half an answer")).toBeGreaterThan(0);
});
it("uses the fetchable wording when the parent polls a background child by id", async () => {
records.set("child-1", {
id: "child-1", status: "aborted", result: "partial work", parentAgentId: "parent-1",
});
const [, getResult] = tools();
const result = await execute(getResult, { agent_id: "child-1" });
// Here the parent does hold a valid id, so the background wording applies.
expect(result.content[0].text).toContain("output may be incomplete");
expect(result.content[0].text).not.toContain("everything the agent produced is above");
});
it("keeps a failed child's partial output alongside the error", async () => {
spawnAndWait.mockImplementation(async () => ({
id: "child-1",
record: {
id: "child-1", status: "error", error: "provider exploded",
result: "got this far", parentAgentId: "parent-1",
},
}));
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "scout",
description: "failing",
prompt: "Do work",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("provider exploded");
expect(result.content[0].text).toContain("got this far");
});
it("attributes a nested child's token spend to the owning parent", async () => {
const parent = { id: "parent-1", status: "running", lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 } };
records.set("parent-1", parent);
spawn.mockImplementation((_pi, _ctx, _type, _prompt, options) => {
options.onAssistantUsage?.({ input: 100, output: 20, cacheWrite: 5 });
return "child-1";
});
const [agent] = tools();
await execute(agent, {
subagent_type: "scout",
description: "spender",
prompt: "Do work",
run_in_background: true,
});
expect(parent.lifetimeUsage).toEqual({ input: 100, output: 20, cacheWrite: 5 });
});
it("attributes spend up the whole ancestor chain, not just one level", async () => {
// A spawn callback fires only for that child's own turns, so a deeper
// descendant would otherwise never reach the one record anyone can see.
const top = { id: "top", status: "running", lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 } };
const middle = {
id: "parent-1", status: "running", parentAgentId: "top",
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
};
records.set("top", top);
records.set("parent-1", middle);
spawn.mockImplementation((_pi, _ctx, _type, _prompt, options) => {
options.onAssistantUsage?.({ input: 7, output: 3, cacheWrite: 1 });
return "child-1";
});
const [agent] = tools();
await execute(agent, {
subagent_type: "scout",
description: "deep spender",
prompt: "Do work",
run_in_background: true,
});
expect(middle.lifetimeUsage).toEqual({ input: 7, output: 3, cacheWrite: 1 });
expect(top.lifetimeUsage).toEqual({ input: 7, output: 3, cacheWrite: 1 });
});
it("files a nested transcript under the root session, honoring output_transcript", async () => {
records.set("parent-1", { id: "parent-1", status: "running", rootSessionId: "root-session" });
spawnAndWait.mockImplementation(async (_pi, _ctx, type, _prompt, options, onSpawned) => {
const record = { id: "child-1", type, status: "completed", result: "done", parentAgentId: options.parentAgentId };
records.set("child-1", record);
onSpawned?.("child-1");
return { id: "child-1", record };
});
// Real path construction (not mocked here), so clean up what it writes.
const transcriptRoot = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`, encodeCwd(cwd));
try {
const [agent] = tools();
await execute(agent, { subagent_type: "scout", description: "traced", prompt: "Do work" });
expect(records.get("child-1").outputFile).toContain(join("root-session", "tasks", "child-1.output"));
// The child's own frontmatter still wins.
writeAgent("quiet", "output_transcript: false\n");
registerAgents(loadCustomAgents(cwd));
records.delete("child-1");
await execute(agent, { subagent_type: "quiet", description: "untraced", prompt: "Do work" });
expect(records.get("child-1").outputFile).toBeUndefined();
} finally {
rmSync(transcriptRoot, { recursive: true, force: true });
}
});
it("forwards the execution context to the manager unmodified", async () => {
// Each AgentSession builds its own ExtensionRunner, so the ctx handed to
// execute is the CHILD's — capturing one at tool-build time instead would
// silently misroute the grandchild's cwd, conversation, and model.
const [agent] = tools();
const executionCtx = ctx();
await agent.execute("call-1", {
subagent_type: "scout",
description: "ctx check",
prompt: "Do work",
} as any, undefined, undefined, executionCtx);
expect(spawnAndWait.mock.calls[0][1]).toBe(executionCtx);
});
});
// setMaxSubagentDepth clamps its input, but test/settings.test.ts only asserts
// the applier SPY was called — the real Math.max(0, Math.floor(n)) never runs
// there. A hand-edited subagents.json reaches this unfiltered, and losing the
// clamp is silent in the worst direction: a negative depth makes the
// `depth >= maxSubagentDepth` check true everywhere, disabling nested
// delegation project-wide with no error.
describe("setMaxSubagentDepth clamping", () => {
let previous: number;
beforeEach(() => { previous = getMaxSubagentDepth(); });
afterEach(() => { setMaxSubagentDepth(previous); });
it("floors a negative depth at 0 rather than storing it", () => {
setMaxSubagentDepth(-1);
expect(getMaxSubagentDepth()).toBe(0);
});
it("truncates a fractional depth toward zero", () => {
setMaxSubagentDepth(2.9);
expect(getMaxSubagentDepth()).toBe(2);
});
it("stores a valid depth unchanged", () => {
setMaxSubagentDepth(3);
expect(getMaxSubagentDepth()).toBe(3);
});
});
@@ -0,0 +1,146 @@
/**
* output-file-compaction-e2e.test.ts — regression for issue #145: output-file
* streaming must survive session compaction.
*
* Uses a REAL pi AgentSession (faux model backend, in-memory session manager)
* and drives a REAL session.compact() — no mocked compaction semantics, so
* this breaks if pi changes how compaction rebuilds session.messages.
*/
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai";
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { streamToOutputFile, writeInitialEntry } from "../src/output-file.js";
import { fauxModelBackend } from "./helpers/faux-model-backend.js";
import { registerFauxProvider } from "./helpers/pi-ai.js";
const TURNS_BEFORE_COMPACT = 6;
describe("output-file streaming across a real compaction (#145)", () => {
let tmp: string;
let prevAgentDir: string | undefined;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "issue-145-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = tmp; // hermetic: no dev-env extensions/themes
});
afterEach(() => {
if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
rmSync(tmp, { recursive: true, force: true });
});
it("keeps writing post-compaction messages to the output file", async () => {
const cwd = tmp;
const faux = registerFauxProvider({
provider: "faux",
models: [{ id: "faux-1", contextWindow: 200_000 }],
});
const model = faux.getModel();
const backend = fauxModelBackend(model);
// Context-branching responder: compaction issues a variable number of
// model calls (summary, plus a turn-prefix summary when the cut point
// splits a turn), so a fixed FIFO would desync. Decide from the request.
const respond = (context: { messages: Array<{ role: string; content: unknown }> }) => {
const last = context.messages[context.messages.length - 1];
const text = typeof last?.content === "string"
? last.content
: JSON.stringify(last?.content ?? "");
// Compaction requests first — their instruction embeds the transcript,
// so the "question N" branch below would otherwise match inside it.
// Markers are pi's own prompt texts (SUMMARIZATION_PROMPT and
// TURN_PREFIX_SUMMARIZATION_PROMPT in core/compaction).
if (text.includes("conversation to summarize") || text.includes("PREFIX of a turn")) {
return fauxAssistantMessage([fauxText("summary of everything so far")]);
}
if (text.includes("final question")) {
return fauxAssistantMessage([fauxText("POST-COMPACTION-ANSWER")]);
}
const q = text.match(/question (\d+)/);
if (last?.role === "user" && q) {
return fauxAssistantMessage([fauxText(`answer-${q[1]} ${"filler ".repeat(200)}`)]);
}
throw new Error(`unexpected faux request: ${text.slice(0, 120)}`);
};
faux.setResponses(Array.from({ length: 16 }, () => respond));
const agentDir = getAgentDir();
const loader = new DefaultResourceLoader({
cwd,
agentDir,
systemPromptOverride: () => "You are a test agent.",
appendSystemPromptOverride: () => [],
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await loader.reload();
const { session } = await createAgentSession({
cwd,
agentDir,
model,
// Registry for pre-0.80.8 Pi, runtime for post — each ignores the other.
modelRegistry: backend.modelRegistry as never,
modelRuntime: backend.modelRuntime as never,
resourceLoader: loader,
sessionManager: SessionManager.inMemory(cwd),
settingsManager: SettingsManager.inMemory({
// Auto-compaction off (deterministic manual compact() below); a small
// keep-window so the bulky early turns actually get summarized away.
compaction: { enabled: false, keepRecentTokens: 500 },
retry: { enabled: false },
}),
});
const outPath = join(tmp, "agent.output");
writeInitialEntry(outPath, "agent-145", "repro", cwd);
const cleanup = streamToOutputFile(session as never, outPath, "agent-145", cwd);
const readEntries = () =>
readFileSync(outPath, "utf-8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>);
// Build up history, streaming as we go (turn_end fires per prompt).
for (let i = 0; i < TURNS_BEFORE_COMPACT; i++) {
await session.prompt(`question ${i}`);
}
const writtenBeforeCompact = readEntries().length;
expect(writtenBeforeCompact).toBeGreaterThan(TURNS_BEFORE_COMPACT); // sanity: streaming worked pre-compaction
const lenBefore = session.messages.length;
const result = await session.compact();
expect(result?.summary).toContain("summary"); // sanity: real compaction ran
// Sanity: compaction actually shrank the array — the exact condition that
// used to strand writtenCount past the end and halt streaming (#145).
expect(session.messages.length).toBeLessThan(lenBefore);
// The run continues after compaction...
await session.prompt("final question");
cleanup();
// The post-compaction turn reaches the output file...
const entries = readEntries();
const all = JSON.stringify(entries);
expect(all).toContain("final question");
expect(all).toContain("POST-COMPACTION-ANSWER");
// ...and re-anchoring didn't double-write the compaction-kept tail: every
// pre-compaction answer still appears exactly once.
for (let i = 0; i < TURNS_BEFORE_COMPACT; i++) {
expect(all.split(`answer-${i} `).length - 1).toBe(1);
}
expect(entries.length).toBe(writtenBeforeCompact + 2); // + final question + its answer
}, 30_000);
});
@@ -0,0 +1,86 @@
// README:142 promises the transcript root is owner-only `0700`. Transcripts
// hold the agent's full conversation — user prompts, file contents, tool output —
// in a shared temp dir, so that mode is the only thing keeping them from every
// other local user. `createOutputFilePath` is mocked in the one wiring test that
// touches it, so this body had never actually executed under test.
//
// This lives in its own file because it must mock `node:os`. The real root is
// `<os-tmpdir>/pi-subagents-<uid>` — one path shared by every worker AND by the
// e2e suites that spawn real agents. Mutating it directly makes the test race
// against anything else writing a transcript (observed: passes alone, fails
// intermittently in the full run). Redirecting `tmpdir()` gives each run its own
// root and removes the shared state entirely.
import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync } from "node:fs";
import { tmpdir as realTmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const fakeTmp = vi.hoisted(() => ({ dir: "" }));
vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return { ...actual, tmpdir: () => fakeTmp.dir || actual.tmpdir() };
});
import { createOutputFilePath } from "../src/output-file.js";
const UID = process.getuid?.() ?? 0;
const AGENT = "agent-xyz";
const SESSION = "session-123";
describe("createOutputFilePath", () => {
let root: string;
beforeEach(() => {
// realpath: macOS resolves /var → /private/var, and the module joins the
// raw tmpdir() value, so comparisons must use the same form.
fakeTmp.dir = realpathSync(mkdtempSync(join(realTmpdir(), "pi-outpath-")));
root = join(fakeTmp.dir, `pi-subagents-${UID}`);
});
afterEach(() => {
rmSync(fakeTmp.dir, { recursive: true, force: true });
fakeTmp.dir = "";
});
it("builds the documented layout: <root>/<encoded-cwd>/<session>/tasks/<agent>.output", () => {
const path = createOutputFilePath("/home/user/project", AGENT, SESSION);
expect(path).toBe(join(root, "home-user-project", SESSION, "tasks", `${AGENT}.output`));
});
it("creates the directory chain so the first write cannot fail", () => {
const path = createOutputFilePath("/home/user/project", AGENT, SESSION);
expect(existsSync(join(path, ".."))).toBe(true);
expect(statSync(join(path, "..")).isDirectory()).toBe(true);
});
it("keeps distinct cwds in separate subdirectories under the shared root", () => {
const a = createOutputFilePath("/home/user/project", AGENT, SESSION);
const b = createOutputFilePath("/home/user/other", "agent-2", SESSION);
expect(a).toContain("home-user-project");
expect(b).toContain("home-user-other");
expect(a).not.toBe(b);
});
it.skipIf(process.platform === "win32")("creates the root owner-only", () => {
createOutputFilePath("/home/user/project", AGENT, SESSION);
expect(statSync(root).mode & 0o777).toBe(0o700);
});
it.skipIf(process.platform === "win32")("re-tightens a pre-existing world-readable root", () => {
// The case the explicit chmod exists for. `mkdirSync(recursive: true)` does
// NOT alter an existing directory's mode, so a root left behind by an older
// version — or created by anything else under a permissive umask — would
// keep its wide permissions and every transcript written into it would be
// readable by every local user. (mkdir's own `mode: 0o700` cannot cover
// this: umask only clears bits, so a fresh mkdir is never too permissive.)
mkdirSync(root, { recursive: true, mode: 0o755 });
chmodSync(root, 0o755); // defeat umask so the premise really holds
expect(statSync(root).mode & 0o777).toBe(0o755);
createOutputFilePath("/home/user/project", AGENT, SESSION);
expect(statSync(root).mode & 0o777).toBe(0o700);
});
});
+296
View File
@@ -0,0 +1,296 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { encodeCwd, streamToOutputFile, writeInitialEntry } from "../src/output-file.js";
describe("encodeCwd", () => {
it("encodes a POSIX absolute path by stripping the leading slash and replacing separators", () => {
expect(encodeCwd("/home/user/project")).toBe("home-user-project");
});
it("handles a POSIX root path", () => {
expect(encodeCwd("/")).toBe("");
});
it("encodes a Windows drive-letter path by stripping the drive prefix", () => {
expect(encodeCwd("C:\\Users\\foo\\project")).toBe("Users-foo-project");
});
it("handles lowercase Windows drives", () => {
expect(encodeCwd("c:\\foo")).toBe("foo");
});
it("handles a Windows path written with forward slashes", () => {
expect(encodeCwd("C:/Users/foo/project")).toBe("Users-foo-project");
});
it("preserves server and share for UNC paths", () => {
expect(encodeCwd("\\\\server\\share\\project")).toBe("server-share-project");
});
it("handles mixed separators", () => {
expect(encodeCwd("/home\\user/project")).toBe("home-user-project");
});
it("collapses runs of leading dashes after separator replacement", () => {
expect(encodeCwd("///foo")).toBe("foo");
});
it("returns an empty string for an empty cwd", () => {
expect(encodeCwd("")).toBe("");
});
it("leaves a relative-looking path with no leading separator alone", () => {
expect(encodeCwd("foo/bar")).toBe("foo-bar");
});
});
/**
* Minimal AgentSession fake. streamToOutputFile only reads `session.messages`
* and calls `session.subscribe(cb)`, so we provide just those — plus test-only
* helpers to mutate state and fire events deterministically.
*/
function makeFakeSession(initialMessages: unknown[] = []) {
let messages: unknown[] = [...initialMessages];
let cb: ((event: unknown) => void) | null = null;
return {
get messages() {
return messages;
},
subscribe(fn: (event: unknown) => void) {
cb = fn;
return () => {
cb = null;
};
},
push(...msgs: unknown[]) {
messages.push(...msgs);
},
/** Swap the whole array, like pi's compaction (`agent.state.messages = ...`). */
replaceAll(msgs: unknown[]) {
messages = msgs;
},
fire(event: unknown) {
cb?.(event);
},
isSubscribed() {
return cb !== null;
},
};
}
/** Drain the microtask queue (the compaction re-anchor is deferred one tick). */
const microtask = () => Promise.resolve();
describe("streamToOutputFile", () => {
let tmp: string;
let outPath: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "stream-out-test-"));
outPath = join(tmp, "agent.output");
writeInitialEntry(outPath, "agent-1", "do the thing", "/work");
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
function readEntries(): Array<Record<string, unknown>> {
return readFileSync(outPath, "utf-8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
}
it("writes nothing past the initial entry until turn_end fires", () => {
const session = makeFakeSession([{ role: "user", content: "do the thing" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
session.push({ role: "assistant", content: [{ type: "text", text: "ok" }] });
expect(readEntries()).toHaveLength(1); // only the initial entry
session.fire({ type: "turn_end" });
expect(readEntries()).toHaveLength(2);
});
it("tags assistant, user, and tool messages with the correct type field", () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
session.push(
{ role: "assistant", content: [{ type: "text", text: "thinking" }] },
{ role: "user", content: "follow-up" },
{ role: "tool", content: [{ type: "tool_result", content: "x" }] },
);
session.fire({ type: "turn_end" });
const entries = readEntries();
expect(entries.map((e) => e.type)).toEqual(["user", "assistant", "user", "toolResult"]);
expect(entries.every((e) => e.agentId === "agent-1" && e.isSidechain === true)).toBe(true);
expect(entries.every((e) => e.cwd === "/work")).toBe(true);
});
it("never re-emits messages already flushed on a previous turn_end", () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
session.push({ role: "assistant", content: [{ type: "text", text: "one" }] });
session.fire({ type: "turn_end" });
session.push({ role: "assistant", content: [{ type: "text", text: "two" }] });
session.fire({ type: "turn_end" });
// Fire a redundant turn_end with no new messages — must not duplicate
session.fire({ type: "turn_end" });
expect(readEntries()).toHaveLength(3);
});
it("ignores session events other than turn_end", () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
session.push({ role: "assistant", content: [{ type: "text", text: "x" }] });
session.fire({ type: "message_start" });
session.fire({ type: "tool_call" });
session.fire({ type: "message_end" });
expect(readEntries()).toHaveLength(1);
});
// ---- Compaction (#145): pi replaces session.messages with a shorter,
// summarized array; streaming must survive it. Event sequences below mirror
// pi's real order of operations (verified against agent-session 0.80.6).
it("resumes streaming after compaction shrinks the message array (#145)", async () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
session.push(
{ role: "assistant", content: [{ type: "text", text: "one" }] },
{ role: "user", content: "q2" },
{ role: "assistant", content: [{ type: "text", text: "two" }] },
);
session.fire({ type: "turn_end" }); // 4 messages flushed
// Compaction: summary + kept tail, much shorter than what was written.
session.fire({ type: "compaction_start", reason: "manual" });
session.replaceAll([
{ role: "user", content: "summary of earlier turns" },
{ role: "assistant", content: [{ type: "text", text: "two" }] },
]);
session.fire({ type: "compaction_end", reason: "manual", aborted: false, result: { summary: "s" } });
await microtask();
session.push({ role: "assistant", content: [{ type: "text", text: "AFTER" }] });
session.fire({ type: "turn_end" });
const entries = readEntries();
// initial + one + q2 + two + AFTER — the kept tail is NOT re-written.
expect(entries).toHaveLength(5);
expect(JSON.stringify(entries.at(-1))).toContain("AFTER");
});
it("flushes the not-yet-written tail before compaction discards it (#145)", () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
// A message lands with no turn_end yet (e.g. overflow mid-turn)...
session.push({ role: "assistant", content: [{ type: "text", text: "tail-before-compact" }] });
expect(readEntries()).toHaveLength(1);
// ...then compaction starts: the tail must reach the file before the array is replaced.
session.fire({ type: "compaction_start", reason: "overflow" });
expect(JSON.stringify(readEntries().at(-1))).toContain("tail-before-compact");
});
it("re-anchors after the overflow-retry trim, not at compaction_end (#145)", async () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
session.push({ role: "assistant", content: [{ type: "text", text: "big" }] });
session.fire({ type: "turn_end" });
// pi's overflow-retry order: compaction_end fires, THEN the trailing error
// assistant message is sliced off. A synchronous anchor would sit one past
// the trimmed array and skip the first post-compaction message.
session.fire({ type: "compaction_start", reason: "overflow" });
session.replaceAll([
{ role: "user", content: "summary" },
{ role: "assistant", content: [{ type: "text", text: "err" }], stopReason: "error" },
]);
session.fire({ type: "compaction_end", reason: "overflow", aborted: false, result: { summary: "s" }, willRetry: true });
session.replaceAll([{ role: "user", content: "summary" }]); // the post-emit trim
await microtask();
session.push({ role: "assistant", content: [{ type: "text", text: "RETRY-ANSWER" }] });
session.fire({ type: "turn_end" });
expect(JSON.stringify(readEntries().at(-1))).toContain("RETRY-ANSWER");
});
it("does not re-anchor on aborted or failed compaction (#145)", async () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
streamToOutputFile(session as never, outPath, "agent-1", "/work");
session.fire({ type: "compaction_start", reason: "manual" });
// Aborted/failed: session.messages is left untouched by pi.
session.fire({ type: "compaction_end", reason: "manual", aborted: true, result: undefined });
session.fire({ type: "compaction_end", reason: "manual", aborted: false, result: undefined, errorMessage: "boom" });
await microtask();
session.push({ role: "assistant", content: [{ type: "text", text: "still-streaming" }] });
session.fire({ type: "turn_end" });
const entries = readEntries();
expect(entries).toHaveLength(2); // nothing skipped, nothing duplicated
expect(JSON.stringify(entries.at(-1))).toContain("still-streaming");
});
it("cleanup() does a final flush and detaches the subscription", () => {
const session = makeFakeSession([{ role: "user", content: "go" }]);
const cleanup = streamToOutputFile(session as never, outPath, "agent-1", "/work");
// Trailing message arrives with no turn_end before shutdown
session.push({ role: "assistant", content: [{ type: "text", text: "tail" }] });
expect(readEntries()).toHaveLength(1);
cleanup();
expect(readEntries()).toHaveLength(2);
expect(session.isSubscribed()).toBe(false);
// Post-cleanup messages must not be written, even if events would otherwise fire
session.push({ role: "assistant", content: [{ type: "text", text: "ghost" }] });
session.fire({ type: "turn_end" });
expect(readEntries()).toHaveLength(2);
});
// A background resume reuses the agent's existing transcript and its existing
// session, which already holds every prior turn. Anchoring at the default 1
// would re-emit that whole history into the file behind the new run's turns.
it("starts at the given index so a resume appends only its own turns", () => {
const prior = [
{ role: "user", content: "do the thing" },
{ role: "assistant", content: [{ type: "text", text: "first answer" }] },
{ role: "user", content: "and again" },
{ role: "assistant", content: [{ type: "text", text: "second answer" }] },
];
const session = makeFakeSession(prior);
streamToOutputFile(session as never, outPath, "agent-1", "/work", session.messages.length);
// The resumed prompt lands as an ordinary user message at the anchor index.
session.push({ role: "user", content: "keep going" });
session.push({ role: "assistant", content: [{ type: "text", text: "third answer" }] });
session.fire({ type: "turn_end" });
const entries = readEntries();
// Initial entry from beforeEach + exactly the two new messages — none of the
// four the session was already carrying.
expect(entries).toHaveLength(3);
const body = JSON.stringify(entries);
expect(body).toContain("keep going");
expect(body).toContain("third answer");
expect(body).not.toContain("first answer");
expect(body).not.toContain("second answer");
});
});
@@ -0,0 +1,195 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
// Only the filesystem side is faked; the transcript default is real module
// state (both spawn paths read it there), so it must be reset between tests.
vi.mock("../src/output-file.js", async () => {
const actual = await vi.importActual<typeof import("../src/output-file.js")>("../src/output-file.js");
return {
...actual,
createOutputFilePath: vi.fn(() => "/tmp/fake-subagent.output"),
writeInitialEntry: vi.fn(),
streamToOutputFile: vi.fn(() => vi.fn()),
};
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
import { createOutputFilePath, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "../src/output-file.js";
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const events = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((tool: any) => tools.set(tool.name, tool)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn(),
on: vi.fn((event: string, handler: any) => {
events.set(event, handler);
return vi.fn();
}),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
function makeCtx(cwd: string) {
return {
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
cwd,
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "session-1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
describe("output_transcript agent wiring", () => {
let cwd: string;
let agentDir: string;
let previousCwd: string;
let previousAgentDir: string | undefined;
let previousHome: string | undefined;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "pi-output-transcript-cwd-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-output-transcript-agent-"));
previousCwd = process.cwd();
previousAgentDir = process.env.PI_CODING_AGENT_DIR;
previousHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
mkdirSync(join(cwd, ".pi"), { recursive: true });
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false }));
mkdirSync(join(agentDir, "agents"), { recursive: true });
process.chdir(cwd);
vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, options) => {
await Promise.resolve();
const session = { messages: [], subscribe: vi.fn(() => vi.fn()), dispose: vi.fn() } as any;
options.onSessionCreated?.(session);
return { responseText: "done", session, aborted: false, steered: false };
});
});
afterEach(() => {
process.chdir(previousCwd);
if (previousAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
if (previousHome == null) delete process.env.HOME;
else process.env.HOME = previousHome;
rmSync(cwd, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
setOutputTranscriptDefault(true);
vi.clearAllMocks();
});
it("creates no transcript when a custom agent sets output_transcript false", async () => {
writeFileSync(join(agentDir, "agents", "sensitive.md"), `---\ndescription: Sensitive in-memory agent\noutput_transcript: false\n---\n\nKeep data in memory.`);
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
await tools.get("Agent").execute(
"tool-call",
{ prompt: "process sensitive data", description: "Process sensitive data", subagent_type: "sensitive" },
undefined,
undefined,
makeCtx(cwd),
);
expect(createOutputFilePath).not.toHaveBeenCalled();
expect(writeInitialEntry).not.toHaveBeenCalled();
expect(streamToOutputFile).not.toHaveBeenCalled();
await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd));
});
it("also suppresses the background transcript", async () => {
writeFileSync(join(agentDir, "agents", "sensitive.md"), `---\ndescription: Sensitive in-memory agent\noutput_transcript: false\nrun_in_background: true\n---\n\nKeep data in memory.`);
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
await tools.get("Agent").execute(
"tool-call",
{ prompt: "process sensitive data", description: "Process sensitive data", subagent_type: "sensitive" },
undefined,
undefined,
makeCtx(cwd),
);
expect(createOutputFilePath).not.toHaveBeenCalled();
expect(writeInitialEntry).not.toHaveBeenCalled();
expect(streamToOutputFile).not.toHaveBeenCalled();
await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd));
});
it("keeps transcript creation as the default", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
await tools.get("Agent").execute(
"tool-call",
{ prompt: "ordinary work", description: "Do ordinary work", subagent_type: "general-purpose" },
undefined,
undefined,
makeCtx(cwd),
);
expect(createOutputFilePath).toHaveBeenCalledOnce();
expect(writeInitialEntry).toHaveBeenCalledOnce();
expect(streamToOutputFile).toHaveBeenCalledOnce();
await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd));
});
it("suppresses the transcript project-wide when subagents.json sets outputTranscript false", async () => {
// A plain default agent (no frontmatter) inherits the project default.
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false, outputTranscript: false }));
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
await tools.get("Agent").execute(
"tool-call",
{ prompt: "ordinary work", description: "Do ordinary work", subagent_type: "general-purpose" },
undefined,
undefined,
makeCtx(cwd),
);
expect(createOutputFilePath).not.toHaveBeenCalled();
expect(writeInitialEntry).not.toHaveBeenCalled();
expect(streamToOutputFile).not.toHaveBeenCalled();
await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd));
});
it("lets agent frontmatter output_transcript true override a project outputTranscript false", async () => {
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false, outputTranscript: false }));
writeFileSync(join(agentDir, "agents", "audited.md"), `---\ndescription: Always keeps a transcript\noutput_transcript: true\n---\n\nWrite a transcript regardless of the project default.`);
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
await tools.get("Agent").execute(
"tool-call",
{ prompt: "audited work", description: "Do audited work", subagent_type: "audited" },
undefined,
undefined,
makeCtx(cwd),
);
expect(createOutputFilePath).toHaveBeenCalledOnce();
expect(writeInitialEntry).toHaveBeenCalledOnce();
expect(streamToOutputFile).toHaveBeenCalledOnce();
await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd));
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import {
PERMISSION_CHILD_CREATED_CHANNEL,
PERMISSION_CHILD_DISPOSED_CHANNEL,
registerPermissionChildSession,
unregisterPermissionChildSession,
withActiveAgentIdentity,
} from "../src/permission-system-bridge.js";
function session(id: string | undefined) {
return { sessionManager: { getSessionId: () => id } } as any;
}
function publisher() {
return { events: { emit: vi.fn() } } as any;
}
describe("permission-system child lifecycle bridge", () => {
it("registers the child against the interactive root and disposes it once", () => {
const pi = publisher();
const child = session(" child-session ");
expect(registerPermissionChildSession(pi, child, " root-session ")).toBe(true);
expect(registerPermissionChildSession(pi, child, "root-session")).toBe(true);
expect(pi.events.emit).toHaveBeenCalledTimes(1);
expect(pi.events.emit).toHaveBeenNthCalledWith(1, PERMISSION_CHILD_CREATED_CHANNEL, {
sessionId: "child-session",
parentSessionId: "root-session",
});
unregisterPermissionChildSession(child);
unregisterPermissionChildSession(child);
expect(pi.events.emit).toHaveBeenCalledTimes(2);
expect(pi.events.emit).toHaveBeenNthCalledWith(2, PERMISSION_CHILD_DISPOSED_CHANNEL, {
sessionId: "child-session",
});
});
it("does not claim integration without both session identities and an event bus", () => {
expect(registerPermissionChildSession({} as any, session("child"), "root")).toBe(false);
expect(registerPermissionChildSession(publisher(), session(undefined), "root")).toBe(false);
expect(registerPermissionChildSession(publisher(), session("child"), " ")).toBe(false);
});
it("fails child startup when a lifecycle subscriber rejects registration", () => {
const pi = { events: { emit: vi.fn(() => { throw new Error("registry unavailable"); }) } } as any;
expect(() => registerPermissionChildSession(pi, session("child"), "root"))
.toThrow("registry unavailable");
});
it("replaces inherited identity with the stable child agent key", () => {
expect(withActiveAgentIdentity(
'parent\n<active_agent name="main-agent"/>\nchild instructions',
"security-auditor",
)).toBe('parent\nchild instructions\n\n<active_agent name="security-auditor"/>');
});
it("omits an unsafe agent key instead of emitting malformed identity markup", () => {
expect(withActiveAgentIdentity("prompt", 'bad" name')).toBe("prompt");
});
});
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return {
...actual,
runAgent: vi.fn(),
};
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
function makePi() {
const tools = new Map<string, any>();
const handlers = new Map<string, any>();
const eventHandlers = new Map<string, any>();
return {
pi: {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((tool: any) => {
tools.set(tool.name, tool);
}),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => {
handlers.set(event, handler);
}),
events: {
emit: vi.fn(),
on: vi.fn((event: string, handler: any) => {
eventHandlers.set(event, handler);
return vi.fn();
}),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(() => {
throw new Error("stale extension context");
}),
} as any,
tools,
handlers,
};
}
function makeHeadlessCtx() {
return {
hasUI: false,
ui: {
setStatus: vi.fn(),
setWidget: vi.fn(),
},
cwd: "/tmp",
model: undefined,
modelRegistry: {
find: vi.fn(),
getAvailable: vi.fn(() => []),
},
sessionManager: {
getSessionId: vi.fn(() => "session-1"),
getBranch: vi.fn(() => []),
},
getSystemPrompt: vi.fn(() => "parent prompt"),
} as any;
}
describe("print mode background notifications", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("ignores stale-context errors from delayed completion nudges", async () => {
vi.mocked(runAgent).mockResolvedValue({
responseText: "done",
session: { dispose: vi.fn() } as any,
aborted: false,
steered: false,
});
const { pi, tools, handlers } = makePi();
subagentsExtension(pi);
vi.useFakeTimers();
const agentTool = tools.get("Agent");
await agentTool.execute(
"tool-call-1",
{
prompt: "reply done",
description: "tiny child",
subagent_type: "general-purpose",
run_in_background: true,
},
undefined,
undefined,
makeHeadlessCtx(),
);
await vi.advanceTimersByTimeAsync(100); // smart-join batch debounce
await vi.advanceTimersByTimeAsync(200); // notification hold window
expect(pi.sendMessage).toHaveBeenCalled();
await handlers.get("session_shutdown")?.({}, makeHeadlessCtx());
});
});
+436
View File
@@ -0,0 +1,436 @@
import { beforeEach, describe, expect, it } from "vitest";
import { getAgentConfig, registerAgents } from "../src/agent-types.js";
import { buildAgentPrompt } from "../src/prompts.js";
import type { AgentConfig, EnvInfo } from "../src/types.js";
const env: EnvInfo = {
isGitRepo: true,
branch: "main",
platform: "darwin",
};
const envNoGit: EnvInfo = {
isGitRepo: false,
branch: "",
platform: "linux",
};
// Initialize default agents
beforeEach(() => {
registerAgents(new Map());
});
function getDefaultConfig(name: string): AgentConfig {
return getAgentConfig(name)!;
}
describe("buildAgentPrompt", () => {
it("includes cwd and git info", () => {
const config = getDefaultConfig("general-purpose");
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain("/workspace");
expect(prompt).toContain("Branch: main");
expect(prompt).toContain("darwin");
});
it("handles non-git repos", () => {
const config = getDefaultConfig("Explore");
const prompt = buildAgentPrompt(config, "/workspace", envNoGit);
expect(prompt).toContain("Not a git repository");
expect(prompt).not.toContain("Branch:");
});
it("Explore prompt is read-only", () => {
const config = getDefaultConfig("Explore");
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain("READ-ONLY");
expect(prompt).toContain("file search specialist");
});
it("Plan prompt is read-only", () => {
const config = getDefaultConfig("Plan");
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain("READ-ONLY");
expect(prompt).toContain("software architect");
});
it("general-purpose uses append mode (parent twin)", () => {
const config = getDefaultConfig("general-purpose");
const parentPrompt = "You are a parent coding agent with full powers.";
const prompt = buildAgentPrompt(config, "/workspace", env, parentPrompt);
expect(prompt).toContain("parent coding agent with full powers");
expect(prompt).toContain("<sub_agent_context>");
expect(prompt).not.toContain("<inherited_system_prompt>");
expect(prompt).not.toContain("READ-ONLY");
// Empty systemPrompt means no <agent_instructions> section
expect(prompt).not.toContain("<agent_instructions>");
});
it("general-purpose without parent prompt falls back to generic base", () => {
const config = getDefaultConfig("general-purpose");
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain("general-purpose coding agent");
expect(prompt).not.toContain("READ-ONLY");
});
it("append mode with parent prompt includes parent + custom instructions", () => {
const config: AgentConfig = {
name: "appender",
description: "Appender",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Extra custom instructions here.",
promptMode: "append",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const parentPrompt = "You are a parent coding agent with special powers.";
const prompt = buildAgentPrompt(config, "/workspace", env, parentPrompt);
expect(prompt).toContain("/workspace");
expect(prompt).toContain("parent coding agent with special powers");
expect(prompt).toContain("<sub_agent_context>");
expect(prompt).not.toContain("<inherited_system_prompt>");
expect(prompt).toContain("<agent_instructions>");
expect(prompt).toContain("Extra custom instructions here.");
});
it("append mode without parent prompt falls back to generic base", () => {
const config: AgentConfig = {
name: "appender",
description: "Appender",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Extra custom instructions here.",
promptMode: "append",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain("/workspace");
expect(prompt).toContain("general-purpose coding agent");
expect(prompt).toContain("Extra custom instructions here.");
});
it("append mode with empty systemPrompt is a pure parent clone", () => {
const config: AgentConfig = {
name: "clone",
description: "Clone",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "",
promptMode: "append",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const parentPrompt = "You are a parent coding agent.";
const prompt = buildAgentPrompt(config, "/workspace", env, parentPrompt);
expect(prompt).toContain("parent coding agent");
expect(prompt).toContain("<sub_agent_context>");
expect(prompt).not.toContain("<inherited_system_prompt>");
expect(prompt).not.toContain("<agent_instructions>");
});
it("replace mode uses config systemPrompt directly", () => {
const config: AgentConfig = {
name: "custom",
description: "Custom",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "You are a specialized agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain("You are a specialized agent.");
expect(prompt).toContain("/workspace");
expect(prompt).toContain("You are a pi coding agent sub-agent");
});
it("replace mode ignores parent prompt", () => {
const config: AgentConfig = {
name: "standalone",
description: "Standalone",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "You are a standalone agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env, "SECRET parent prompt content");
expect(prompt).toContain("You are a standalone agent.");
expect(prompt).not.toContain("SECRET parent prompt content");
expect(prompt).not.toContain("<sub_agent_context>");
});
it("append mode bridge contains tool reminders", () => {
const config = getDefaultConfig("general-purpose");
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt.");
expect(prompt).toContain("Use the read tool instead of cat");
expect(prompt).toContain("Use the edit tool instead of sed");
expect(prompt).toContain("Use the grep tool instead of");
});
it("append mode without parent prompt still has bridge", () => {
const config: AgentConfig = {
name: "no-parent",
description: "No parent",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Extra stuff.",
promptMode: "append",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain("<sub_agent_context>");
expect(prompt).not.toContain("<inherited_system_prompt>");
expect(prompt).toContain("Use the read tool instead of cat");
expect(prompt).toContain("general-purpose coding agent");
expect(prompt).toContain("Extra stuff.");
});
it("injects memory block in replace mode", () => {
const config: AgentConfig = {
name: "mem-agent",
description: "Memory Agent",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "You are a memory agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const extras = { memoryBlock: "# Agent Memory\nYou have persistent memory at /tmp/mem/" };
const prompt = buildAgentPrompt(config, "/workspace", env, undefined, extras);
expect(prompt).toContain("You are a memory agent.");
expect(prompt).toContain("Agent Memory");
expect(prompt).toContain("persistent memory");
});
it("injects memory block in append mode", () => {
const config: AgentConfig = {
name: "mem-append",
description: "Memory Append",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Custom instructions.",
promptMode: "append",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const extras = { memoryBlock: "# Agent Memory\nPersistent memory here." };
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt.", extras);
expect(prompt).toContain("<sub_agent_context>");
expect(prompt).toContain("Agent Memory");
expect(prompt).toContain("Custom instructions.");
});
it("injects preloaded skill blocks", () => {
const config: AgentConfig = {
name: "skill-agent",
description: "Skill Agent",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "You are a skill agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const extras = {
skillBlocks: [
{ name: "api-conventions", content: "Use REST endpoints." },
{ name: "error-handling", content: "Handle errors gracefully." },
],
};
const prompt = buildAgentPrompt(config, "/workspace", env, undefined, extras);
expect(prompt).toContain("Preloaded Skill: api-conventions");
expect(prompt).toContain("Use REST endpoints.");
expect(prompt).toContain("Preloaded Skill: error-handling");
expect(prompt).toContain("Handle errors gracefully.");
});
it("injects both memory and skills", () => {
const config: AgentConfig = {
name: "full-agent",
description: "Full Agent",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Full agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const extras = {
memoryBlock: "# Memory\nRemember this.",
skillBlocks: [{ name: "skill1", content: "Skill content." }],
};
const prompt = buildAgentPrompt(config, "/workspace", env, undefined, extras);
expect(prompt).toContain("# Memory");
expect(prompt).toContain("Preloaded Skill: skill1");
});
it("no extras means no extra sections", () => {
const config: AgentConfig = {
name: "plain",
description: "Plain",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Plain agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).not.toContain("Agent Memory");
expect(prompt).not.toContain("Preloaded Skill");
});
describe("active_agent tag", () => {
it("tag is present at start of prompt in replace mode", () => {
const config: AgentConfig = {
name: "my-agent",
description: "Test",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "You are a test agent.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toMatch(/^<active_agent name="my-agent"\/>/);
});
it("tag follows the cacheable inherited prefix in append mode", () => {
const config: AgentConfig = {
name: "my-agent",
description: "Test",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Custom instructions.",
promptMode: "append",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt.");
// Parent prompt must form the verbatim, cacheable byte prefix.
expect(prompt.startsWith("Parent prompt.")).toBe(true);
// The varying tag follows the static <sub_agent_context> bridge.
const ctxIdx = prompt.indexOf("<sub_agent_context>");
const tagIdx = prompt.indexOf('<active_agent name="my-agent"/>');
expect(ctxIdx).toBeGreaterThan(-1);
expect(tagIdx).toBeGreaterThan(ctxIdx);
});
it("tag uses agent name verbatim", () => {
const config: AgentConfig = {
name: "Some Agent With Spaces",
description: "Test",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Test.",
promptMode: "replace",
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env);
expect(prompt).toContain('<active_agent name="Some Agent With Spaces"/>');
});
it("tag appears before the env block in both modes", () => {
for (const promptMode of ["replace", "append"] as const) {
const config: AgentConfig = {
name: "test-agent",
description: "Test",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Test.",
promptMode,
inheritContext: false,
runInBackground: false,
isolated: false,
};
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent.");
const tagIndex = prompt.indexOf('<active_agent name="test-agent"/>');
const envIndex = prompt.indexOf("# Environment");
expect(tagIndex).toBeLessThan(envIndex);
}
});
});
// #187: the inherited parent prompt names the main checkout as cwd, so a
// worktree agent needs to be told which of the two paths is really its own.
describe("worktree isolation block", () => {
function worktreeConfig(promptMode: "append" | "replace"): AgentConfig {
return {
name: "test-agent",
description: "Test",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt: "Custom instructions.",
promptMode,
inheritContext: false,
runInBackground: false,
isolated: false,
};
}
it("is absent without a worktree base", () => {
for (const promptMode of ["replace", "append"] as const) {
const prompt = buildAgentPrompt(worktreeConfig(promptMode), "/wt/copy", env, "Parent.", {});
expect(prompt).not.toContain("<worktree_isolation>");
}
});
it("names the parent checkout and follows the env block in both modes", () => {
for (const promptMode of ["replace", "append"] as const) {
const prompt = buildAgentPrompt(worktreeConfig(promptMode), "/wt/copy", env, "Parent.", {
worktreeBase: "/repo",
});
expect(prompt).toContain("isolated git worktree copy of /repo");
expect(prompt).toContain("never in /repo, even if other instructions name that path");
expect(prompt.indexOf("<worktree_isolation>")).toBeGreaterThan(prompt.indexOf("Working directory: /wt/copy"));
}
});
it("stays out of the cacheable inherited prefix", () => {
const prompt = buildAgentPrompt(worktreeConfig("append"), "/wt/copy", env, "Parent prompt.", {
worktreeBase: "/repo",
});
expect(prompt.startsWith("Parent prompt.")).toBe(true);
expect(prompt.indexOf("<worktree_isolation>")).toBeGreaterThan(prompt.indexOf("<sub_agent_context>"));
});
});
});
@@ -0,0 +1,234 @@
/**
* rpc-lifecycle-gating.test.ts — issue #142.
*
* pi runs every extension factory BEFORE applying an agent's `extensions:`
* filter, and only delivers lifecycle events (session_start, …) to the
* survivors — but the `pi.events` bus is shared with the filtered-out
* activations. The old code registered the RPC handlers and emitted
* `subagents:ready` at factory time, so a child session that excluded
* pi-subagents still saw `subagents:ready` + a working `subagents:rpc:ping`,
* yet every spawn failed with "No active session" (its session_start never
* fired, so currentCtx stayed undefined).
*
* The fix defers BOTH the RPC registration and the readiness broadcast to the
* first bound session_start. These tests drive the real extension factory with
* a mock ExtensionAPI and assert the timing: nothing is wired at factory time;
* everything is wired (once) on session_start.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { runAgent } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
const RPC_CHANNELS = ["subagents:rpc:ping", "subagents:rpc:spawn", "subagents:rpc:stop"] as const;
function makePi() {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>(); // pi.on(...) — session_start, session_shutdown, …
const busHandlers = new Map<string, (raw: any) => unknown>(); // pi.events.on(...) — rpc channels
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn(),
on: vi.fn((event: string, handler: any) => {
busHandlers.set(event, handler);
return vi.fn();
}),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle, busHandlers };
}
function ctx(hasUI = false, setWidget = vi.fn()) {
return {
hasUI,
ui: {
setStatus: vi.fn(),
setWidget,
notify: vi.fn(),
onTerminalInput: vi.fn(() => vi.fn()),
getEditorText: vi.fn(() => ""),
custom: vi.fn(),
},
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
} as any;
}
const readyEmits = (pi: any): unknown[] =>
pi.events.emit.mock.calls.filter((c: any[]) => c[0] === "subagents:ready");
const onCallsFor = (pi: any, channel: string): unknown[] =>
pi.events.on.mock.calls.filter((c: any[]) => c[0] === channel);
describe("issue #142: RPC handlers + subagents:ready are gated on session_start", () => {
let tmpDir: string;
let agentDir: string;
let prevCwd: string;
let prevAgentDir: string | undefined;
let prevHome: string | undefined;
beforeEach(() => {
// Hermetic cwd + global dir with scheduling off, so session_start doesn't
// spin a scheduler or touch the dev's filesystem — isolates the RPC wiring.
tmpDir = mkdtempSync(join(tmpdir(), "pi-142-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-142-agentdir-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
prevHome = process.env.HOME;
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
prevCwd = process.cwd();
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false }));
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(prevCwd);
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(tmpDir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("does NOT advertise or register RPC at factory time (the filtered-out case)", () => {
const { pi, busHandlers } = makePi();
// A filtered-out activation only ever gets the factory run — its
// session_start never fires. So after the factory alone, nothing should
// be on the shared bus.
subagentsExtension(pi);
expect(readyEmits(pi), "no subagents:ready before session_start").toHaveLength(0);
for (const channel of RPC_CHANNELS) {
expect(busHandlers.has(channel), `${channel} must not be registered at factory time`).toBe(false);
}
});
it("advertises and registers RPC on session_start, and spawn works once bound", async () => {
const { pi, lifecycle, busHandlers } = makePi();
subagentsExtension(pi);
await lifecycle.get("session_start")({}, ctx());
// Readiness broadcast once, all three channels now live.
expect(readyEmits(pi), "subagents:ready fires once bound").toHaveLength(1);
for (const channel of RPC_CHANNELS) {
expect(busHandlers.has(channel), `${channel} registered on session_start`).toBe(true);
}
// spawn no longer hits the "No active session" trap — currentCtx is set.
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); // never resolves
const requestId = "req-142";
await busHandlers.get("subagents:rpc:spawn")!({
requestId,
type: "general-purpose",
prompt: "go",
options: { description: "rpc gating test" },
});
const reply = pi.events.emit.mock.calls.find(
(c: any[]) => c[0] === `subagents:rpc:spawn:reply:${requestId}`,
);
expect(reply, "spawn emitted a reply").toBeTruthy();
expect(reply![1].success, `spawn succeeded, got: ${JSON.stringify(reply![1])}`).toBe(true);
expect(reply![1].data.id).toBeTruthy();
});
it("renders an RPC-spawned agent in the native widget while it is running", async () => {
const { pi, lifecycle, busHandlers } = makePi();
const activeCtx = ctx(true);
subagentsExtension(pi);
await lifecycle.get("session_start")({}, activeCtx);
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); // keep agent running
try {
await busHandlers.get("subagents:rpc:spawn")!({
requestId: "req-widget",
type: "general-purpose",
prompt: "go",
options: { description: "visible RPC agent" },
});
await vi.waitFor(() => {
expect(activeCtx.ui.setWidget).toHaveBeenCalledWith(
"agents",
expect.any(Function),
{ placement: "aboveEditor" },
);
expect(activeCtx.ui.setStatus).toHaveBeenCalledWith("subagents", "1 running agent");
});
} finally {
await lifecycle.get("session_shutdown")();
}
});
it("shows live tool activity for an RPC-spawned background agent", async () => {
const { pi, lifecycle, busHandlers } = makePi();
let widgetFactory: any;
const setWidget = vi.fn((key: string, content: any) => {
if (key === "agents" && content) widgetFactory = content;
});
const extensionCtx = ctx(true, setWidget);
let onToolActivity: ((activity: { type: "start" | "end"; toolName: string }) => void) | undefined;
vi.mocked(runAgent).mockImplementation((_ctx, _type, _prompt, options: any) => {
onToolActivity = options.onToolActivity;
options.onSessionCreated?.({ subscribe: () => vi.fn() });
return new Promise(() => {}) as any;
});
subagentsExtension(pi);
await lifecycle.get("session_start")({}, extensionCtx);
// TaskExecute runs inside a root tool call, so the extension already has
// the UI context before pi-tasks sends its cross-extension spawn request.
await lifecycle.get("tool_execution_start")({}, extensionCtx);
await busHandlers.get("subagents:rpc:spawn")!({
requestId: "req-activity",
type: "general-purpose",
prompt: "go",
options: { description: "rpc activity test", isBackground: true },
});
await vi.waitFor(() => expect(onToolActivity).toBeTypeOf("function"));
onToolActivity!({ type: "start", toolName: "bash" });
expect(widgetFactory).toBeTypeOf("function");
const lines = widgetFactory(
{ terminal: { columns: 120 }, requestRender: vi.fn() },
{ fg: (_color: string, text: string) => text, bold: (text: string) => text },
).render().join("\n");
expect(lines).toContain("running command…");
expect(lines).not.toContain("thinking…");
});
it("is idempotent — a second session_start does not re-advertise or double-register", async () => {
const { pi, lifecycle } = makePi();
subagentsExtension(pi);
await lifecycle.get("session_start")({}, ctx());
await lifecycle.get("session_start")({}, ctx());
expect(readyEmits(pi), "subagents:ready emitted exactly once across two session_starts").toHaveLength(1);
for (const channel of RPC_CHANNELS) {
expect(onCallsFor(pi, channel), `${channel} registered exactly once`).toHaveLength(1);
}
});
});
+237
View File
@@ -0,0 +1,237 @@
/**
* schedule-e2e.test.ts — End-to-end integration test for the scheduler.
*
* Unlike `schedule.test.ts` (which uses vi.useFakeTimers), this exercises
* the full real-timer firing path: real `setTimeout` triggers `executeJob`,
* a faithful `AgentManager` mock (promise resolves with the right semantics)
* runs through to `finalize`, and the on-disk `ScheduleStore` reflects the
* outcome. Catches integration bugs that fake-timer microtask scheduling
* can hide.
*
* Uses very short timings (100–300ms) so the test stays fast.
*/
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SubagentScheduler } from "../src/schedule.js";
import { ScheduleStore } from "../src/schedule-store.js";
type FakeRecord = { status: string; promise: Promise<string>; resolve: () => void };
/**
* Faithful AgentManager mock: spawn returns an id, getRecord returns a
* record whose promise can be resolved at test time, status is mutable so
* the test can assert success vs error inference.
*/
function makeFaithfulManager(initialStatus = "completed") {
const records = new Map<string, FakeRecord>();
return {
records,
initialStatus,
spawn: vi.fn(function (this: any) {
const id = "agent-" + Math.random().toString(36).slice(2, 10);
let resolve!: () => void;
const promise = new Promise<string>(r => { resolve = () => r(""); });
records.set(id, { status: initialStatus, promise, resolve });
// Auto-resolve on next tick — mimics a fast-finishing real agent.
queueMicrotask(() => records.get(id)?.resolve());
return id;
}),
getRecord: vi.fn(function (this: any, id: string) {
return records.get(id);
}),
} as any;
}
function makePi() {
return { events: { emit: vi.fn() } } as any;
}
function makeCtx() {
return {
cwd: "/tmp",
modelRegistry: { find: vi.fn(), getAll: () => [], getAvailable: () => [] },
sessionManager: { getSessionId: () => "sess-e2e" },
} as any;
}
/** Wait for a predicate, polling at 5ms intervals, with a deadline. */
async function waitFor(predicate: () => boolean, timeoutMs = 1500): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
}
await new Promise(r => setTimeout(r, 5));
}
}
describe("SubagentScheduler — end-to-end with real timers", () => {
let tmp: string;
let store: ScheduleStore;
let scheduler: SubagentScheduler;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "schedule-e2e-"));
store = new ScheduleStore(join(tmp, "schedules.json"));
scheduler = new SubagentScheduler();
});
afterEach(() => {
scheduler.stop();
rmSync(tmp, { recursive: true, force: true });
});
it("one-shot job: real setTimeout fires, agent runs, store reflects success", async () => {
const manager = makeFaithfulManager("completed");
const pi = makePi();
scheduler.start(pi, makeCtx(), manager, store);
// Fire ~100ms in the future. detectSchedule normalizes "+100ms" — but our
// parser only accepts s/m/h/d, so use a near-future ISO timestamp instead.
const future = new Date(Date.now() + 100).toISOString();
const job = scheduler.addJob({
name: "e2e-once",
description: "test",
schedule: future,
subagent_type: "general-purpose",
prompt: "hello",
});
expect(job.scheduleType).toBe("once");
// Wait for the spawn to occur (real timer fires) and the finalize promise
// chain to settle. Polling, no fake timers.
await waitFor(() => manager.spawn.mock.calls.length === 1);
await waitFor(() => scheduler.list().find(j => j.id === job.id)?.lastStatus === "success");
const final = scheduler.list().find(j => j.id === job.id)!;
expect(final.lastStatus).toBe("success");
expect(final.runCount).toBe(1);
expect(final.enabled).toBe(false); // one-shot auto-disabled
expect(final.lastRun).toBeDefined();
});
it("one-shot job that errors: store records lastStatus error (regression — bug #1)", async () => {
const manager = makeFaithfulManager("error"); // Agent terminates with error status
const pi = makePi();
scheduler.start(pi, makeCtx(), manager, store);
const future = new Date(Date.now() + 100).toISOString();
const job = scheduler.addJob({
name: "e2e-fail",
description: "test",
schedule: future,
subagent_type: "general-purpose",
prompt: "fail",
});
await waitFor(() => manager.spawn.mock.calls.length === 1);
await waitFor(() => scheduler.list().find(j => j.id === job.id)?.lastStatus !== "running");
expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error");
expect(scheduler.list().find(j => j.id === job.id)?.runCount).toBe(1);
});
it("interval job: fires repeatedly, runCount grows", async () => {
const manager = makeFaithfulManager("completed");
const pi = makePi();
scheduler.start(pi, makeCtx(), manager, store);
// 100ms interval — wait for ~3 fires
const job = scheduler.addJob({
name: "e2e-interval",
description: "test",
schedule: "100s", // Will be too long; override below.
subagent_type: "general-purpose",
prompt: "tick",
});
// Replace with a literal 100ms interval — easier than crafting a parseable shorthand for ms.
// (parseInterval doesn't accept "ms"; we patch the persisted job and re-arm.)
scheduler.updateJob(job.id, { intervalMs: 100, schedule: "100ms" });
await waitFor(() => manager.spawn.mock.calls.length >= 3, 2000);
const final = scheduler.list().find(j => j.id === job.id)!;
expect(final.runCount).toBeGreaterThanOrEqual(3);
expect(final.lastStatus).toBe("success");
expect(final.enabled).toBe(true); // intervals don't auto-disable
scheduler.removeJob(job.id);
});
it("persistence: schedules survive re-instantiating the store on the same file", async () => {
const manager = makeFaithfulManager("completed");
const pi = makePi();
scheduler.start(pi, makeCtx(), manager, store);
const future = new Date(Date.now() + 60_000).toISOString(); // far enough not to fire
const job = scheduler.addJob({
name: "persistent",
description: "x",
schedule: future,
subagent_type: "general-purpose",
prompt: "x",
});
// Tear down the live scheduler; re-load from disk
scheduler.stop();
const reloadedStore = new ScheduleStore(join(tmp, "schedules.json"));
expect(reloadedStore.list()).toHaveLength(1);
expect(reloadedStore.list()[0].id).toBe(job.id);
expect(reloadedStore.list()[0].name).toBe("persistent");
});
it("on-disk file shape: version=1 plus jobs array", async () => {
const manager = makeFaithfulManager("completed");
const pi = makePi();
scheduler.start(pi, makeCtx(), manager, store);
scheduler.addJob({
name: "shape-test",
description: "x",
schedule: "1h",
subagent_type: "general-purpose",
prompt: "x",
});
const onDisk = JSON.parse(readFileSync(join(tmp, "schedules.json"), "utf-8"));
expect(onDisk.version).toBe(1);
expect(onDisk.jobs).toHaveLength(1);
expect(onDisk.jobs[0]).toMatchObject({
name: "shape-test",
schedule: "1h",
scheduleType: "interval",
enabled: true,
runCount: 0,
});
});
it("subagents:scheduled events fire across the lifecycle", async () => {
const manager = makeFaithfulManager("completed");
const pi = makePi();
scheduler.start(pi, makeCtx(), manager, store);
const future = new Date(Date.now() + 100).toISOString();
const job = scheduler.addJob({
name: "events", description: "x", schedule: future,
subagent_type: "general-purpose", prompt: "x",
});
await waitFor(() => manager.spawn.mock.calls.length === 1);
const eventTypes = pi.events.emit.mock.calls
.filter((c: any[]) => c[0] === "subagents:scheduled")
.map((c: any[]) => c[1].type);
expect(eventTypes).toContain("added");
expect(eventTypes).toContain("fired");
scheduler.removeJob(job.id);
const after = pi.events.emit.mock.calls
.filter((c: any[]) => c[0] === "subagents:scheduled")
.map((c: any[]) => c[1].type);
expect(after).toContain("removed");
});
});
+187
View File
@@ -0,0 +1,187 @@
// src/ui/schedule-menu.ts had 0% coverage — every one of its 50 statements.
// That is not a cosmetic gap: the menu's only action is DESTRUCTIVE (cancel a
// scheduled job), and it identifies the chosen job by matching the selected
// label string back against the label list.
//
// Labels are built by `formatJob`, which truncates the name to 18 characters
// (`j.name.padEnd(18).slice(0, 18)`). Job names come from the `Agent` call's
// `description`, which is LLM-authored and routinely shares a prefix. Two jobs
// whose names agree in the first 18 chars, with the same schedule, type, run
// count and run times, produce byte-identical labels — and `labels.indexOf`
// then resolves to the FIRST of them regardless of which the user picked.
//
// CHANGELOG 0.10.0 states the menu "lets you cancel any one of them", so this
// contradicts documented intent rather than merely being unspecified.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SubagentScheduler } from "../src/schedule.js";
import { ScheduleStore } from "../src/schedule-store.js";
import { showSchedulesMenu } from "../src/ui/schedule-menu.js";
/** ctx.ui stub: `select` returns whichever label index we tell it to. */
function makeCtx(opts: { pick?: (labels: string[]) => string | undefined; confirm?: boolean } = {}) {
const notify = vi.fn();
const select = vi.fn(async (_title: string, labels: string[]) => opts.pick?.(labels));
const confirm = vi.fn(async () => opts.confirm ?? true);
return { ctx: { ui: { select, confirm, notify } } as any, select, confirm, notify };
}
describe("showSchedulesMenu", () => {
let dir: string;
let scheduler: SubagentScheduler;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "pi-schedmenu-"));
scheduler = new SubagentScheduler();
scheduler.start(
{ events: { emit: vi.fn() } } as any,
{ cwd: dir } as any,
{ spawn: vi.fn(), getRecord: vi.fn() } as any,
new ScheduleStore(join(dir, "jobs.json")),
);
});
afterEach(() => {
scheduler.stop();
rmSync(dir, { recursive: true, force: true });
});
const addJob = (name: string) =>
scheduler.addJob({
name,
description: name,
schedule: "0 0 9 * * 1",
subagent_type: "general-purpose",
prompt: "go",
});
it("warns and does nothing when the scheduler is not active", async () => {
const idle = new SubagentScheduler();
const { ctx, select, notify } = makeCtx();
await showSchedulesMenu(ctx, idle);
expect(select).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith(expect.stringContaining("not active"), "warning");
});
it("reports an empty list without opening a picker", async () => {
const { ctx, select, notify } = makeCtx();
await showSchedulesMenu(ctx, scheduler);
expect(select).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith("No scheduled jobs.", "info");
});
it("cancels the job the user selected", async () => {
const a = addJob("alpha job");
const b = addJob("beta job");
const { ctx } = makeCtx({ pick: (labels) => labels[1] });
await showSchedulesMenu(ctx, scheduler);
expect(scheduler.list().map(j => j.id)).toEqual([a.id]);
expect(scheduler.list().map(j => j.id)).not.toContain(b.id);
});
// Names are truncated to 18 chars for column alignment, and they come from the
// LLM-authored `Agent` description, so two rows formatting identically is
// ordinary. Resolving the pick by string match then cancelled whichever came
// first — silently, with the confirm dialog showing the wrong job's details.
describe("rows that format identically after truncation", () => {
const NAME_A = "review the auth module A";
const NAME_B = "review the auth module B";
it("gives every row a distinct label", () => {
// The invariant the fix rests on. Everything below depends on it.
addJob(NAME_A);
addJob(NAME_B);
const { ctx, select } = makeCtx({ pick: () => undefined });
return showSchedulesMenu(ctx, scheduler).then(() => {
const labels = select.mock.calls[0][1] as string[];
expect(new Set(labels).size).toBe(labels.length);
});
});
it("cancels the second job when the second row is picked", async () => {
const first = addJob(NAME_A);
const second = addJob(NAME_B);
const { ctx } = makeCtx({ pick: (labels) => labels[1] });
await showSchedulesMenu(ctx, scheduler);
expect(scheduler.list().map(j => j.id)).toEqual([first.id]);
expect(scheduler.list().map(j => j.id)).not.toContain(second.id);
});
it("cancels the first job when the first row is picked", async () => {
// The other direction — an off-by-one or a last-match resolver would pass
// the test above and fail this one.
const first = addJob(NAME_A);
const second = addJob(NAME_B);
const { ctx } = makeCtx({ pick: (labels) => labels[0] });
await showSchedulesMenu(ctx, scheduler);
expect(scheduler.list().map(j => j.id)).toEqual([second.id]);
expect(scheduler.list().map(j => j.id)).not.toContain(first.id);
});
it("shows the SELECTED job's untruncated details in the confirm dialog", async () => {
// Guards the half of the bug the user could never catch: deleting the
// right job while confirming against the wrong one, or vice versa.
addJob(NAME_A);
addJob(NAME_B);
const { ctx, confirm } = makeCtx({ pick: (labels) => labels[1] });
await showSchedulesMenu(ctx, scheduler);
expect(confirm.mock.calls[0][0]).toContain(NAME_B);
expect(confirm.mock.calls[0][0]).not.toContain(NAME_A);
});
it("cancels the right job past the single-digit boundary", async () => {
// 11 rows: insurance against any refactor that parses the number back out
// of the label and confuses "1" with "11".
const jobs = Array.from({ length: 11 }, (_, i) => addJob(`review the auth module ${i}`));
const { ctx } = makeCtx({ pick: (labels) => labels[10] });
await showSchedulesMenu(ctx, scheduler);
const surviving = scheduler.list().map(j => j.id);
expect(surviving).toHaveLength(10);
expect(surviving).not.toContain(jobs[10].id);
});
});
it("cancels nothing when the picker returns a label we never offered", async () => {
addJob("alpha job");
const { ctx, confirm } = makeCtx({ pick: () => "something else entirely" });
await showSchedulesMenu(ctx, scheduler);
expect(confirm).not.toHaveBeenCalled();
expect(scheduler.list()).toHaveLength(1);
});
it("leaves every job intact when the user escapes the picker", async () => {
addJob("alpha job");
addJob("beta job");
const { ctx, confirm } = makeCtx({ pick: () => undefined });
await showSchedulesMenu(ctx, scheduler);
expect(confirm).not.toHaveBeenCalled();
expect(scheduler.list()).toHaveLength(2);
});
it("leaves the job intact when the user declines the confirm", async () => {
addJob("alpha job");
const { ctx, notify } = makeCtx({ pick: (labels) => labels[0], confirm: false });
await showSchedulesMenu(ctx, scheduler);
expect(scheduler.list()).toHaveLength(1);
expect(notify).not.toHaveBeenCalledWith(expect.stringContaining("Cancelled"), "info");
});
});
@@ -0,0 +1,232 @@
/**
* schedule-params-wiring.test.ts — what the Agent tool actually persists when
* `schedule` is set.
*
* A scheduled job's config is written to disk now and consumed at fire time,
* possibly days later, in a session nobody is watching. So a field that fails to
* reach the store — or reaches it with the wrong value — surfaces as an agent
* that misbehaves long after the call that configured it, with no error anywhere.
* That makes this the one part of the schedule branch worth pinning; the six
* refusal messages next to it are straight-line guards that fail loudly at the
* call site and are deliberately not tested.
*
* The job is read back through the real ScheduleStore rather than by spying on
* `addJob`, so the assertion covers persistence too.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("../src/agent-runner.js", async () => {
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
return { ...actual, runAgent: vi.fn() };
});
import { getDefaultMaxTurns, normalizeMaxTurns } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
import { resolveStorePath, ScheduleStore } from "../src/schedule-store.js";
import type { ScheduledSubagent } from "../src/types.js";
import { ctx, hermeticDir, makePi, textOf } from "./helpers/boot-extension.js";
const SESSION_ID = "sched-wiring-session";
function bootedCtx() {
return ctx({
sessionManager: { getSessionId: vi.fn(() => SESSION_ID), getBranch: vi.fn(() => []) },
});
}
/**
* Boot the extension in a temp cwd, bind session_start (the scheduler is
* inactive until then), schedule one agent, and read the job back off disk.
*/
async function scheduleAndReadBack(
params: Record<string, unknown>,
): Promise<{ job: ScheduledSubagent; reply: string; restore: () => void }> {
const hermetic = hermeticDir();
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const c = bootedCtx();
await lifecycle.get("session_start")?.({}, c);
const reply = textOf(
await tools.get("Agent").execute(
"tc-sched",
{ prompt: "do the thing", description: "nightly sweep", schedule: "0 3 * * * *", ...params },
undefined,
undefined,
c,
),
);
const store = new ScheduleStore(resolveStorePath(c.cwd, SESSION_ID));
const jobs = store.list();
await lifecycle.get("session_shutdown")?.();
expect(jobs, `expected one persisted job, reply was: ${reply}`).toHaveLength(1);
return { job: jobs[0], reply, restore: hermetic.restore };
}
describe("Agent tool → persisted scheduled job", () => {
it("persists the run-shaping params the job will fire with", async () => {
const { job, restore } = await scheduleAndReadBack({
subagent_type: "general-purpose",
thinking: "high",
isolated: true,
isolation: "worktree",
});
try {
expect(job.thinking).toBe("high");
expect(job.isolated).toBe(true);
expect(job.isolation).toBe("worktree");
expect(job.prompt).toBe("do the thing");
expect(job.enabled).toBe(true);
} finally {
restore();
}
});
it("persists the normalized turn limit, not the raw parameter", async () => {
// max_turns goes through normalizeMaxTurns and the agent-config/default
// fallback chain before it is stored. Persisting the raw param instead
// would give the scheduled run a different limit from an identical
// immediate run — and only at fire time.
const { job, restore } = await scheduleAndReadBack({
subagent_type: "general-purpose",
max_turns: 0, // 0 means "unlimited", not "zero turns"
});
try {
expect(job.max_turns).toBe(normalizeMaxTurns(0));
} finally {
restore();
}
});
it("falls back to the configured default turn limit when the call omits one", async () => {
const { job, restore } = await scheduleAndReadBack({ subagent_type: "general-purpose" });
try {
expect(job.max_turns).toBe(normalizeMaxTurns(getDefaultMaxTurns()));
} finally {
restore();
}
});
it("stores the caller's own subagent_type, not the fallback substitute", async () => {
// The scheduler re-resolves the type at fire time, and the stored name is
// what a user sees and edits in /agents. Baking in today's substitute would
// permanently rewrite their job to an agent they never asked for.
const { job, reply, restore } = await scheduleAndReadBack({ subagent_type: "does-not-exist" });
try {
expect(job.subagent_type).toBe("does-not-exist");
expect(reply).toContain("Scheduled");
} finally {
restore();
}
});
});
// README documents three combinations that scheduling refuses. Each is a
// published contract an orchestrator reads before calling, and none was pinned
// (`grep "Cannot combine" test/` returned nothing) — so the wording could drift,
// or a guard could be dropped, without any test noticing. The exact strings are
// asserted because the ORDER of these guards decides which message a caller
// gets when two apply at once; a substring match would let a reordering pass.
describe("Agent tool → schedule restrictions", () => {
/** Boot + bind, then make one scheduling call and return its reply text. */
async function scheduleCall(
params: Record<string, unknown>,
settings?: Record<string, unknown>,
): Promise<{ reply: string; jobCount: number; restore: () => void }> {
const hermetic = hermeticDir(settings ? { settings } : {});
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const c = bootedCtx();
await lifecycle.get("session_start")?.({}, c);
const reply = textOf(
await tools.get("Agent").execute(
"tc-sched",
{
prompt: "do the thing",
description: "nightly sweep",
schedule: "0 3 * * * *",
subagent_type: "general-purpose",
...params,
},
undefined,
undefined,
c,
),
);
let jobCount = 0;
try {
jobCount = new ScheduleStore(resolveStorePath(c.cwd, SESSION_ID)).list().length;
} catch { /* store never created — nothing was scheduled */ }
await lifecycle.get("session_shutdown")?.();
return { reply, jobCount, restore: hermetic.restore };
}
it("refuses `schedule` with `resume` — schedules create fresh agents", async () => {
const { reply, jobCount, restore } = await scheduleCall({ resume: "agent-123" });
try {
expect(reply).toBe("Cannot combine `schedule` with `resume` — schedules create fresh agents.");
expect(jobCount).toBe(0); // refused, not scheduled-and-warned
} finally {
restore();
}
});
it("refuses `schedule` with `inherit_context` — no parent conversation at fire time", async () => {
const { reply, jobCount, restore } = await scheduleCall({ inherit_context: true });
try {
expect(reply).toBe(
"Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.",
);
expect(jobCount).toBe(0);
} finally {
restore();
}
});
it("refuses `schedule` with `run_in_background: false` rather than silently coercing it", async () => {
// README:91 long claimed this parameter was "forced to true". It is not —
// the call is refused. Silently flipping a parameter the caller explicitly
// set is the failure mode #37 was filed about; refusing is the intended
// behavior and this pins it.
const { reply, jobCount, restore } = await scheduleCall({ run_in_background: false });
try {
expect(reply).toBe(
"Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.",
);
expect(jobCount).toBe(0);
} finally {
restore();
}
});
it("accepts `run_in_background: true` and an omitted `run_in_background`", async () => {
// The mirror: only an explicit `false` is refused, so a caller that sets the
// flag by habit is not blocked.
for (const params of [{ run_in_background: true }, {}]) {
const { reply, jobCount, restore } = await scheduleCall(params);
try {
expect(reply).toContain("Scheduled");
expect(jobCount).toBe(1);
} finally {
restore();
}
}
});
it("refuses scheduling entirely when the project disabled it", async () => {
const { reply, jobCount, restore } = await scheduleCall({}, { schedulingEnabled: false });
try {
expect(reply).toBe(
"Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.",
);
expect(jobCount).toBe(0);
} finally {
restore();
}
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* schedule-store.test.ts — Persistence + concurrency for ScheduleStore.
*
* Mirrors the patterns from pi-chonky-tasks's task-store testing: round-trip
* load/save, parse-error self-heal, stale-lock recovery.
*/
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveStorePath, ScheduleStore } from "../src/schedule-store.js";
import type { ScheduledSubagent } from "../src/types.js";
function makeJob(overrides: Partial<ScheduledSubagent> = {}): ScheduledSubagent {
return {
id: "job-" + Math.random().toString(36).slice(2, 10),
name: "test-job",
description: "test",
schedule: "5m",
scheduleType: "interval",
intervalMs: 5 * 60_000,
subagent_type: "general-purpose",
prompt: "hello",
enabled: true,
createdAt: new Date().toISOString(),
runCount: 0,
...overrides,
};
}
describe("ScheduleStore", () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "schedule-store-test-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("resolveStorePath produces session-scoped path under .pi/subagent-schedules/", () => {
const p = resolveStorePath("/repo", "abc123");
expect(p).toBe("/repo/.pi/subagent-schedules/abc123.json");
});
it("starts empty and round-trips a job through add/list", () => {
const store = new ScheduleStore(join(tmp, "s.json"));
expect(store.list()).toEqual([]);
const job = makeJob();
store.add(job);
expect(store.list()).toEqual([job]);
// New instance on same file — verifies persistence
const fresh = new ScheduleStore(join(tmp, "s.json"));
expect(fresh.list()).toEqual([job]);
});
it("update returns merged record and persists the patch", () => {
const store = new ScheduleStore(join(tmp, "s.json"));
const job = makeJob({ name: "before" });
store.add(job);
const updated = store.update(job.id, { name: "after", runCount: 3 });
expect(updated).toMatchObject({ id: job.id, name: "after", runCount: 3 });
const fresh = new ScheduleStore(join(tmp, "s.json"));
expect(fresh.list()[0]).toMatchObject({ name: "after", runCount: 3 });
});
it("update returns undefined for unknown id and does not create a record", () => {
const store = new ScheduleStore(join(tmp, "s.json"));
const r = store.update("nonexistent", { name: "x" });
expect(r).toBeUndefined();
expect(store.list()).toEqual([]);
});
it("remove returns true on existing job and false on missing", () => {
const store = new ScheduleStore(join(tmp, "s.json"));
const job = makeJob();
store.add(job);
expect(store.remove(job.id)).toBe(true);
expect(store.list()).toEqual([]);
expect(store.remove(job.id)).toBe(false);
});
it("hasName excludes a given id (for rename safety)", () => {
const store = new ScheduleStore(join(tmp, "s.json"));
const job = makeJob({ name: "alpha" });
store.add(job);
expect(store.hasName("alpha")).toBe(true);
expect(store.hasName("alpha", job.id)).toBe(false); // excluded — own record
expect(store.hasName("beta")).toBe(false);
});
it("uses atomic temp+rename — write produces final file, no .tmp leftover", () => {
const file = join(tmp, "s.json");
const store = new ScheduleStore(file);
store.add(makeJob());
expect(existsSync(file)).toBe(true);
expect(existsSync(file + ".tmp")).toBe(false);
});
it("self-heals from a corrupt JSON file — load silently empties, next save rewrites", () => {
const file = join(tmp, "s.json");
writeFileSync(file, "{ this is not valid JSON");
const store = new ScheduleStore(file);
expect(store.list()).toEqual([]);
// Next mutation overwrites the broken file with healthy JSON
store.add(makeJob({ id: "fresh" }));
const data = JSON.parse(readFileSync(file, "utf-8"));
expect(data.version).toBe(1);
expect(data.jobs).toHaveLength(1);
expect(data.jobs[0].id).toBe("fresh");
});
it("recovers from a stale lock left by a dead process", () => {
const file = join(tmp, "s.json");
const lockFile = file + ".lock";
// Simulate a stale lock file containing a non-existent PID.
// PID 999_999_999 is virtually never a live process — kill -0 returns ESRCH.
writeFileSync(lockFile, "999999999");
const store = new ScheduleStore(file);
// The mutation will detect the stale lock, unlink it, and proceed.
expect(() => store.add(makeJob())).not.toThrow();
expect(store.list()).toHaveLength(1);
expect(existsSync(lockFile)).toBe(false);
});
it("releases the lock after a successful mutation so subsequent ones don't deadlock", () => {
const store = new ScheduleStore(join(tmp, "s.json"));
const a = makeJob({ id: "a" });
const b = makeJob({ id: "b" });
store.add(a);
store.add(b); // would hang if the lock from the first add wasn't released
expect(store.list().map(j => j.id).sort()).toEqual(["a", "b"]);
});
it("does not create the backing directory until a mutation persists", () => {
const dir = join(tmp, ".pi", "subagent-schedules");
const file = join(dir, "sess.json");
// Constructing + read-only use must not touch the filesystem.
const store = new ScheduleStore(file);
expect(store.list()).toEqual([]);
expect(existsSync(dir)).toBe(false);
// First mutation lazily creates the directory.
store.add(makeJob());
expect(existsSync(dir)).toBe(true);
expect(existsSync(file)).toBe(true);
});
it("no-op update/remove of an unknown id never creates the backing directory", () => {
const dir = join(tmp, ".pi", "subagent-schedules");
const file = join(dir, "sess.json");
const store = new ScheduleStore(file);
expect(store.update("nonexistent", { name: "x" })).toBeUndefined();
expect(store.remove("nonexistent")).toBe(false);
expect(existsSync(dir)).toBe(false);
});
it("deleteFileIfEmpty unlinks file only when no jobs remain", () => {
const file = join(tmp, "s.json");
const store = new ScheduleStore(file);
const job = makeJob();
store.add(job);
store.deleteFileIfEmpty(); // not empty — should be a no-op
expect(existsSync(file)).toBe(true);
store.remove(job.id);
store.deleteFileIfEmpty();
expect(existsSync(file)).toBe(false);
});
});
+460
View File
@@ -0,0 +1,460 @@
/**
* schedule.test.ts — SubagentScheduler engine.
*
* Tests:
* - Static format parsers (cron / relative / interval / detection)
* - Job lifecycle (add / update / remove / cleanup)
* - Fire path (interval, one-shot) with mocked AgentManager + fake timers
* - Past-timestamp rejection
* - One-shot auto-disable
* - Concurrency-bypass option flows through to manager.spawn
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NO_FALLBACK, registerAgents, setFallbackSubagent } from "../src/agent-types.js";
import { SubagentScheduler } from "../src/schedule.js";
import { ScheduleStore } from "../src/schedule-store.js";
function makeMockManager() {
const spawnFn = vi.fn(() => "agent-" + Math.random().toString(36).slice(2, 10));
return {
spawn: spawnFn,
getRecord: vi.fn(() => ({ promise: Promise.resolve("done") })),
} as any;
}
function makeMockPi() {
return {
events: { emit: vi.fn() },
} as any;
}
function makeMockCtx() {
return {
cwd: "/tmp",
modelRegistry: { find: vi.fn(), getAll: () => [], getAvailable: () => [] },
sessionManager: { getSessionId: () => "sess-1" },
} as any;
}
describe("SubagentScheduler — static format parsers", () => {
it("parseRelativeTime accepts +Ns/Nm/Nh/Nd and rejects bare numbers", () => {
const before = Date.now();
const iso = SubagentScheduler.parseRelativeTime("+10s");
expect(iso).not.toBeNull();
const t = new Date(iso!).getTime();
expect(t - before).toBeGreaterThanOrEqual(9_000);
expect(t - before).toBeLessThanOrEqual(11_000);
expect(SubagentScheduler.parseRelativeTime("+5m")).not.toBeNull();
expect(SubagentScheduler.parseRelativeTime("+1h")).not.toBeNull();
expect(SubagentScheduler.parseRelativeTime("+2d")).not.toBeNull();
// Bare digits / wrong unit / no plus → null
expect(SubagentScheduler.parseRelativeTime("10s")).toBeNull();
expect(SubagentScheduler.parseRelativeTime("+5x")).toBeNull();
expect(SubagentScheduler.parseRelativeTime("hello")).toBeNull();
});
it("parseInterval converts unit-suffixed strings to milliseconds", () => {
expect(SubagentScheduler.parseInterval("10s")).toBe(10_000);
expect(SubagentScheduler.parseInterval("5m")).toBe(300_000);
expect(SubagentScheduler.parseInterval("1h")).toBe(3_600_000);
expect(SubagentScheduler.parseInterval("2d")).toBe(172_800_000);
expect(SubagentScheduler.parseInterval("+5m")).toBeNull(); // relative isn't an interval
expect(SubagentScheduler.parseInterval("5x")).toBeNull();
expect(SubagentScheduler.parseInterval("five-minutes")).toBeNull();
});
it("validateCronExpression rejects non-6-field expressions", () => {
expect(SubagentScheduler.validateCronExpression("* * * * *").valid).toBe(false); // 5 fields
expect(SubagentScheduler.validateCronExpression("0 0 9 * * 1").valid).toBe(true);
expect(SubagentScheduler.validateCronExpression("0 0 9 * * *").valid).toBe(true);
expect(SubagentScheduler.validateCronExpression("not-a-cron").valid).toBe(false);
});
it("detectSchedule tags type and normalizes input", () => {
expect(SubagentScheduler.detectSchedule("+10m").type).toBe("once");
expect(SubagentScheduler.detectSchedule("5m").type).toBe("interval");
expect(SubagentScheduler.detectSchedule("5m").intervalMs).toBe(300_000);
expect(SubagentScheduler.detectSchedule("0 0 9 * * 1").type).toBe("cron");
const iso = "2099-01-01T00:00:00.000Z";
const r = SubagentScheduler.detectSchedule(iso);
expect(r.type).toBe("once");
expect(r.normalized).toBe(iso);
expect(() => SubagentScheduler.detectSchedule("garbage")).toThrow(/Invalid schedule/);
});
});
describe("SubagentScheduler — lifecycle", () => {
let tmp: string;
let store: ScheduleStore;
let scheduler: SubagentScheduler;
let manager: any;
let pi: any;
let ctx: any;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "scheduler-test-"));
store = new ScheduleStore(join(tmp, "s.json"));
scheduler = new SubagentScheduler();
manager = makeMockManager();
pi = makeMockPi();
ctx = makeMockCtx();
scheduler.start(pi, ctx, manager, store);
});
afterEach(() => {
scheduler.stop();
rmSync(tmp, { recursive: true, force: true });
});
it("isActive() reports start/stop state", () => {
expect(scheduler.isActive()).toBe(true);
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
it("addJob persists, arms, and emits added event", () => {
const job = scheduler.addJob({
name: "j1",
description: "test",
schedule: "1h",
subagent_type: "general-purpose",
prompt: "hi",
});
expect(job.scheduleType).toBe("interval");
expect(scheduler.list()).toHaveLength(1);
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "added" }));
});
it("addJob rejects duplicate names", () => {
scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" });
expect(() => scheduler.addJob({
name: "j1", description: "y", schedule: "2h", subagent_type: "general-purpose", prompt: "p2",
})).toThrow(/already exists/);
});
it("removeJob clears the job and emits removed", () => {
const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" });
expect(scheduler.removeJob(job.id)).toBe(true);
expect(scheduler.list()).toEqual([]);
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "removed", jobId: job.id }));
});
it("updateJob({enabled: false}) unschedules but keeps the record", () => {
const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" });
scheduler.updateJob(job.id, { enabled: false });
expect(scheduler.list()[0].enabled).toBe(false);
expect(scheduler.getNextRun(job.id)).toBeUndefined();
});
// Regression: getNextRun on a freshly-created interval used to return undefined
// (the lastRun-based branch needs lastRun, which is undefined before first fire),
// surfacing as "Next run: (unknown)" in the agent's create-response.
it("getNextRun returns an approximate future time for a fresh interval (no lastRun yet)", () => {
const before = Date.now();
const job = scheduler.addJob({
name: "fresh-interval", description: "x", schedule: "1h",
subagent_type: "general-purpose", prompt: "p",
});
const next = scheduler.getNextRun(job.id);
expect(next).toBeDefined();
const t = new Date(next!).getTime();
// Should be ~now + 1h, with a small tolerance for the time spent in the call
expect(t - before).toBeGreaterThanOrEqual(3_600_000 - 1_000);
expect(t - before).toBeLessThanOrEqual(3_600_000 + 1_000);
});
// Once a fire happens and `lastRun` is set, getNextRun should pivot to it.
it("getNextRun uses lastRun when present for interval jobs", () => {
const job = scheduler.addJob({
name: "ran-once", description: "x", schedule: "1h",
subagent_type: "general-purpose", prompt: "p",
});
const lastRun = new Date(Date.now() - 30 * 60_000).toISOString(); // 30m ago
scheduler.updateJob(job.id, { lastRun });
const next = scheduler.getNextRun(job.id);
expect(next).toBe(new Date(new Date(lastRun).getTime() + 3_600_000).toISOString());
});
it("rejects past one-shot timestamps upfront — no record created", () => {
const past = new Date(Date.now() - 60_000).toISOString();
expect(() => scheduler.addJob({
name: "past", description: "x", schedule: past, subagent_type: "general-purpose", prompt: "p",
})).toThrow(/in the past/);
// No dead-on-arrival record left behind
expect(scheduler.list()).toEqual([]);
});
// The safety net in scheduleJob's past-branch only fires on store reload —
// a once-job persisted with a future ISO whose time has now passed (process
// restart after the trigger window). detectSchedule rejects past timestamps
// at create time, so this is the only remaining production path.
it("disables a previously-enabled one-shot reloaded from disk past its time", () => {
const past = new Date(Date.now() - 60_000).toISOString();
// Direct store insert bypasses addJob's upfront validation, mimicking a
// record that was valid when written but is now stale on reload.
store.add({
id: "reload-test",
name: "reload",
description: "reload",
schedule: past,
scheduleType: "once",
subagent_type: "general-purpose",
prompt: "x",
enabled: true,
createdAt: past,
runCount: 0,
});
// Re-arm: stop drops timers, start re-reads store.list() and calls scheduleJob
// for every enabled job → the past-branch fires for our seeded record.
scheduler.stop();
scheduler.start(pi, ctx, manager, store);
const reloaded = scheduler.list().find(j => j.id === "reload-test");
expect(reloaded?.enabled).toBe(false);
expect(reloaded?.lastStatus).toBe("error");
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({
type: "error", jobId: "reload-test", error: expect.stringMatching(/in the past/),
}));
});
});
describe("SubagentScheduler — fire path", () => {
let tmp: string;
let store: ScheduleStore;
let scheduler: SubagentScheduler;
let manager: any;
let pi: any;
let ctx: any;
beforeEach(() => {
vi.useFakeTimers();
tmp = mkdtempSync(join(tmpdir(), "scheduler-fire-"));
store = new ScheduleStore(join(tmp, "s.json"));
scheduler = new SubagentScheduler();
manager = makeMockManager();
pi = makeMockPi();
ctx = makeMockCtx();
scheduler.start(pi, ctx, manager, store);
});
afterEach(() => {
scheduler.stop();
vi.useRealTimers();
// Module-global: restore here, not at the end of a test body, so a failing
// assertion can't leak strict dispatch into every test that follows.
setFallbackSubagent(undefined);
registerAgents(new Map());
rmSync(tmp, { recursive: true, force: true });
});
it("interval jobs fire repeatedly via setInterval", () => {
scheduler.addJob({
name: "every-10s", description: "tick", schedule: "10s",
subagent_type: "general-purpose", prompt: "tick",
});
expect(manager.spawn).toHaveBeenCalledTimes(0);
vi.advanceTimersByTime(10_000);
expect(manager.spawn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(20_000);
expect(manager.spawn).toHaveBeenCalledTimes(3);
});
it("refuses at fire time when the job's agent type no longer resolves", () => {
// The registry is what production populates at activation; a job outliving
// its agent must not silently run something else (#183).
registerAgents(new Map());
setFallbackSubagent(NO_FALLBACK);
const job = scheduler.addJob({
name: "gone", description: "vanished agent", schedule: "+1s",
subagent_type: "deleted-since", prompt: "run",
});
vi.advanceTimersByTime(2_000);
expect(manager.spawn).not.toHaveBeenCalled();
const stored = store.get(job.id);
expect(stored?.lastStatus).toBe("error");
// Pin WHY it failed — "didn't spawn" alone would be satisfied by any
// unrelated pre-spawn throw.
expect(pi.events.emit).toHaveBeenCalledWith(
"subagents:scheduled",
expect.objectContaining({
type: "error",
error: expect.stringContaining("Unknown or disabled agent type"),
}),
);
});
it("one-shot fires once and auto-disables", async () => {
const job = scheduler.addJob({
name: "soon", description: "once", schedule: "+1s",
subagent_type: "general-purpose", prompt: "once",
});
vi.advanceTimersByTime(2_000);
expect(manager.spawn).toHaveBeenCalledTimes(1);
// The auto-disable update happens synchronously inside the timer callback
expect(scheduler.list().find(j => j.id === job.id)?.enabled).toBe(false);
// Subsequent ticks shouldn't fire again
vi.advanceTimersByTime(60_000);
expect(manager.spawn).toHaveBeenCalledTimes(1);
});
it("fire passes bypassQueue: true to manager.spawn", () => {
scheduler.addJob({
name: "every-1s", description: "x", schedule: "1s",
subagent_type: "general-purpose", prompt: "x",
});
vi.advanceTimersByTime(1_000);
expect(manager.spawn).toHaveBeenCalledTimes(1);
const optsArg = manager.spawn.mock.calls[0][4];
expect(optsArg.bypassQueue).toBe(true);
expect(optsArg.isBackground).toBe(true);
});
it("disabled jobs do not fire", () => {
const job = scheduler.addJob({
name: "off", description: "x", schedule: "1s",
subagent_type: "general-purpose", prompt: "x",
});
scheduler.updateJob(job.id, { enabled: false });
vi.advanceTimersByTime(5_000);
expect(manager.spawn).toHaveBeenCalledTimes(0);
});
it("emits fired event with agentId on successful spawn", () => {
scheduler.addJob({
name: "fire-once", description: "x", schedule: "+1s",
subagent_type: "general-purpose", prompt: "x",
});
vi.advanceTimersByTime(2_000);
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({
type: "fired", name: "fire-once", agentId: expect.stringMatching(/^agent-/),
}));
});
it("records lastStatus error and emits when manager.spawn throws", async () => {
manager.spawn.mockImplementationOnce(() => { throw new Error("no slots"); });
const job = scheduler.addJob({
name: "boom", description: "x", schedule: "+1s",
subagent_type: "general-purpose", prompt: "x",
});
vi.advanceTimersByTime(2_000);
// Update is synchronous in the spawn-throw path
expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error");
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({
type: "error", jobId: job.id, error: "no slots",
}));
});
// ── Status reflection from record.status (regression for bug #1) ────
// The real AgentManager's promise *always* resolves (its .catch returns ""),
// so the schedule's success/error must be inferred from `record.status`,
// not from promise resolution. These two tests model that contract.
describe("infers success vs error from record.status, not promise resolution", () => {
type FakeRecord = { status: string; promise: Promise<string>; resolve: () => void };
function installFaithfulMock(): Map<string, FakeRecord> {
const records = new Map<string, FakeRecord>();
manager.spawn.mockImplementation(() => {
const id = "agent-" + Math.random().toString(36).slice(2, 10);
let resolve!: () => void;
const promise = new Promise<string>(r => { resolve = () => r(""); });
records.set(id, { status: "running", promise, resolve });
return id;
});
manager.getRecord.mockImplementation((id: string) => records.get(id));
return records;
}
it("records lastStatus 'error' when the agent terminates with status='error'", async () => {
const records = installFaithfulMock();
const job = scheduler.addJob({
name: "fail-job", description: "x", schedule: "+1s",
subagent_type: "general-purpose", prompt: "x",
});
vi.advanceTimersByTime(2_000);
expect(manager.spawn).toHaveBeenCalledTimes(1);
// The agent ran and ended in error — same shape the real AgentManager produces.
const r = [...records.values()][0];
r.status = "error";
r.resolve();
// Flush microtasks so .then(finalize) runs.
await vi.advanceTimersByTimeAsync(0);
expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error");
});
it("records lastStatus 'success' when the agent terminates with status='completed'", async () => {
const records = installFaithfulMock();
const job = scheduler.addJob({
name: "ok-job", description: "x", schedule: "+1s",
subagent_type: "general-purpose", prompt: "x",
});
vi.advanceTimersByTime(2_000);
const r = [...records.values()][0];
r.status = "completed";
r.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("success");
});
it("treats aborted and stopped as errors (terminal failure states)", async () => {
const records = installFaithfulMock();
const a = scheduler.addJob({
name: "abort-job", description: "x", schedule: "+1s",
subagent_type: "general-purpose", prompt: "x",
});
const b = scheduler.addJob({
name: "stop-job", description: "x", schedule: "+2s",
subagent_type: "general-purpose", prompt: "x",
});
vi.advanceTimersByTime(3_000);
const recs = [...records.values()];
recs[0].status = "aborted";
recs[0].resolve();
recs[1].status = "stopped";
recs[1].resolve();
await vi.advanceTimersByTimeAsync(0);
expect(scheduler.list().find(j => j.id === a.id)?.lastStatus).toBe("error");
expect(scheduler.list().find(j => j.id === b.id)?.lastStatus).toBe("error");
});
});
});
describe("SubagentScheduler — stopped state", () => {
it("throws on mutation when not started", () => {
const scheduler = new SubagentScheduler();
expect(() => scheduler.addJob({
name: "x", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p",
})).toThrow(/not started/);
});
it("list() returns empty array when not started", () => {
const scheduler = new SubagentScheduler();
expect(scheduler.list()).toEqual([]);
});
});
+811
View File
@@ -0,0 +1,811 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyAndEmitLoaded,
applySettings,
loadSettings,
persistToastFor,
type SettingsAppliers,
saveAndEmitChanged,
saveSettings,
} from "../src/settings.js";
/**
* Tests for persistent settings. Uses two tmp directories:
* - `globalDir`: redirected via PI_CODING_AGENT_DIR so getAgentDir() returns it.
* Simulates `~/.pi/agent/` — the global scope.
* - `projectDir`: passed explicitly as cwd to load/save.
* Simulates the user's project root. Settings live at `<projectDir>/.pi/subagents.json`.
*/
describe("settings persistence", () => {
let globalDir: string;
let projectDir: string;
let originalAgentDirEnv: string | undefined;
const globalFile = () => join(globalDir, "subagents.json");
const projectFile = () => join(projectDir, ".pi", "subagents.json");
beforeEach(() => {
globalDir = mkdtempSync(join(tmpdir(), "pi-settings-global-"));
projectDir = mkdtempSync(join(tmpdir(), "pi-settings-project-"));
originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = globalDir;
});
afterEach(() => {
if (originalAgentDirEnv == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv;
rmSync(globalDir, { recursive: true, force: true });
rmSync(projectDir, { recursive: true, force: true });
});
function writeGlobal(obj: unknown) {
writeFileSync(globalFile(), JSON.stringify(obj));
}
function writeProject(obj: unknown) {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(projectFile(), JSON.stringify(obj));
}
it("returns {} when both files are missing", () => {
expect(loadSettings(projectDir)).toEqual({});
});
it("returns {} when both files are malformed JSON", () => {
writeFileSync(globalFile(), "not json {{");
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(projectFile(), "also not json");
expect(loadSettings(projectDir)).toEqual({});
});
it("loads from global when no project file", () => {
writeGlobal({ maxConcurrent: 16, graceTurns: 10 });
expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 16, graceTurns: 10 });
});
it("loads from project when no global file", () => {
writeProject({ maxConcurrent: 8, defaultJoinMode: "group" });
expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 8, defaultJoinMode: "group" });
});
it("merges global + project with project winning on conflicts", () => {
writeGlobal({ maxConcurrent: 16, graceTurns: 10, defaultJoinMode: "async" });
writeProject({ maxConcurrent: 4, defaultMaxTurns: 50 });
expect(loadSettings(projectDir)).toEqual({
maxConcurrent: 4, // project wins
graceTurns: 10, // from global
defaultJoinMode: "async", // from global
defaultMaxTurns: 50, // from project only
});
});
it("round-trips values: saveSettings then loadSettings", () => {
const settings = {
maxConcurrent: 7,
defaultMaxTurns: 30,
graceTurns: 3,
defaultJoinMode: "smart" as const,
schedulingEnabled: false,
toolDescriptionMode: "compact" as const,
};
saveSettings(settings, projectDir);
expect(loadSettings(projectDir)).toEqual(settings);
});
it("round-trips schedulingEnabled (true and false), and absence stays absent", () => {
saveSettings({ schedulingEnabled: false }, projectDir);
expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: false });
saveSettings({ schedulingEnabled: true }, projectDir);
expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: true });
// Absence — caller's "use default" signal — must not become a stored false.
saveSettings({}, projectDir);
expect(loadSettings(projectDir)).toEqual({});
});
it("round-trips fleetView (true and false); keeps boolean, drops non-boolean", () => {
saveSettings({ fleetView: false }, projectDir);
expect(loadSettings(projectDir)).toEqual({ fleetView: false });
saveSettings({ fleetView: true }, projectDir);
expect(loadSettings(projectDir)).toEqual({ fleetView: true });
writeProject({ fleetView: "on" } as any);
expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
});
it("round-trips agentMentions modes; drops an unknown one", () => {
for (const mode of ["model", "direct", "off"] as const) {
saveSettings({ agentMentions: mode }, projectDir);
expect(loadSettings(projectDir)).toEqual({ agentMentions: mode });
}
writeProject({ agentMentions: "on" } as any);
expect(loadSettings(projectDir)).toEqual({}); // unknown mode dropped
});
it("reads the pre-mode agentMentions booleans as their modes", () => {
// The setting shipped as a boolean before `model` existed, so a config
// written then — or hand-written from the old README — must keep working.
// `true` meant "on", and on is now `model`.
writeProject({ agentMentions: true } as any);
expect(loadSettings(projectDir)).toEqual({ agentMentions: "model" });
writeProject({ agentMentions: false } as any);
expect(loadSettings(projectDir)).toEqual({ agentMentions: "off" });
});
it("round-trips rememberAgents (true and false); keeps boolean, drops non-boolean", () => {
saveSettings({ rememberAgents: false }, projectDir);
expect(loadSettings(projectDir)).toEqual({ rememberAgents: false });
saveSettings({ rememberAgents: true }, projectDir);
expect(loadSettings(projectDir)).toEqual({ rememberAgents: true });
writeProject({ rememberAgents: "on" } as any);
expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
});
it("round-trips widgetMode; keeps valid values, drops invalid", () => {
saveSettings({ widgetMode: "off" }, projectDir);
expect(loadSettings(projectDir)).toEqual({ widgetMode: "off" });
saveSettings({ widgetMode: "background" }, projectDir);
expect(loadSettings(projectDir)).toEqual({ widgetMode: "background" });
writeProject({ widgetMode: "sideways" } as any);
expect(loadSettings(projectDir)).toEqual({}); // invalid value dropped
});
it("round-trips outputTranscript; drops non-boolean", () => {
saveSettings({ outputTranscript: false }, projectDir);
expect(loadSettings(projectDir)).toEqual({ outputTranscript: false });
saveSettings({ outputTranscript: true }, projectDir);
expect(loadSettings(projectDir)).toEqual({ outputTranscript: true });
writeProject({ outputTranscript: "no" } as any);
expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
});
it("round-trips backgroundByDefault (true and false), and absence stays absent", () => {
// `false` is the load-bearing case: it's how a user restores the previous
// foreground default, so it must survive a save/load rather than being
// read back as absent and re-defaulting to background.
saveSettings({ backgroundByDefault: false }, projectDir);
expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: false });
saveSettings({ backgroundByDefault: true }, projectDir);
expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: true });
saveSettings({}, projectDir);
expect(loadSettings(projectDir)).toEqual({});
});
it("sanitize drops non-boolean backgroundByDefault silently", () => {
writeProject({ backgroundByDefault: "yes" } as any);
expect(loadSettings(projectDir)).toEqual({});
writeProject({ backgroundByDefault: 0 } as any);
expect(loadSettings(projectDir)).toEqual({});
});
it("round-trips worktreeIsolation; drops non-boolean", () => {
saveSettings({ worktreeIsolation: false }, projectDir);
expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: false });
saveSettings({ worktreeIsolation: true }, projectDir);
expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: true });
writeProject({ worktreeIsolation: "off" } as any);
expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
});
it("round-trips reportUsage and showCost; drops non-boolean", () => {
saveSettings({ reportUsage: true, showCost: true }, projectDir);
expect(loadSettings(projectDir)).toEqual({ reportUsage: true, showCost: true });
saveSettings({ reportUsage: false, showCost: false }, projectDir);
expect(loadSettings(projectDir)).toEqual({ reportUsage: false, showCost: false });
// The sanitizer is an allowlist: a key it does not name is dropped, and the
// setting silently never applies.
writeProject({ reportUsage: "on", showCost: 1 } as any);
expect(loadSettings(projectDir)).toEqual({});
});
it("sanitize drops non-boolean schedulingEnabled silently", async () => {
writeProject({ schedulingEnabled: "yes" } as any);
expect(loadSettings(projectDir)).toEqual({});
writeProject({ schedulingEnabled: 1 } as any);
expect(loadSettings(projectDir)).toEqual({});
});
it("saveSettings writes only to the project file; global is untouched", () => {
writeGlobal({ maxConcurrent: 16 });
saveSettings({ maxConcurrent: 2 }, projectDir);
// Project file contains the new value
expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 });
// Global file unchanged
expect(JSON.parse(readFileSync(globalFile(), "utf-8"))).toEqual({ maxConcurrent: 16 });
});
it("saveSettings creates <cwd>/.pi/ when missing", () => {
expect(existsSync(join(projectDir, ".pi"))).toBe(false);
saveSettings({ maxConcurrent: 4 }, projectDir);
expect(existsSync(projectFile())).toBe(true);
});
it("round-trips defaultMaxTurns: 0 (unlimited marker)", () => {
saveSettings({ defaultMaxTurns: 0 }, projectDir);
expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 });
});
it("ignores unknown extra fields on load (forward-compat)", () => {
writeProject({ maxConcurrent: 2, futureField: "ignored" });
const loaded = loadSettings(projectDir);
expect(loaded.maxConcurrent).toBe(2);
// Unknown fields are stripped by the sanitizer — old versions won't persist garbage
expect((loaded as Record<string, unknown>).futureField).toBeUndefined();
});
it("composes partial global + partial project correctly", () => {
writeGlobal({ graceTurns: 10 });
writeProject({ maxConcurrent: 2 });
expect(loadSettings(projectDir)).toEqual({ graceTurns: 10, maxConcurrent: 2 });
});
describe("sanitizer", () => {
it("drops maxConcurrent < 1", () => {
writeProject({ maxConcurrent: 0, graceTurns: 5 });
expect(loadSettings(projectDir)).toEqual({ graceTurns: 5 });
});
it("drops negative maxConcurrent", () => {
writeProject({ maxConcurrent: -3 });
expect(loadSettings(projectDir)).toEqual({});
});
it("drops non-integer maxConcurrent (floats, NaN, strings)", () => {
writeProject({ maxConcurrent: 3.5 });
expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
writeProject({ maxConcurrent: "four" });
expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
writeProject({ maxConcurrent: null });
expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
});
it("accepts defaultMaxTurns: 0 (explicit unlimited)", () => {
writeProject({ defaultMaxTurns: 0 });
expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 });
});
it("drops negative defaultMaxTurns", () => {
writeProject({ defaultMaxTurns: -1 });
expect(loadSettings(projectDir)).toEqual({});
});
it("drops graceTurns < 1", () => {
writeProject({ graceTurns: 0 });
expect(loadSettings(projectDir)).toEqual({});
});
it("keeps maxSubagentDepth 0 (nesting off) but drops negative, fractional, and over-ceiling values", () => {
writeProject({ maxSubagentDepth: 0 });
expect(loadSettings(projectDir)).toEqual({ maxSubagentDepth: 0 });
writeProject({ maxSubagentDepth: -1 });
expect(loadSettings(projectDir)).toEqual({});
writeProject({ maxSubagentDepth: 1.5 });
expect(loadSettings(projectDir)).toEqual({});
writeProject({ maxSubagentDepth: 17 });
expect(loadSettings(projectDir)).toEqual({});
});
it("accepts `none` and `false` as the disabled fallback, nothing else", () => {
// Only the boolean needs an alias: it would otherwise be dropped, leaving
// the PERMISSIVE default while the author believed strict was on. Every
// string stays an agent name, so a mistaken "off" fails loudly at dispatch
// instead of meaning one thing here and another in the resolver.
for (const spelling of ["none", "NONE", " none ", false]) {
writeProject({ fallbackSubagent: spelling });
expect(loadSettings(projectDir).fallbackSubagent?.toLowerCase()).toBe("none");
}
writeProject({ fallbackSubagent: "off" });
expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "off" });
});
it("drops values that aren't a string or `false`, without coercing them", () => {
// String(["none"]) is "none" — coercing would silently enable strict mode.
for (const junk of [["none"], null, 42, true, {}]) {
writeProject({ fallbackSubagent: junk });
expect(loadSettings(projectDir)).toEqual({});
}
});
it("keeps a named fallback agent and drops non-strings", () => {
writeProject({ fallbackSubagent: " my-router " });
expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "my-router" });
writeProject({ fallbackSubagent: 42 });
expect(loadSettings(projectDir)).toEqual({});
writeProject({ fallbackSubagent: " " });
expect(loadSettings(projectDir)).toEqual({});
});
it("drops invalid defaultJoinMode values", () => {
writeProject({ defaultJoinMode: "invalid" });
expect(loadSettings(projectDir)).toEqual({});
writeProject({ defaultJoinMode: 42 });
expect(loadSettings(projectDir)).toEqual({});
writeProject({ defaultJoinMode: "" });
expect(loadSettings(projectDir)).toEqual({});
});
it("accepts all three valid join modes", () => {
for (const mode of ["async", "group", "smart"] as const) {
writeProject({ defaultJoinMode: mode });
expect(loadSettings(projectDir)).toEqual({ defaultJoinMode: mode });
}
});
it("accepts scopeModels boolean (true and false)", () => {
writeProject({ scopeModels: true });
expect(loadSettings(projectDir)).toEqual({ scopeModels: true });
writeProject({ scopeModels: false });
expect(loadSettings(projectDir)).toEqual({ scopeModels: false });
});
it("accepts strictAgentFiles boolean (true and false)", () => {
writeProject({ strictAgentFiles: true });
expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: true });
writeProject({ strictAgentFiles: false });
expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: false });
});
it("drops non-boolean strictAgentFiles", () => {
writeProject({ strictAgentFiles: "yes" });
expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined();
writeProject({ strictAgentFiles: 1 });
expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined();
});
it("drops non-boolean scopeModels", () => {
writeProject({ scopeModels: "yes" });
expect(loadSettings(projectDir).scopeModels).toBeUndefined();
writeProject({ scopeModels: 1 });
expect(loadSettings(projectDir).scopeModels).toBeUndefined();
writeProject({ scopeModels: null });
expect(loadSettings(projectDir).scopeModels).toBeUndefined();
});
it("accepts disableDefaultAgents boolean (true and false)", () => {
writeProject({ disableDefaultAgents: true });
expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: true });
writeProject({ disableDefaultAgents: false });
expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: false });
});
it("drops non-boolean disableDefaultAgents", () => {
writeProject({ disableDefaultAgents: "yes" });
expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined();
writeProject({ disableDefaultAgents: 1 });
expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined();
writeProject({ disableDefaultAgents: null });
expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined();
});
it("accepts all valid toolDescriptionMode values", () => {
for (const mode of ["full", "compact", "custom"] as const) {
writeProject({ toolDescriptionMode: mode });
expect(loadSettings(projectDir)).toEqual({ toolDescriptionMode: mode });
}
});
it("drops invalid toolDescriptionMode", () => {
writeProject({ toolDescriptionMode: "tiny" });
expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined();
writeProject({ toolDescriptionMode: true });
expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined();
writeProject({ toolDescriptionMode: null });
expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined();
});
it("returns {} when the JSON root is not an object (array, string, null)", () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(projectFile(), '["not", "an", "object"]');
expect(loadSettings(projectDir)).toEqual({});
writeFileSync(projectFile(), '"just a string"');
expect(loadSettings(projectDir)).toEqual({});
writeFileSync(projectFile(), "null");
expect(loadSettings(projectDir)).toEqual({});
});
it("keeps valid fields while dropping invalid siblings", () => {
writeProject({
maxConcurrent: 4, // ok
defaultMaxTurns: -5, // dropped
graceTurns: 3, // ok
defaultJoinMode: "nope", // dropped
});
expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 4, graceTurns: 3 });
});
it("accepts values at the ceiling (maxConcurrent=1024, defaultMaxTurns=10000, graceTurns=1000)", () => {
writeProject({ maxConcurrent: 1024, defaultMaxTurns: 10_000, graceTurns: 1_000 });
expect(loadSettings(projectDir)).toEqual({
maxConcurrent: 1024,
defaultMaxTurns: 10_000,
graceTurns: 1_000,
});
});
it("drops values above the ceiling", () => {
writeProject({ maxConcurrent: 1025 });
expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
writeProject({ defaultMaxTurns: 10_001 });
expect(loadSettings(projectDir).defaultMaxTurns).toBeUndefined();
writeProject({ graceTurns: 1_001 });
expect(loadSettings(projectDir).graceTurns).toBeUndefined();
});
it("drops absurdly large values (e.g. 1e6)", () => {
writeProject({ maxConcurrent: 1_000_000, defaultMaxTurns: 1_000_000, graceTurns: 1_000_000 });
expect(loadSettings(projectDir)).toEqual({});
});
});
describe("save result + corrupt-file warning", () => {
it("saveSettings returns true on success", () => {
expect(saveSettings({ maxConcurrent: 2 }, projectDir)).toBe(true);
expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 });
});
it("saveSettings returns false when the target dir cannot be created", () => {
// Place a regular file where the parent of the settings file would go —
// mkdirSync + writeFileSync both fail with ENOTDIR / EEXIST.
const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`);
writeFileSync(filePosingAsCwd, "");
try {
expect(saveSettings({ maxConcurrent: 1 }, filePosingAsCwd)).toBe(false);
} finally {
rmSync(filePosingAsCwd, { force: true });
}
});
it("warns to console.warn when an existing file is malformed", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(projectFile(), "not valid json {{{");
try {
expect(loadSettings(projectDir)).toEqual({});
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0][0])).toMatch(/Ignoring malformed settings/);
} finally {
spy.mockRestore();
}
});
it("does NOT warn when a file is simply missing", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
expect(loadSettings(projectDir)).toEqual({});
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
});
describe("applySettings", () => {
let appliers: SettingsAppliers;
beforeEach(() => {
appliers = {
setMaxConcurrent: vi.fn(),
setDefaultMaxTurns: vi.fn(),
setGraceTurns: vi.fn(),
setDefaultJoinMode: vi.fn(),
setBackgroundByDefault: vi.fn(),
setSchedulingEnabled: vi.fn(),
setScopeModels: vi.fn(),
setStrictAgentFiles: vi.fn(),
setDisableDefaultAgents: vi.fn(),
setToolDescriptionMode: vi.fn(),
setFleetView: vi.fn(),
setAgentMentions: vi.fn(),
setRememberAgents: vi.fn(),
setWidgetMode: vi.fn(),
setOutputTranscript: vi.fn(),
setWorktreeIsolation: vi.fn(),
setMaxSubagentDepth: vi.fn(),
setFallbackSubagent: vi.fn(),
setReportUsage: vi.fn(),
setShowCost: vi.fn(),
};
});
it("applies reportUsage and showCost", () => {
applySettings({ reportUsage: true, showCost: true }, appliers);
expect(appliers.setReportUsage).toHaveBeenCalledWith(true);
expect(appliers.setShowCost).toHaveBeenCalledWith(true);
applySettings({ reportUsage: false, showCost: false }, appliers);
expect(appliers.setReportUsage).toHaveBeenCalledWith(false);
expect(appliers.setShowCost).toHaveBeenCalledWith(false);
});
it("is a no-op on an empty settings object", () => {
applySettings({}, appliers);
expect(appliers.setReportUsage).not.toHaveBeenCalled();
expect(appliers.setShowCost).not.toHaveBeenCalled();
expect(appliers.setMaxConcurrent).not.toHaveBeenCalled();
expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
expect(appliers.setGraceTurns).not.toHaveBeenCalled();
expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();
expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled();
expect(appliers.setScopeModels).not.toHaveBeenCalled();
expect(appliers.setDisableDefaultAgents).not.toHaveBeenCalled();
expect(appliers.setToolDescriptionMode).not.toHaveBeenCalled();
});
it("applies fallbackSubagent through to the registry", () => {
// Without this, deleting the applySettings line for this field leaves the
// whole suite green while `subagents.json` silently stops working.
applySettings({ fallbackSubagent: "none" }, appliers);
expect(appliers.setFallbackSubagent).toHaveBeenCalledWith("none");
});
it("applies only the fields that are present", () => {
applySettings({ maxConcurrent: 4, graceTurns: 3, maxSubagentDepth: 1 }, appliers);
expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(4);
expect(appliers.setGraceTurns).toHaveBeenCalledWith(3);
expect(appliers.setMaxSubagentDepth).toHaveBeenCalledWith(1);
expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();
expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled();
expect(appliers.setScopeModels).not.toHaveBeenCalled();
});
it("applies all fields when all are present", () => {
applySettings(
{
maxConcurrent: 8,
defaultMaxTurns: 50,
graceTurns: 7,
defaultJoinMode: "group",
schedulingEnabled: false,
scopeModels: true,
disableDefaultAgents: true,
toolDescriptionMode: "compact",
fleetView: false,
widgetMode: "off",
},
appliers,
);
expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(8);
expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(50);
expect(appliers.setGraceTurns).toHaveBeenCalledWith(7);
expect(appliers.setDefaultJoinMode).toHaveBeenCalledWith("group");
expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false);
expect(appliers.setScopeModels).toHaveBeenCalledWith(true);
expect(appliers.setStrictAgentFiles).not.toHaveBeenCalled(); // absent from this snapshot
expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(true);
expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("compact");
expect(appliers.setFleetView).toHaveBeenCalledWith(false);
expect(appliers.setWidgetMode).toHaveBeenCalledWith("off");
});
it("applies strictAgentFiles; skips it when absent", () => {
applySettings({ strictAgentFiles: true }, appliers);
expect(appliers.setStrictAgentFiles).toHaveBeenCalledWith(true);
applySettings({}, appliers);
expect(appliers.setStrictAgentFiles).toHaveBeenCalledTimes(1);
});
it("applies widgetMode; skips it when absent", () => {
applySettings({ widgetMode: "off" }, appliers);
expect(appliers.setWidgetMode).toHaveBeenCalledWith("off");
applySettings({}, appliers);
expect(appliers.setWidgetMode).toHaveBeenCalledTimes(1); // absence is "use default"
});
it("applies fleetView (true and false); skips it when absent", () => {
applySettings({ fleetView: true }, appliers);
expect(appliers.setFleetView).toHaveBeenCalledWith(true);
applySettings({}, appliers);
expect(appliers.setFleetView).toHaveBeenCalledTimes(1); // absence is "use default"
});
it("applies agentMentions; skips it when absent", () => {
applySettings({ agentMentions: "direct" }, appliers);
expect(appliers.setAgentMentions).toHaveBeenCalledWith("direct");
applySettings({}, appliers);
expect(appliers.setAgentMentions).toHaveBeenCalledTimes(1); // absence is "use default"
});
it("applies rememberAgents; skips it when absent", () => {
applySettings({ rememberAgents: false }, appliers);
expect(appliers.setRememberAgents).toHaveBeenCalledWith(false);
applySettings({}, appliers);
expect(appliers.setRememberAgents).toHaveBeenCalledTimes(1); // absence is "use default"
});
it("applies scopeModels: false", () => {
applySettings({ scopeModels: false }, appliers);
expect(appliers.setScopeModels).toHaveBeenCalledWith(false);
});
it("applies disableDefaultAgents: false", () => {
applySettings({ disableDefaultAgents: false }, appliers);
expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(false);
});
it("applies toolDescriptionMode", () => {
applySettings({ toolDescriptionMode: "full" }, appliers);
expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("full");
});
it("applies outputTranscript (both true and false)", () => {
applySettings({ outputTranscript: false }, appliers);
expect(appliers.setOutputTranscript).toHaveBeenCalledWith(false);
applySettings({ outputTranscript: true }, appliers);
expect(appliers.setOutputTranscript).toHaveBeenCalledWith(true);
});
it("applies worktreeIsolation (both true and false)", () => {
applySettings({ worktreeIsolation: false }, appliers);
expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(false);
applySettings({ worktreeIsolation: true }, appliers);
expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(true);
});
it("applies defaultMaxTurns: 0 as the explicit unlimited marker", () => {
applySettings({ defaultMaxTurns: 0 }, appliers);
expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(0);
});
it("calls setBackgroundByDefault with either boolean", () => {
applySettings({ backgroundByDefault: false }, appliers);
expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(false);
applySettings({ backgroundByDefault: true }, appliers);
expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(true);
});
// Absence must leave the in-memory default (background) alone — calling
// the applier with `undefined` would read as foreground at the spawn site.
it("does not call setBackgroundByDefault when the field is absent", () => {
applySettings({ maxConcurrent: 4 }, appliers);
expect(appliers.setBackgroundByDefault).not.toHaveBeenCalled();
});
// Wiring tests for the master switch — ensures the schedulingEnabled
// field flows from the parsed settings into the applier callback that
// sets the in-memory flag in index.ts.
it("calls setSchedulingEnabled(true) when schedulingEnabled is true", () => {
applySettings({ schedulingEnabled: true }, appliers);
expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(true);
});
it("calls setSchedulingEnabled(false) when schedulingEnabled is false", () => {
applySettings({ schedulingEnabled: false }, appliers);
expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false);
});
// Absence preserves the in-memory default — the applier must NOT be
// called, otherwise loading a settings file without the field would
// overwrite the runtime default with `undefined`.
it("does not call setSchedulingEnabled when the field is absent", () => {
applySettings({ maxConcurrent: 4 }, appliers);
expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled();
});
});
describe("persistToastFor", () => {
it("returns info-level toast with the plain message on success", () => {
expect(persistToastFor("Max concurrency set to 7", true)).toEqual({
message: "Max concurrency set to 7",
level: "info",
});
});
it("returns warning-level toast with session-only suffix on failure", () => {
expect(persistToastFor("Max concurrency set to 7", false)).toEqual({
message: "Max concurrency set to 7 (session only; failed to persist)",
level: "warning",
});
});
});
describe("applyAndEmitLoaded", () => {
let appliers: SettingsAppliers;
beforeEach(() => {
appliers = {
setMaxConcurrent: vi.fn(),
setDefaultMaxTurns: vi.fn(),
setGraceTurns: vi.fn(),
setDefaultJoinMode: vi.fn(),
setBackgroundByDefault: vi.fn(),
setSchedulingEnabled: vi.fn(),
setScopeModels: vi.fn(),
setStrictAgentFiles: vi.fn(),
setDisableDefaultAgents: vi.fn(),
setToolDescriptionMode: vi.fn(),
setFleetView: vi.fn(),
setAgentMentions: vi.fn(),
setRememberAgents: vi.fn(),
setWidgetMode: vi.fn(),
setOutputTranscript: vi.fn(),
setWorktreeIsolation: vi.fn(),
setMaxSubagentDepth: vi.fn(),
setFallbackSubagent: vi.fn(),
setReportUsage: vi.fn(),
setShowCost: vi.fn(),
};
});
it("loads, applies, and emits subagents:settings_loaded with merged settings", () => {
writeGlobal({ maxConcurrent: 16 });
writeProject({ graceTurns: 7 });
const emit = vi.fn();
const result = applyAndEmitLoaded(appliers, emit, projectDir);
expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(16);
expect(appliers.setGraceTurns).toHaveBeenCalledWith(7);
expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", {
settings: { maxConcurrent: 16, graceTurns: 7 },
});
expect(result).toEqual({ maxConcurrent: 16, graceTurns: 7 });
});
it("still emits the event when both files are missing (payload carries {})", () => {
const emit = vi.fn();
const result = applyAndEmitLoaded(appliers, emit, projectDir);
expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", { settings: {} });
expect(result).toEqual({});
// No setters fired — defaults preserved
expect(appliers.setMaxConcurrent).not.toHaveBeenCalled();
expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
expect(appliers.setGraceTurns).not.toHaveBeenCalled();
expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();
});
});
describe("saveAndEmitChanged", () => {
it("persists, emits with persisted=true, and returns info toast on success", () => {
const emit = vi.fn();
const snapshot = { maxConcurrent: 5, graceTurns: 2 };
const toast = saveAndEmitChanged(snapshot, "Max concurrency set to 5", emit, projectDir);
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith("subagents:settings_changed", {
settings: snapshot,
persisted: true,
});
expect(toast).toEqual({ message: "Max concurrency set to 5", level: "info" });
// File actually written
expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual(snapshot);
});
it("emits with persisted=false and returns warning toast on save failure", () => {
const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`);
writeFileSync(filePosingAsCwd, "");
const emit = vi.fn();
try {
const toast = saveAndEmitChanged(
{ maxConcurrent: 5 },
"Max concurrency set to 5",
emit,
filePosingAsCwd,
);
expect(emit).toHaveBeenCalledWith("subagents:settings_changed", {
settings: { maxConcurrent: 5 },
persisted: false,
});
expect(toast).toEqual({
message: "Max concurrency set to 5 (session only; failed to persist)",
level: "warning",
});
} finally {
rmSync(filePosingAsCwd, { force: true });
}
});
});
});

Some files were not shown because too many files have changed in this diff Show More