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:
Federico Jaramillo Martinez
2026-06-17 12:13:23 +02:00
parent ef454f2b65
commit 355ebe8cf8
17 changed files with 658 additions and 13 deletions
@@ -63,9 +63,9 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
return filterSessionsForCwd(await listSessionsInDir(resolution.sessionDir), cwd);
}
create(cwd: string): PiSessionManager {
create(cwd: string, options?: { parentSession?: string }): PiSessionManager {
const resolution = this.resolver.resolve(cwd);
return SessionManager.create(cwd, resolution.sessionDir);
return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession });
}
listAll(): Promise<PiSessionListEntry[]> {
+150 -1
View File
@@ -50,9 +50,10 @@ function sessionRef(id: string, cwd = "/workspace") {
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
const promptCalls: { text: string; options: unknown }[] = [];
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls };
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
@@ -87,6 +88,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
calls.prompt.push({ text, options });
return Promise.resolve();
},
sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => {
calls.sendCustomMessage.push({ message, options });
return Promise.resolve();
},
executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }),
abort: () => {
calls.abort += 1;
@@ -832,4 +837,148 @@ describe("PiSessionService", () => {
await service.dispose();
});
});
describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const created = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime;
index += 1;
return runtime;
};
const archived = new Map<string, { sessionId: string; cwd: string; archivedAt: string }>();
const archiveStore = {
list: () => Promise.resolve([...archived.values()]),
get: (sessionId: string) => Promise.resolve(archived.get(sessionId)),
archive: (input: { sessionId: string; cwd: string }) => {
const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" };
archived.set(input.sessionId, record);
return Promise.resolve(record);
},
restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); },
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore,
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs,
});
return { parent, child, service };
}
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace"); // bring the parent online so it can be notified
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" });
expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]);
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
]);
void parent;
await service.dispose();
});
it("notifies the parent once when the tracked child stops working", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification
child.session.isStreaming = true;
child.emit({ type: "agent_start" }); // arm the notification
child.session.isStreaming = false;
child.emit({ type: "agent_end" }); // fire once
child.emit({ type: "turn_end" }); // must not re-notify
await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion");
expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message
await service.dispose();
});
it("notifies via the heartbeat when the child settles without a further event", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
parent.calls.prompt.length = 0;
// The child works, then settles silently: agent_end arrives while it still
// reports active work, so the event-driven latch does not fire here.
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.emit({ type: "agent_end" });
expect(parent.calls.sendCustomMessage).toHaveLength(0);
// Once the session settles, the periodic heartbeat re-check notifies.
child.session.isStreaming = false;
await new Promise((resolve) => setTimeout(resolve, 40));
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
await service.dispose();
});
it("does not notify the parent when a tracked child is archived", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
// Arm the notification, as a real working child would.
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
parent.calls.sendCustomMessage.length = 0;
await service.archive("child-1");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
await service.dispose();
});
it("reports an archived child's status in the subsession list", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
await service.archive("child-1");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "archived" },
]);
await service.dispose();
});
it("read_subsession refuses sessions that are not the caller's children", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
await expect(service.readSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions");
await service.dispose();
});
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
});
});
+188 -4
View File
@@ -32,6 +32,7 @@ import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
/**
@@ -127,7 +128,7 @@ export interface PiSessionManager {
export interface PiSessionManagerGateway {
list(cwd: string): Promise<PiSessionListEntry[]>;
create(cwd: string): PiSessionManager;
create(cwd: string, options?: { parentSession?: string }): PiSessionManager;
/**
* Legacy id-only lookup surface for older clients. This intentionally searches
* only Pi's default session store, because custom session directories require
@@ -172,6 +173,7 @@ export interface PiAgentSession {
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
abort(): Promise<void>;
clearQueue(): { steering: string[]; followUp: string[] };
@@ -208,12 +210,13 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn): CreateAgentSessionRuntimeFactory {
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): CreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
const customTools = [
createPiWebEditToolDefinition(cwd),
...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
];
const options = sessionStartEvent === undefined
? { services, sessionManager, customTools }
@@ -262,6 +265,13 @@ export interface PiSessionServiceDependencies {
* Omit to keep the capability disabled (the tool is never registered).
*/
spawnTargets?: SpawnTargetResolver;
/**
* Beta: when true (and `spawnTargets` is provided), the tracked-subsession
* tools (`spawn_subsession`, `list_subsessions`, `read_subsession`) are
* registered on every session. Off by default so the capability can ship in
* main without being exposed in releases.
*/
subsessionsEnabled?: boolean;
/** Structured logger for notable runtime events (e.g. spawns). */
logger?: PiSessionLogger;
}
@@ -274,6 +284,16 @@ export class PiSessionService {
private readonly compactionPromptQueues = new Map<string, QueuedPrompt[]>();
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
private readonly authLossWarnings = new Set<string>();
/** Tracked subsession id -> the parent session id that spawned it. */
private readonly subsessionParents = new Map<string, string>();
/** Parent session id -> the set of tracked subsession ids it spawned. */
private readonly subsessionChildren = new Map<string, Set<string>>();
/**
* Tracked subsession id -> whether a completion notification is armed.
* Armed when the child starts working; firing on completion disarms it so a
* child that works again (and stops again) notifies the parent each time.
*/
private readonly subsessionNotifyArmed = new Map<string, boolean>();
private readonly archiveStore: SessionArchiveRepository;
private readonly agentDir: string;
private readonly sessionManager: PiSessionManagerGateway;
@@ -291,10 +311,18 @@ export class PiSessionService {
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
// Subsessions are a beta capability gated behind their own flag, and they
// also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
this.modelRegistry.authStorage,
this.modelRegistry,
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
!subsessionsActive ? undefined : {
spawn: (input) => this.spawnSubsession(input),
list: (parentSessionId) => this.listSubsessions(parentSessionId),
read: (parentSessionId, sessionId) => this.readSubsession(parentSessionId, sessionId),
},
);
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
this.workspaceActivity = deps.workspaceActivity;
@@ -329,6 +357,9 @@ export class PiSessionService {
this.activities.clear();
this.compactionPromptQueues.clear();
this.authLossWarnings.clear();
this.subsessionParents.clear();
this.subsessionChildren.clear();
this.subsessionNotifyArmed.clear();
await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe();
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
@@ -355,8 +386,8 @@ export class PiSessionService {
return [...unarchivedSessions, ...archivedSessions];
}
async start(cwd: string): Promise<ClientSession> {
const active = await this.create(this.sessionManager.create(cwd), cwd);
async start(cwd: string, parentSession?: string): Promise<ClientSession> {
const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
const { session } = active.runtime;
const created: ClientSession = {
id: session.sessionId,
@@ -366,6 +397,9 @@ export class PiSessionService {
modified: new Date().toISOString(),
messageCount: session.messages.length,
firstMessage: "",
// Include the parent so listeners can nest the new session in the tree
// immediately, instead of showing it flat until the next reload.
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
};
// Broadcast so other clients (and the spawning agent's UI) can add the new
// session to their list without a manual reload.
@@ -391,6 +425,120 @@ export class PiSessionService {
return { sessionId: created.id, cwd: decision.cwd };
}
/**
* Start a *tracked* child session on behalf of a LLM. Identical to
* {@link spawnSession} in how the target cwd is resolved, but the child
* records its parent (so it shows in the session tree) and is registered so
* the parent is notified when it stops working and can inspect it later.
*/
async spawnSubsession(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult> {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd, input.parentSessionFile);
this.registerSubsession(input.parentSessionId, created.id);
await this.prompt(created.id, input.prompt);
this.logger.info(
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
"spawn_subsession started a tracked child session",
);
return { sessionId: created.id, cwd: decision.cwd };
}
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
async listSubsessions(parentSessionId: string): Promise<SubsessionSummary[]> {
const childIds = this.subsessionChildren.get(parentSessionId);
if (childIds === undefined) return [];
return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
}
/** Status and final result of a subsession, scoped to the caller's children. */
async readSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionReadResult> {
if (this.subsessionParents.get(sessionId) !== parentSessionId) {
throw new Error(`Session ${sessionId} is not one of your subsessions`);
}
const session = await this.getOrOpen(sessionId);
const messages = historyMessages(session);
return {
sessionId,
cwd: session.sessionManager.getCwd(),
status: await this.subsessionStatus(session),
finalText: finalAssistantText(messages),
messageCount: messages.length,
};
}
private registerSubsession(parentSessionId: string, childSessionId: string): void {
this.subsessionParents.set(childSessionId, parentSessionId);
const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>();
children.add(childSessionId);
this.subsessionChildren.set(parentSessionId, children);
this.subsessionNotifyArmed.set(childSessionId, false);
}
private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> {
const active = this.active.get(childSessionId);
if (active !== undefined) {
return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
}
const archived = await this.archiveStore.get(childSessionId);
if (archived !== undefined) return { cwd: archived.cwd, status: "archived" };
return { cwd: "", status: "unknown" };
}
private async subsessionStatus(session: PiAgentSession): Promise<SubsessionStatus> {
if (await this.archiveStore.isArchived(session.sessionId)) return "archived";
if (this.hasActiveWork(session)) return "working";
if (this.activities.get(session.sessionId)?.phase === "error") return "error";
return "idle";
}
/**
* Drive parent notifications from a tracked child's status. Arms a pending
* notification while the child is working, and when it stops fires a single
* follow-up message to the parent via {@link prompt} (which queues if the
* parent is busy and delivers immediately when it is idle).
*/
private updateSubsessionTracking(session: PiAgentSession): void {
const childId = session.sessionId;
const parentId = this.subsessionParents.get(childId);
if (parentId === undefined) return;
if (this.hasActiveWork(session)) {
this.subsessionNotifyArmed.set(childId, true);
return;
}
if (this.subsessionNotifyArmed.get(childId) !== true) return;
this.subsessionNotifyArmed.set(childId, false);
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
const finalText = finalAssistantText(historyMessages(session));
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse read_subsession with sessionId "${childId}" for the full result.`;
void this.notifyParentOfSubsession(parentId, childId, text);
}
/**
* Deliver a subsession-completion notice to the parent as a system-authored
* custom message rather than a user message, so it is not attributed to the
* human in the transcript. It still wakes an idle parent (`triggerTurn`) and
* queues behind in-flight work (`deliverAs: "followUp"`), preserving the
* established "queue if busy, send and act if idle" behavior.
*/
private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> {
try {
const session = await this.getOrOpen(parentId);
await session.sendCustomMessage(
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } },
{ triggerTurn: true, deliverAs: "followUp" },
);
this.publishStatus(session);
} catch (error: unknown) {
this.logger.info(
{ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) },
"failed to notify parent of subsession completion",
);
}
}
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
const session = await this.getOrOpen(ref);
return pageMessagesAtSafeBoundary(historyMessages(session), page);
@@ -751,6 +899,10 @@ export class PiSessionService {
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
this.clearAuthLossWarningsForSession(sessionId);
this.clearCompactionPromptQueue(sessionId);
// Disarm subsession notification before teardown so the abort below cannot
// emit a "stopped working" event that notifies the parent (e.g. on archive).
// The parent/children link is kept so the parent can still see the child.
this.subsessionNotifyArmed.delete(sessionId);
clearSessionQueue(active.runtime.session);
active.unsubscribe();
try {
@@ -839,6 +991,7 @@ export class PiSessionService {
if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
this.publishStatus(session);
this.updateSubsessionTracking(session);
});
this.active.set(session.sessionId, active);
}
@@ -969,6 +1122,10 @@ export class PiSessionService {
private publishHeartbeats(): void {
for (const active of this.active.values()) {
const { session } = active.runtime;
// Re-evaluate subsession completion here too: agent_end can arrive while
// the session still reports active work transiently, so the event-driven
// latch may not fire. The heartbeat re-checks once the session settles.
this.updateSubsessionTracking(session);
const activity = this.activities.get(session.sessionId);
if (!this.hasActiveWork(session)) {
if (activity?.phase === "active") this.publishStatus(session);
@@ -1298,6 +1455,33 @@ function historyMessages(session: PiAgentSession): unknown[] {
return messages;
}
/** customType marking a parent-facing subsession-completion notice. */
const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;
function truncateForNotification(text: string): string {
if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS) return text;
return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}`;
}
/** Most recent assistant text from a history message list, or "" if none. */
function finalAssistantText(messages: readonly unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!isRecord(message) || message["role"] !== "assistant") continue;
const content = message["content"];
if (typeof content === "string") return content;
if (!Array.isArray(content)) continue;
const texts: string[] = [];
for (const part of content) {
if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string") texts.push(part["text"]);
}
if (texts.length > 0) return texts.join("\n").trim();
}
return "";
}
function toClientEvent(event: unknown): SessionUiEvent {
const eventType = getString(event, "type");
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
@@ -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");
});
});
+125
View File
@@ -0,0 +1,125 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
/** Lifecycle phase of a tracked subsession as seen by its parent. */
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown";
export interface SpawnSubsessionResult {
sessionId: string;
cwd: string;
}
export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */
spawningCwd: string;
/** Session id of the parent; the spawned session is tracked against it. */
parentSessionId: string;
/** Session file of the parent, recorded in the child's `parentSession` header. */
parentSessionFile: string | undefined;
prompt: string;
cwd: string | undefined;
}
export interface SubsessionSummary {
sessionId: string;
cwd: string;
status: SubsessionStatus;
}
export interface SubsessionReadResult {
sessionId: string;
cwd: string;
status: SubsessionStatus;
finalText: string;
messageCount: number;
}
export interface SubsessionToolDeps {
spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>;
list(parentSessionId: string): Promise<SubsessionSummary[]>;
read(parentSessionId: string, sessionId: string): Promise<SubsessionReadResult>;
}
const SpawnSubsessionParams = Type.Object({
prompt: Type.String({
description: "The first instruction to send to the new tracked subsession.",
}),
cwd: Type.Optional(Type.String({
description: "Working directory for the subsession. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
})),
});
const ListSubsessionsParams = Type.Object({});
const ReadSubsessionParams = Type.Object({
sessionId: Type.String({
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
}),
});
function statusLine(summary: SubsessionSummary): string {
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
}
/**
* Tools that let an agent spawn *tracked* child sessions and inspect them.
*
* Unlike `spawn_session` (fire-and-forget peers), a subsession records its
* parent in its session header, the parent is notified when it stops working,
* and the parent may read its transcript/result. The tools are constructed
* per-session, carrying the spawning cwd for project-scope validation; the
* parent's identity is taken from the live extension context at call time.
*/
export function createSubsessionToolDefinitions(spawningCwd: string, deps: SubsessionToolDeps) {
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
name: "spawn_subsession",
label: "Spawn subsession",
description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions and read_subsession. Use this to delegate work you intend to follow up on.",
promptSnippet: "spawn_subsession: start a tracked child session you will be notified about",
parameters: SpawnSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.spawn({ spawningCwd, parentSessionId, parentSessionFile, prompt: params.prompt, cwd: params.cwd });
return {
content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }],
details: result,
};
},
});
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "list_subsessions",
label: "List subsessions",
description: "List the tracked subsessions you spawned, with their current status (working, idle, error, or unknown).",
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const subsessions = await deps.list(parentSessionId);
const text = subsessions.length === 0
? "You have not spawned any subsessions."
: `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`;
return { content: [{ type: "text", text }], details: { subsessions } };
},
});
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
name: "read_subsession",
label: "Read subsession",
description: "Read a subsession you spawned: its status and final result. Returns the subsession's most recent assistant output so you can react to what it produced.",
promptSnippet: "read_subsession: read the result of a subsession you spawned",
parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const result = await deps.read(parentSessionId, params.sessionId);
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
return {
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
details: result,
};
},
});
return [spawnTool, listTool, readTool];
}