Archived
feat(sessions): add tracked subsessions behind a beta flag
Add spawn_subsession / list_subsessions / read_subsession tools that let an agent start child sessions it stays attached to: the child records its parent in the session tree, the parent is notified (as a system-authored custom message that wakes an idle parent and queues behind in-flight work) when the child stops working, and the parent can inspect children's status and result. Gated behind a beta flag, off by default, mirroring spawnSessions: enable via PI_WEB_SUBSESSIONS, the subsessions config key, or the Settings toggle. Also requires spawnSessions. Also fix the release skill so the version step resyncs package-lock.json (npm install --package-lock-only) and the commit step refuses a release where package.json and package-lock.json versions disagree.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
|
||||
function ctxFor(sessionId: string, sessionFile: string | undefined): ExtensionContext {
|
||||
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
|
||||
// The subsession tools only read sessionManager.getSessionId/getSessionFile.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
|
||||
return { sessionManager } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function tools(deps: Partial<SubsessionToolDeps>) {
|
||||
const full: SubsessionToolDeps = {
|
||||
spawn: deps.spawn ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a" })),
|
||||
list: deps.list ?? vi.fn(() => Promise.resolve([])),
|
||||
read: deps.read ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, finalText: "", messageCount: 0 })),
|
||||
};
|
||||
const definitions = createSubsessionToolDefinitions("/repos/a", full);
|
||||
const find = (name: string) => {
|
||||
const tool = definitions.find((definition) => definition.name === name);
|
||||
if (tool === undefined) throw new Error(`missing tool ${name}`);
|
||||
return tool;
|
||||
};
|
||||
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), read: find("read_subsession") };
|
||||
}
|
||||
|
||||
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
||||
const first = content[0];
|
||||
return first?.type === "text" ? first.text : "";
|
||||
}
|
||||
|
||||
describe("createSubsessionToolDefinitions", () => {
|
||||
it("spawn_subsession forwards parent identity and params from the live context", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
|
||||
const { spawn: spawnTool } = tools({ spawn });
|
||||
|
||||
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/repos/a",
|
||||
parentSessionId: "parent-1",
|
||||
parentSessionFile: "/sessions/parent-1.jsonl",
|
||||
prompt: "do it",
|
||||
cwd: "/repos/a-feature",
|
||||
});
|
||||
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
|
||||
expect(firstText(result.content)).toContain("Started subsession child-1");
|
||||
});
|
||||
|
||||
it("list_subsessions reports the caller's subsessions and their status", async () => {
|
||||
const list = vi.fn(() => Promise.resolve([
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
|
||||
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" as const },
|
||||
]));
|
||||
const { list: listTool } = tools({ list });
|
||||
|
||||
const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(list).toHaveBeenCalledWith("parent-1");
|
||||
expect(result.details).toEqual({ subsessions: [
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" },
|
||||
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" },
|
||||
] });
|
||||
expect(firstText(result.content)).toContain("child-1 [working]");
|
||||
});
|
||||
|
||||
it("list_subsessions reports an empty state", async () => {
|
||||
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
|
||||
const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "You have not spawned any subsessions." });
|
||||
});
|
||||
|
||||
it("read_subsession scopes by parent and returns the final result", async () => {
|
||||
const read = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 }));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
const result = await readTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
|
||||
expect(firstText(result.content)).toContain("all done");
|
||||
});
|
||||
|
||||
it("read_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||
const read = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
await expect(readTool.execute("call-5", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
|
||||
.rejects.toThrow("not one of your subsessions");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user