mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-27 11:15:23 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" }] };
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user