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:
@@ -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[]> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user