feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
+401
View File
@@ -0,0 +1,401 @@
/**
* mention-clone.test.ts — the off-screen conversation clone that starts a
* mentioned agent.
*
* Everything here guards one property: the clone is a throwaway, but the agent
* it starts must be indistinguishable from one the main model launched. That
* breaks in quiet ways — a spawn attributed to the fork's session id files its
* transcript in the wrong place, a tool-call id the real session never issued
* puts a dangling `<tool-use-id>` in the completion notification. Neither
* surfaces as an error, so each is pinned below.
*
* The other half is the fallback contract: `runMentionClone` never rejects,
* because the caller starts the agent directly on `spawned: false` and a
* rejection would instead lose the mention entirely.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
// Hoisted: vi.mock's factory is lifted above the imports, so it cannot close
// over ordinary top-level consts.
const { buildSessionContext, createAgentSession, inMemory } = vi.hoisted(() => ({
buildSessionContext: vi.fn(),
createAgentSession: vi.fn(),
inMemory: vi.fn(),
}));
vi.mock("@earendil-works/pi-coding-agent", async () => {
const actual = await vi.importActual<any>("@earendil-works/pi-coding-agent");
return {
...actual,
buildSessionContext,
createAgentSession,
SessionManager: { ...actual.SessionManager, inMemory },
};
});
import { agentMentionReminder } from "../src/mention.js";
import { runMentionClone } from "../src/mention-clone.js";
/** One user turn and its reply, as buildSessionContext resolves them. */
const CONVERSATION = [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
] as any[];
beforeEach(() => {
createAgentSession.mockReset();
inMemory.mockReset();
inMemory.mockReturnValue({ kind: "in-memory-session-manager" } as any);
buildSessionContext.mockReset();
buildSessionContext.mockReturnValue({ messages: CONVERSATION, thinkingLevel: "high", model: null } as any);
});
/** The main session's context — the one the spawn must be attributed to. */
function mainCtx(overrides: Record<string, unknown> = {}) {
return {
cwd: "/repo",
model: { id: "main-model" },
thinkingLevel: "high",
modelRegistry: { runtime: { kind: "runtime" } },
getSystemPrompt: vi.fn(() => "the live system prompt"),
sessionManager: {
getEntries: vi.fn(() => [{ type: "message" }] as any[]),
getLeafId: vi.fn(() => "leaf-1"),
},
...overrides,
} as any;
}
/** The registered Agent tool, whose handler the clone is supposed to reuse. */
function agentTool() {
return {
name: "Agent",
execute: vi.fn(async () => ({ content: [{ type: "text", text: "Agent ID: a1" }], details: {} })),
} as any;
}
/**
* Pi's own tool-visibility rule, reproduced from `sdk.js` + `agent-session.js`:
* an allowlist is derived once (`tools`, or the empty list when `noTools:
* "all"`), and EVERY tool — built-in, extension and custom alike — is dropped
* from the registry unless the allowlist names it. So `noTools: "all"` does not
* mean "no built-ins, keep my custom tool": it means the clone is handed
* nothing, answers in prose, and the mention falls back to a direct start.
*/
function visibleTools(opts: any): any[] {
const allowed = opts.tools ?? (opts.noTools === "all" ? [] : undefined);
const allowedSet = allowed ? new Set<string>(allowed) : undefined;
const excluded = new Set<string>(opts.excludeTools ?? []);
return (opts.customTools ?? []).filter(
(tool: any) => (!allowedSet || allowedSet.has(tool.name)) && !excluded.has(tool.name),
);
}
/**
* Stand in for `createAgentSession`. `turn` receives the clone's single custom
* tool and plays the part of the model deciding what to do with it — and only
* the tools Pi would really expose reach it, so a clone built with an allowlist
* that hides its own tool prompts a model with nothing to call.
*/
function cloneSession(turn?: (tool: any) => Promise<void> | void) {
const session = {
agent: { state: { systemPrompt: "rebuilt-from-cwd", messages: [] as any[] } },
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
} as any;
createAgentSession.mockImplementation(async (opts: any) => {
const tools = visibleTools(opts);
session.prompt.mockImplementation(async () => {
// No tool, no tool call: the model can only answer in prose.
if (tools.length === 0) return;
await turn?.(tools[0]);
});
session.createdWith = opts;
return { session };
});
return session;
}
/** What the model does when it plays along: one Agent call. */
const callsAgent = (params: Record<string, unknown> = { subagent_type: "Explore", prompt: "go" }) =>
async (tool: any) => {
await tool.execute("clone-tool-call-1", params, undefined, undefined, { cwd: "/fork" });
};
const opts = (over: Record<string, unknown> = {}) => ({
ctx: mainCtx(),
type: "Explore",
message: "find the flaky test",
agentTool: agentTool(),
...over,
}) as any;
describe("cloning the conversation", () => {
it("carries the conversation's own messages, not a rendering of them", async () => {
// The whole point: the copy reasons over what the main model can see.
const session = cloneSession(callsAgent());
await runMentionClone(opts());
expect(session.agent.state.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
]);
});
it("takes the conversation from memory, never from the session file", async () => {
// SessionManager withholds every write until the first assistant message,
// so a file-based copy is empty for the whole of the first turn — which is
// exactly when someone types their first mention.
const o = opts();
cloneSession(callsAgent());
await runMentionClone(o);
expect(buildSessionContext).toHaveBeenCalledWith([{ type: "message" }], "leaf-1");
expect(createAgentSession.mock.calls[0][0].sessionManager).toEqual({
kind: "in-memory-session-manager",
});
});
it("thinks at the level the session is really on", async () => {
cloneSession(callsAgent());
await runMentionClone(opts());
expect(createAgentSession.mock.calls[0][0].thinkingLevel).toBe("high");
});
it("omits the level rather than taking buildSessionContext's, which lies", async () => {
// getSessionContextSettings starts at "off" and moves only on an explicit
// thinking_level_change entry, so a session where nobody ran /think reports
// "off". Passing that would silently think less than the user asked for;
// omitting it lets createAgentSession resolve the real level from settings.
// Also the Pi <0.82.0 path, where ctx has no thinkingLevel at all.
buildSessionContext.mockReturnValue({ messages: CONVERSATION, thinkingLevel: "off", model: null } as any);
const o = opts({ ctx: mainCtx({ thinkingLevel: undefined }) });
cloneSession(callsAgent());
await runMentionClone(o);
expect(createAgentSession.mock.calls[0][0]).not.toHaveProperty("thinkingLevel");
});
it("clones a conversation that has not started yet", async () => {
// First input of a fresh session. There is no history to carry, which is an
// answer and not a failure — the copy still runs on the main model and
// system prompt, and still makes the call.
buildSessionContext.mockReturnValue({ messages: [], thinkingLevel: "medium", model: null } as any);
const o = opts();
const session = cloneSession(callsAgent());
const result = await runMentionClone(o);
expect(result).toEqual({ spawned: true });
expect(session.agent.state.messages).toEqual([]);
expect(session.agent.state.systemPrompt).toBe("the live system prompt");
});
it("carries the live system prompt rather than the one it rebuilt", async () => {
// createAgentSession derives a prompt from cwd and agentDir. Close, but not
// what the user's model is working under — extensions add to it per turn.
const session = cloneSession(callsAgent());
await runMentionClone(opts());
expect(session.agent.state.systemPrompt).toBe("the live system prompt");
});
it("inherits the parent's model, thinking level and providers", async () => {
cloneSession(callsAgent());
await runMentionClone(opts());
const built = createAgentSession.mock.calls[0][0];
expect(built.model).toEqual({ id: "main-model" });
expect(built.thinkingLevel).toBe("high");
expect(built.modelRuntime).toEqual({ kind: "runtime" });
});
it("gives the clone the Agent tool and nothing else", async () => {
// It runs where nobody is watching. A full toolset would let an invisible
// turn read, write or run things on the user's behalf.
cloneSession(callsAgent());
await runMentionClone(opts());
const built = createAgentSession.mock.calls[0][0];
expect(built.customTools).toHaveLength(1);
expect(built.customTools[0].name).toBe("Agent");
expect(visibleTools(built).map((tool: any) => tool.name)).toEqual(["Agent"]);
});
it("names its own tool in the allowlist, or Pi hands it nothing", async () => {
// `noTools: "all"` reads like "no built-ins, keep my custom tool" and is
// not: it sets an EMPTY allowlist, which strips the custom tool from the
// registry too (agent-session.js `isAllowedTool`). The clone then has
// nothing to call, every mention falls through to the direct start, and the
// user sees "Started @x directly — the conversation clone did not start it"
// on every single one. Naming the tool is what makes it reachable.
cloneSession(callsAgent());
const result = await runMentionClone(opts());
const built = createAgentSession.mock.calls[0][0];
expect(built.tools).toEqual(["Agent"]);
expect(built.noTools).toBeUndefined();
expect(result).toEqual({ spawned: true });
});
it("prompts it with the message, then the reminder", async () => {
const session = cloneSession(callsAgent());
await runMentionClone(opts());
expect(session.prompt).toHaveBeenCalledWith(
`find the flaky test\n\n${agentMentionReminder("Explore")}`,
);
});
});
describe("attributing the spawn to the real session", () => {
it("runs the real Agent handler with the MAIN context, not the fork's", async () => {
// The handler reads cwd, model and sessionManager.getSessionId() off this
// to place the .output transcript and rootSessionId. The clone's own
// context would file both under a session that is about to be discarded.
const tool = agentTool();
const o = opts({ agentTool: tool });
cloneSession(callsAgent());
await runMentionClone(o);
expect(tool.execute).toHaveBeenCalledTimes(1);
expect(tool.execute.mock.calls[0][4]).toBe(o.ctx);
});
it("passes no tool-call id, since the real session issued none", async () => {
// Left on the record it becomes a <tool-use-id> in the completion
// notification pointing at a call the main conversation never made.
const tool = agentTool();
cloneSession(callsAgent());
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][0]).toBeUndefined();
});
it("forwards the parameters the clone chose", async () => {
const tool = agentTool();
cloneSession(callsAgent({ subagent_type: "Plan", prompt: "sketch the migration" }));
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][1]).toEqual({
subagent_type: "Plan",
prompt: "sketch the migration",
run_in_background: true,
});
});
it("forces the spawn into the background — a foreground result goes nowhere", async () => {
// `run_in_background` defaults to false, and a foreground agent returns its
// answer as the TOOL RESULT: AgentManager marks the record `resultConsumed`
// precisely so the completion notification is skipped as redundant. Here
// that tool result lands in the throwaway clone, which is disposed moments
// later — so the agent runs to completion, shows up in the widget and the
// fleet, and its answer reaches nobody. The main conversation is not part
// of the clone's turn, so background delivery is the only way back.
const tool = agentTool();
cloneSession(callsAgent({ subagent_type: "Explore", prompt: "go" }));
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][1]).toMatchObject({ run_in_background: true });
});
it("overrides a clone that explicitly asked for a foreground run", async () => {
// Nothing tells the clone's model that its own turn is discarded, so an
// explicit `false` is a reasonable thing for it to emit. It must not decide
// this one.
const tool = agentTool();
cloneSession(callsAgent({ subagent_type: "Explore", prompt: "go", run_in_background: false }));
await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute.mock.calls[0][1]).toMatchObject({ run_in_background: true });
});
it("refuses a second spawn from the same mention", async () => {
// One handle, one agent. A clone that decides to also launch something else
// would do it unseen and unasked.
const tool = agentTool();
cloneSession(async (t) => {
await callsAgent()(t);
await callsAgent()(t);
});
const result = await runMentionClone(opts({ agentTool: tool }));
expect(tool.execute).toHaveBeenCalledTimes(1);
expect(result).toEqual({ spawned: true });
});
it("tells the clone why the second call was refused", async () => {
const captured: any[] = [];
cloneSession(async (t) => {
await callsAgent()(t);
captured.push(await t.execute("c2", { subagent_type: "Explore", prompt: "again" }, undefined, undefined, {}));
});
await runMentionClone(opts());
expect(captured[0].isError).toBe(true);
expect(captured[0].content[0].text).toContain("Already started an agent");
});
});
describe("when the clone cannot deliver", () => {
it("reports a turn that never called the tool", async () => {
// The model answering in prose is a real outcome, and a silent one — the
// caller needs it to fall back rather than leave the mention unanswered.
cloneSession();
const result = await runMentionClone(opts());
expect(result.spawned).toBe(false);
expect(result.error).toContain("did not start it");
});
it("returns a thrown error rather than rejecting", async () => {
// The caller's fallback runs off the resolved value. A rejection here would
// land in an unhandled promise and lose the mention.
createAgentSession.mockRejectedValue(new Error("no provider configured"));
await expect(runMentionClone(opts())).resolves.toEqual({
spawned: false,
error: "no provider configured",
});
});
it("keeps a spawn that already happened when the turn then fails", async () => {
// The agent is running. Reporting spawned:false would start a second one.
cloneSession(async (tool) => {
await callsAgent()(tool);
throw new Error("turn aborted");
});
const result = await runMentionClone(opts());
expect(result).toEqual({ spawned: true, error: "turn aborted" });
});
it("disposes the clone even when the turn throws", async () => {
const session = cloneSession(() => {
throw new Error("turn aborted");
});
await runMentionClone(opts());
expect(session.dispose).toHaveBeenCalled();
});
});