docs: clarify Pi-compatible agent profiles

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:30:09 +02:00
38 changed files with 1282 additions and 160 deletions
+18
View File
@@ -186,6 +186,24 @@ describe("buildApp remote machine proxy routes", () => {
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
});
it("proxies remote session queue clearing through the allowlisted route", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const status = { sessionId: "s1", pendingMessageCount: 0, queuedMessages: [] };
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify(status)]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/queue/clear`, payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(status);
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/queue/clear", { cwd: "/repo" });
});
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
@@ -28,6 +28,17 @@ describe("machine-scoped session proxy routes", () => {
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]);
});
it("forwards queue-clear mutations and their status through the session daemon", async () => {
const status = { sessionId: "session-1", pendingMessageCount: 0, queuedMessages: [] };
daemon.respondWith({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(status) });
const response = await app.inject({ method: "POST", url: "/api/machines/local/sessions/session-1/queue/clear", payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(status);
expect(daemon.requests).toEqual([{ method: "POST", path: "/sessions/session-1/queue/clear", body: { cwd: "/repo" } }]);
});
it("strips the machine prefix before forwarding auth requests", async () => {
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
@@ -46,6 +46,7 @@ describe("delegation tool capability boundary", () => {
"list_subsessions",
"check_subsession",
"read_subsession",
"yield_to_subsessions",
]);
});
@@ -222,6 +222,84 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
it("clears runtime and compaction queues without interrupting active work", async () => {
const steeringMessages = ["adjust this turn"];
const followUpMessages = ["then do this"];
const transcript = [{ role: "user", content: "keep this history" }];
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("clear-queue-session", {
messages: transcript,
isStreaming: true,
isCompacting: true,
pendingMessageCount: 2,
getSteeringMessages: () => steeringMessages,
getFollowUpMessages: () => followUpMessages,
});
const clearRuntimeQueue = vi.fn(() => {
const cleared = { steering: [...steeringMessages], followUp: [...followUpMessages] };
steeringMessages.length = 0;
followUpMessages.length = 0;
fake.session.pendingMessageCount = 0;
return cleared;
});
fake.session.clearQueue = clearRuntimeQueue;
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("clear-queue-session"), "queued during compaction", "followUp");
await expect(service.status(sessionRef("clear-queue-session"))).resolves.toMatchObject({
isStreaming: true,
isCompacting: true,
pendingMessageCount: 3,
queuedMessages: [
{ kind: "steer", text: "adjust this turn" },
{ kind: "followUp", text: "then do this" },
{ kind: "followUp", text: "queued during compaction" },
],
});
const status = await service.clearQueue(sessionRef("clear-queue-session"));
expect(clearRuntimeQueue).toHaveBeenCalledOnce();
expect(status).toMatchObject({
isStreaming: true,
isCompacting: true,
pendingMessageCount: 0,
queuedMessages: [],
messageCount: 1,
});
expect(fake.session.messages).toBe(transcript);
expect(fake.calls.prompt).toEqual([]);
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
const publishedStatuses = hub.sessionEvents.filter(({ event }) => event.type === "status.update");
expect(publishedStatuses.at(-1)?.event).toEqual({ type: "status.update", status });
await service.dispose();
});
it("clears an already-empty queue idempotently", async () => {
const fake = fakeRuntime("clear-empty-queue-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
heartbeatIntervalMs: 60_000,
});
const firstStatus = await service.clearQueue(sessionRef("clear-empty-queue-session"));
const secondStatus = await service.clearQueue(sessionRef("clear-empty-queue-session"));
expect(fake.calls.clearQueue).toBe(2);
expect(fake.calls.abort).toBe(0);
expect(firstStatus).toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
expect(secondStatus).toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
await service.dispose();
});
it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -10,10 +10,15 @@ const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
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];
const children = childIds.map((childId) => fakeRuntime(childId, {
sessionFile: `/tmp/${childId}.jsonl`,
sessionManager: fakeSessionManager("/workspace-feature"),
}));
const child = children[0];
if (child === undefined) throw new Error("At least one child fixture is required");
const created = [parent.runtime, ...children.map(({ runtime }) => runtime)];
let index = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
@@ -41,7 +46,7 @@ describe("PiSessionService", () => {
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs,
});
return { parent, child, service };
return { parent, child, children, service };
}
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
@@ -796,6 +801,41 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("reports other working children in each completion notice", async () => {
const { parent, children, service } = subsessionService(
{ allowed: true, cwd: "/workspace-feature" },
60_000,
["child-1", "child-2"],
);
const [first, second] = children;
if (first === undefined || second === undefined) throw new Error("Expected two child fixtures");
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "first", cwd: "/workspace-feature" });
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "second", cwd: "/workspace-feature" });
first.session.isStreaming = true;
first.emit({ type: "agent_start" });
second.session.isStreaming = true;
second.emit({ type: "agent_start" });
first.session.isStreaming = false;
first.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[0]?.message.content).toBe(
"Subsession child-1 stopped working (idle).\nStill working: child-2. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.\n\n--- SUBSESSION OUTPUT: child-1 ---\n(no output)",
);
second.session.isStreaming = false;
second.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[1]?.message.content).toBe(
"Subsession child-2 stopped working (idle).\nNo other tracked subsessions are working.\n\n--- SUBSESSION OUTPUT: child-2 ---\n(no output)",
);
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");
+24 -1
View File
@@ -896,6 +896,16 @@ export class PiSessionService implements SessionRouteService {
return "idle";
}
private workingSubsessionIds(parentSessionId: string): string[] {
const childIds = this.subsessionChildren.get(parentSessionId);
if (childIds === undefined) return [];
return [...childIds].filter((childId) => {
const link = this.subsessionLinks.get(childId);
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
return active !== undefined && this.hasActiveWork(active.runtime.session);
});
}
/**
* 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
@@ -915,7 +925,11 @@ export class PiSessionService implements SessionRouteService {
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\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`;
const workingIds = this.workingSubsessionIds(link.parentSessionId);
const next = workingIds.length === 0
? "No other tracked subsessions are working."
: `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n--- SUBSESSION OUTPUT: ${childId} ---\n${preview}`;
void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
}
@@ -1347,6 +1361,15 @@ export class PiSessionService implements SessionRouteService {
this.unregisterSubsession(session.sessionId);
}
async clearQueue(ref: PiSessionLookup): Promise<ClientSessionStatus> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.clearCompactionPromptQueue(session.sessionId);
clearSessionQueue(session);
this.publishStatus(session);
return this.statusFromSession(session);
}
async abort(ref: PiSessionLookup): Promise<void> {
const active = this.activeForLookup(ref);
if (active === undefined) return;
+68 -1
View File
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
@@ -168,6 +168,55 @@ describe("session routes", () => {
}
});
it("clears a session queue with workspace context and returns fresh status", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear", payload: { cwd: requestCwd } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
sessionId: "session-1",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
expect(routeService.clearQueueCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("maps archived queue-clear failures to a mutation error without requiring a body", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
routeService.clearQueueError = new Error("Archived sessions are read-only. Restore the session to continue.");
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear" });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Archived sessions are read-only. Restore the session to continue." });
expect(routeService.clearQueueCalls).toEqual(["session-1"]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("normalizes cleanup requests for preview and execute routes", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
@@ -255,12 +304,14 @@ describe("session routes", () => {
class CapturingRouteSessionService implements SessionRouteService {
readonly calls: unknown[] = [];
readonly reloadCalls: SessionRouteLookup[] = [];
readonly clearQueueCalls: SessionRouteLookup[] = [];
messagesResponse: unknown[] | MessagePage = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
reloadError: Error | undefined;
clearQueueError: Error | undefined;
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
this.cleanupPreviewCalls.push(request);
@@ -294,6 +345,22 @@ class CapturingRouteSessionService implements SessionRouteService {
list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); }
clearQueue(lookup: SessionRouteLookup): Promise<SessionStatus> {
this.clearQueueCalls.push(lookup);
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
return Promise.resolve({
sessionId: sessionIdFromLookup(lookup),
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
}
messages(): Promise<unknown[] | MessagePage> {
return Promise.resolve(this.messagesResponse);
}
+8
View File
@@ -173,6 +173,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/queue/clear`, async (request, reply) => {
try {
return await sessions.clearQueue(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: AttachmentsRequestBody | undefined }>(`${prefix}/sessions/:sessionId/attachments`, async (request, reply) => {
try {
const body = optionalRecord(request.body);
+4 -3
View File
@@ -25,15 +25,16 @@ export type SessionRouteLookup = string | SessionRouteRef;
/**
* Route-facing session contract for PI WEB's HTTP/WebSocket API.
*
* Keep this surface neutral: implementations may be backed by the native Pi SDK,
* an out-of-process agent bridge, or another daemon. Pi-specific lifecycle hooks
* such as auth-change handling and daemon shutdown stay on the concrete service.
* Keep transport concerns separate from the bundled Pi SDK implementation so
* routes remain testable. Pi-specific lifecycle hooks such as auth-change
* handling and daemon shutdown stay on the concrete service.
*/
export interface SessionRouteService {
list(cwd: string): Promise<ClientSession[]>;
start(cwd: string): Promise<ClientSession>;
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus>;
@@ -0,0 +1,163 @@
import type { Api, AssistantMessage, Message, Model } from "@earendil-works/pi-ai";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { runAgentLoop, type AgentEvent, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { createSubsessionToolDefinitions, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
const model: Model<Api> = {
id: "fake-model",
name: "Fake Model",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000,
maxTokens: 100,
};
function extensionContext(): ExtensionContext {
const sessionManager = {
getSessionId: () => "parent-1",
getSessionFile: () => "/sessions/parent-1.jsonl",
};
// The wrapped yield definition only reads the two session-manager methods above.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal integration boundary for a Pi tool definition.
return { sessionManager } as unknown as ExtensionContext;
}
function wrapDefinition(definition: ToolDefinition, ctx: ExtensionContext): AgentTool {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
...(definition.executionMode === undefined ? {} : { executionMode: definition.executionMode }),
execute: (toolCallId, params, signal, onUpdate) => definition.execute(toolCallId, params, signal, onUpdate, ctx),
};
}
function isLlmMessage(agentMessage: AgentMessage): agentMessage is Message {
return agentMessage.role === "user" || agentMessage.role === "assistant" || agentMessage.role === "toolResult";
}
function message(stopReason: "stop" | "toolUse", content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp: 0,
};
}
function streamSequence(messages: AssistantMessage[]): StreamFn {
let index = 0;
return vi.fn(() => {
const next = messages[index];
index += 1;
if (next === undefined) throw new Error("unexpected provider invocation");
if (next.stopReason !== "stop" && next.stopReason !== "toolUse" && next.stopReason !== "length") {
throw new Error(`unsupported fake stop reason ${next.stopReason}`);
}
const stream = createAssistantMessageEventStream();
stream.push({ type: "done", reason: next.stopReason, message: next });
stream.end(next);
return stream;
});
}
async function runYieldBatch(subsessions: SubsessionSummary[], includeSentinel = false) {
const list = vi.fn(() => Promise.resolve(subsessions));
const deps: SubsessionToolDeps = {
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
list,
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
};
const yieldDefinition = createSubsessionToolDefinitions("/workspace", deps)
.find(({ name }) => name === "yield_to_subsessions");
if (yieldDefinition === undefined) throw new Error("missing yield_to_subsessions");
const sentinel = vi.fn(() => Promise.resolve({ content: [{ type: "text" as const, text: "sentinel complete" }], details: {} }));
const sentinelTool: AgentTool = {
name: "sentinel",
label: "Sentinel",
description: "Return normally.",
parameters: Type.Object({}),
execute: sentinel,
};
const firstContent: AssistantMessage["content"] = [
{ type: "toolCall", id: "yield-call", name: "yield_to_subsessions", arguments: {} },
...(includeSentinel
? [{ type: "toolCall" as const, id: "sentinel-call", name: "sentinel", arguments: {} }]
: []),
];
const streamFn = streamSequence([
message("toolUse", firstContent),
message("stop", [{ type: "text", text: "normal follow-up" }]),
]);
const events: AgentEvent[] = [];
const messages = await runAgentLoop(
[{ role: "user", content: "join now", timestamp: 0 }],
{
systemPrompt: "",
messages: [],
tools: [wrapDefinition(yieldDefinition, extensionContext()), sentinelTool],
},
{ model, convertToLlm: (agentMessages) => agentMessages.filter(isLlmMessage) },
(event) => { events.push(event); },
undefined,
streamFn,
);
return { events, list, messages, sentinel, streamFn };
}
describe("yield_to_subsessions Pi agent-loop integration", () => {
it("ends the run after one provider call when invoked alone with a working child", async () => {
const result = await runYieldBatch([
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
]);
expect(result.streamFn).toHaveBeenCalledTimes(1);
expect(result.list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
expect(result.events.slice(-2).map(({ type }) => type)).toEqual(["turn_end", "agent_end"]);
expect(result.messages.at(-1)).toMatchObject({ role: "toolResult", toolName: "yield_to_subsessions" });
});
it("makes a normal follow-up provider call when no child is working", async () => {
const result = await runYieldBatch([]);
expect(result.streamFn).toHaveBeenCalledTimes(2);
expect(result.events.filter(({ type }) => type === "turn_start")).toHaveLength(2);
expect(result.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "normal follow-up" }],
});
});
it("does not terminate a mixed batch with a non-terminating sibling tool", async () => {
const result = await runYieldBatch([
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
], true);
expect(result.sentinel).toHaveBeenCalledTimes(1);
expect(result.streamFn).toHaveBeenCalledTimes(2);
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
});
});
+127 -18
View File
@@ -25,7 +25,17 @@ function tools(deps: Partial<SubsessionToolDeps>) {
if (tool === undefined) throw new Error(`missing tool ${name}`);
return tool;
};
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), check: find("check_subsession"), read: find("read_subsession") };
return {
spawn: find("spawn_subsession"),
list: find("list_subsessions"),
check: find("check_subsession"),
read: find("read_subsession"),
yield: find("yield_to_subsessions"),
};
}
function workingGuidance(sessionId: string): string {
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
}
function firstText(content: readonly (TextContent | ImageContent)[]): string {
@@ -52,27 +62,40 @@ describe("createSubsessionToolDefinitions", () => {
expect(firstText(result.content)).toContain("Started tracked subsession child-1");
});
it("guides the parent to join all required subsessions without polling", async () => {
it("guides the parent to continue independent work and use the explicit join action", async () => {
const { spawn: spawnTool } = tools({
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })),
});
expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.");
expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete.");
expect(spawnTool.description).toBe("Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.");
expect(spawnTool.promptSnippet).toBe("spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions");
const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.");
expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Continue independent work, then join with yield_to_subsessions; do not poll.");
});
it("keeps subsession inspection tool descriptions capability-oriented", () => {
it("distinguishes status inspection from yielding in tool metadata", () => {
const definitions = tools({});
expect(definitions.list.description).toBe("List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).");
expect(definitions.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output.");
expect(definitions.read.description).toBe("Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.");
for (const definition of [definitions.list, definitions.check, definitions.read]) {
expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i);
}
expect(definitions.list.description).toBe("List tracked child statuses. Never yields or changes control flow; do not poll.");
expect(definitions.list.promptSnippet).toBe("list_subsessions: inspect child statuses; never yields");
expect(definitions.check.description).toBe("Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.");
expect(definitions.check.promptSnippet).toBe("check_subsession: inspect child status and available output; never yields");
expect(definitions.read.description).toBe("Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.");
expect(definitions.read.promptSnippet).toBe("read_subsession: inspect an available child transcript; never yields");
});
it("registers the parameterless yield action with terminal-batch guidance", () => {
const { yield: yieldTool } = tools({});
expect(yieldTool.parameters).toMatchObject({ type: "object", properties: {} });
expect(yieldTool.description).toBe("At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.");
expect(yieldTool.promptSnippet).toBe("yield_to_subsessions: end the run at a join point; call alone and last");
expect(yieldTool.promptGuidelines).toEqual([
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
"Call alone and last; a mixed tool batch may continue the run.",
"Completion notices wake you; do not poll inspection tools.",
]);
});
it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => {
@@ -105,12 +128,51 @@ describe("createSubsessionToolDefinitions", () => {
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" },
] });
expect(firstText(result.content)).toContain("child-1 [working]");
expect(result.terminate).toBeUndefined();
});
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: "No tracked subsessions." });
expect(result.terminate).toBeUndefined();
});
it("yield_to_subsessions terminates when tracked children are working", async () => {
const subsessions = [
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" as const },
{ sessionId: "child-3", cwd: "/repos/a", status: "working" as const },
];
const list = vi.fn(() => Promise.resolve(subsessions));
const { yield: yieldTool } = tools({ list });
const result = await yieldTool.execute("call-yield", {}, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
expect(result.details).toEqual({ subsessions });
expect(firstText(result.content)).toBe("Working: child-1, child-3. Ending this run; completion notices will wake you.");
expect(result.terminate).toBe(true);
});
it.each([
{ label: "an empty list", subsessions: [] },
{
label: "only non-working children",
subsessions: [
{ sessionId: "child-idle", cwd: "/repos/a", status: "idle" as const },
{ sessionId: "child-error", cwd: "/repos/a", status: "error" as const },
{ sessionId: "child-unknown", cwd: "/repos/a", status: "unknown" as const },
],
},
])("yield_to_subsessions remains active with $label", async ({ subsessions }) => {
const { yield: yieldTool } = tools({ list: vi.fn(() => Promise.resolve(subsessions)) });
const result = await yieldTool.execute("call-no-yield", {}, undefined, undefined, ctxFor("parent-1", undefined));
expect(result.details).toEqual({ subsessions });
expect(firstText(result.content)).toBe("No tracked subsessions are working; continuing.");
expect(result.terminate).toBeUndefined();
});
it("check_subsession scopes by parent and returns the final result", async () => {
@@ -121,7 +183,33 @@ describe("createSubsessionToolDefinitions", () => {
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
expect(firstText(result.content)).toContain("all done");
expect(firstText(result.content)).toBe("Subsession child-1 [idle].\n\n--- SUBSESSION OUTPUT: child-1 ---\nall done");
expect(result.terminate).toBeUndefined();
});
it("check_subsession withholds partial working output without yielding", async () => {
const partial = { sessionId: "child-1", cwd: "/repos/a", status: "working" as const, finalText: "SECRET PARTIAL OUTPUT", messageCount: 4 };
const check = vi.fn(() => Promise.resolve(partial));
const { check: checkTool } = tools({ check });
const result = await checkTool.execute("call-working-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
expect(firstText(result.content)).not.toContain("SECRET PARTIAL OUTPUT");
expect(result.details).toEqual(partial);
expect(result.terminate).toBeUndefined();
});
it("check_subsession preserves non-working error output without yielding", async () => {
const { check: checkTool } = tools({
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "error" as const, finalText: "child failed", messageCount: 3 })),
});
const result = await checkTool.execute("call-error-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(firstText(result.content)).toBe("Subsession child-1 [error].\n\n--- SUBSESSION OUTPUT: child-1 ---\nchild failed");
expect(result.terminate).toBeUndefined();
});
it("check_subsession propagates scope errors so the agent loop reports them", async () => {
@@ -136,15 +224,34 @@ describe("createSubsessionToolDefinitions", () => {
const read = vi.fn(() => Promise.resolve({
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }],
total: 5, matched: 1, start: 2, hasMore: false,
total: 5, matched: 2, start: 2, hasMore: true,
}));
const { read: readTool } = tools({ read });
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200, limit: 1 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
expect(firstText(result.content)).toContain("the answer");
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200, limit: 1 }, "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 2 });
expect(firstText(result.content)).toBe("Subsession child-1 [idle] — messages 22 of 5 (2 matched). Earlier matching messages exist before index 2.\n\n--- SUBSESSION TRANSCRIPT: child-1 ---\n#2 assistant\nthe answer");
expect(result.terminate).toBeUndefined();
});
it("read_subsession withholds partial working transcripts without yielding", async () => {
const partial = {
sessionId: "child-1", cwd: "/repos/a", status: "working" as const,
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "SECRET TRANSCRIPT ENTRY" }] }],
total: 3, matched: 1, start: 2, hasMore: false,
};
const read = vi.fn(() => Promise.resolve(partial));
const { read: readTool } = tools({ read });
const result = await readTool.execute("call-working-read", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", {}, "/sessions/parent-1.jsonl");
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
expect(firstText(result.content)).not.toContain("SECRET TRANSCRIPT ENTRY");
expect(result.details).toEqual(partial);
expect(result.terminate).toBeUndefined();
});
it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => {
@@ -165,6 +272,7 @@ describe("createSubsessionToolDefinitions", () => {
expect(text).toContain("command"); // raw args surfaced in text, not only details
expect(text).toContain("ls -la");
expect(text).toContain("[+43 chars truncated"); // 50 - 7
expect(result.terminate).toBeUndefined();
});
it("read_subsession distinguishes an empty page-window from a zero-match result", async () => {
@@ -178,6 +286,7 @@ describe("createSubsessionToolDefinitions", () => {
const text = firstText(result.content);
expect(text).toContain("4 matched"); // not "nothing matched"
expect(text).not.toContain("nothing matched");
expect(result.terminate).toBeUndefined();
});
it("read_subsession propagates scope errors so the agent loop reports them", async () => {
+68 -26
View File
@@ -67,50 +67,51 @@ export interface SubsessionToolDeps {
const SpawnSubsessionParams = Type.Object({
prompt: Type.String({
description: "The first instruction to send to the new tracked subsession.",
description: "Initial instruction for the tracked child.",
}),
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.",
description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.",
})),
});
const ListSubsessionsParams = Type.Object({});
const YieldToSubsessionsParams = Type.Object({});
const CheckSubsessionParams = Type.Object({
sessionId: Type.String({
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.",
description: "Tracked child id from spawn_subsession or list_subsessions.",
}),
});
const ReadSubsessionParams = Type.Object({
sessionId: Type.String({
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.",
description: "Tracked child id from spawn_subsession or list_subsessions.",
}),
roles: Type.Optional(Type.Array(
Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]),
{ description: "Message roles to include. Omit for all roles." },
{ description: "Roles to include; omit for all." },
)),
include: Type.Optional(Type.Array(
Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]),
{ description: "Content kinds to keep within messages. Omit for all kinds." },
{ description: "Content kinds to include; omit for all." },
)),
search: Type.Optional(Type.String({
description: "Case-insensitive substring; keep only messages whose text or tool name matches. Always searches full message content, even when maxChars is set.",
description: "Case-insensitive text or tool-name substring; searches full content before maxChars truncation.",
})),
maxChars: Type.Optional(Type.Integer({
minimum: 0,
description: "Truncate each text/thinking/tool-result value to this many characters; clipped parts are marked '[+N chars truncated]'. Omit for full, untruncated text (there is no default, so truncation only happens when you ask for it).",
description: "Maximum characters per text, thinking, or tool-result value; omit for no truncation.",
})),
includeToolArgs: Type.Optional(Type.Boolean({
description: "Include raw tool-call arguments (can be large). A compact one-line summary of each call is always shown regardless.",
description: "Include raw tool-call arguments; summaries are always included.",
})),
before: Type.Optional(Type.Integer({
minimum: 0,
description: "Return only messages before this transcript index; page backward by passing the previous response's 'start'.",
description: "Return messages before this index; use the previous start to page backward.",
})),
limit: Type.Optional(Type.Integer({
minimum: 1,
description: "Maximum number of most-recent matching messages to return within the window (returned in chronological order). Defaults to 50.",
description: "Maximum recent matches, in chronological order. Defaults to 50.",
})),
});
@@ -118,6 +119,10 @@ function statusLine(summary: SubsessionSummary): string {
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
}
function workingInspectionGuidance(sessionId: string): string {
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
}
function renderEntry(entry: TranscriptEntry): string {
const header = `#${String(entry.index)} ${entry.role}`;
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
@@ -153,7 +158,7 @@ function renderTranscript(result: SubsessionReadResult): string {
? "no messages matched your filters"
: `no messages in this window (${String(result.matched)} matched outside it)`)
: `messages ${String(result.start)}${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`;
const more = result.hasMore ? `\n\nEarlier matching messages exist before index ${String(result.start)}.` : "";
const more = result.hasMore ? ` Earlier matching messages exist before index ${String(result.start)}.` : "";
// Empty entries with matches means the `before` cursor excluded every match
// (they all sit at index >= before): the agent paged too far back and should
// raise `before` or omit it, not page back further.
@@ -162,11 +167,12 @@ function renderTranscript(result: SubsessionReadResult): string {
: (result.matched === 0
? "(no messages matched the filters)"
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`);
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`;
return `Subsession ${result.sessionId} [${result.status}] — ${range}.${more}\n\n--- SUBSESSION TRANSCRIPT: ${result.sessionId} ---\n${body}`;
}
/**
* Tools that let an agent spawn *tracked* child sessions and inspect them.
* Tools that let an agent spawn *tracked* child sessions, inspect them, and
* explicitly yield at a join point.
*
* Unlike `spawn_session` (fire-and-forget peers), a subsession records its
* parent in its session header, the parent is notified when it stops working,
@@ -178,8 +184,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
name: "spawn_subsession",
label: "Spawn subsession",
description: "Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.",
promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.",
description: "Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.",
promptSnippet: "spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions",
parameters: SpawnSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
@@ -193,7 +199,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
...(ctx.model === undefined ? {} : { model: ctx.model }),
});
return {
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.` }],
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Continue independent work, then join with yield_to_subsessions; do not poll.` }],
details: result,
};
},
@@ -202,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "list_subsessions",
label: "List subsessions",
description: "List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).",
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
description: "List tracked child statuses. Never yields or changes control flow; do not poll.",
promptSnippet: "list_subsessions: inspect child statuses; never yields",
parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
@@ -219,16 +225,19 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
name: "check_subsession",
label: "Check subsession",
description: "Return a tracked subsession's current status, message count, and most recent assistant output.",
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
description: "Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.",
promptSnippet: "check_subsession: inspect child status and available output; never yields",
parameters: CheckSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
const text = result.status === "working"
? workingInspectionGuidance(result.sessionId)
: `Subsession ${result.sessionId} [${result.status}].\n\n--- SUBSESSION OUTPUT: ${result.sessionId} ---\n${body}`;
return {
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
content: [{ type: "text", text }],
details: result,
};
},
@@ -237,20 +246,53 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
name: "read_subsession",
label: "Read subsession",
description: "Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.",
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
description: "Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.",
promptSnippet: "read_subsession: inspect an available child transcript; never yields",
parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const { sessionId, ...query } = params;
const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
const text = result.status === "working"
? workingInspectionGuidance(result.sessionId)
: renderTranscript(result);
return {
content: [{ type: "text", text: renderTranscript(result) }],
content: [{ type: "text", text }],
details: result,
};
},
});
return [spawnTool, listTool, checkTool, readTool];
const yieldTool = defineTool<typeof YieldToSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "yield_to_subsessions",
label: "Yield to subsessions",
description: "At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.",
promptSnippet: "yield_to_subsessions: end the run at a join point; call alone and last",
promptGuidelines: [
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
"Call alone and last; a mixed tool batch may continue the run.",
"Completion notices wake you; do not poll inspection tools.",
],
parameters: YieldToSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const subsessions = await deps.list(parentSessionId, parentSessionFile);
const working = subsessions.filter(({ status }) => status === "working");
if (working.length === 0) {
return {
content: [{ type: "text", text: "No tracked subsessions are working; continuing." }],
details: { subsessions },
};
}
return {
content: [{ type: "text", text: `Working: ${working.map(({ sessionId }) => sessionId).join(", ")}. Ending this run; completion notices will wake you.` }],
details: { subsessions },
terminate: true,
};
},
});
return [spawnTool, listTool, checkTool, readTool, yieldTool];
}