mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user