mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|