From d5154dfb3a448171da4831ee4559058ed8bb8aac Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 22:45:07 +0200 Subject: [PATCH 1/4] feat: add explicit tracked subsession yielding --- .changeset/yield-to-tracked-subsessions.md | 5 + docs/config.html | 40 +++-- docs/config.md | 10 +- .../piSessionService.delegationTools.test.ts | 1 + .../spawnSubsessionTool.integration.test.ts | 163 ++++++++++++++++++ .../sessions/spawnSubsessionTool.test.ts | 133 ++++++++++++-- src/server/sessions/spawnSubsessionTool.ts | 68 ++++++-- 7 files changed, 378 insertions(+), 42 deletions(-) create mode 100644 .changeset/yield-to-tracked-subsessions.md create mode 100644 src/server/sessions/spawnSubsessionTool.integration.test.ts diff --git a/.changeset/yield-to-tracked-subsessions.md b/.changeset/yield-to-tracked-subsessions.md new file mode 100644 index 0000000..84c4e32 --- /dev/null +++ b/.changeset/yield-to-tracked-subsessions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add an explicit tracked-subsession yield action so parents can end their run at join points and resume from completion notifications without polling or reading partial child output. diff --git a/docs/config.html b/docs/config.html index c91a8a9..8d5375a 100644 --- a/docs/config.html +++ b/docs/config.html @@ -525,24 +525,36 @@

subsessions

Boolean. Beta. Controls whether agents receive the tracked-subsession tools: - spawn_subsession, list_subsessions, check_subsession, and - read_subsession. Defaults to false and also requires spawnSessions - to be enabled. + spawn_subsession, list_subsessions, check_subsession, + read_subsession, and yield_to_subsessions. Defaults to false and also + requires spawnSessions to be enabled.

- Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child - stops working, and inspect their status and transcripts. Calling spawn_subsession returns - immediately. The parent can continue independent work while treating every child whose result it needs - as pending. Before producing work that depends on those results, the parent reaches a join point and - yields until every required child has sent a completion notice. + Tracked subsessions are join-oriented. Calling spawn_subsession returns immediately, so the + parent can continue independent work while the child runs. Work whose result the parent does not need to + join belongs in the fire-and-forget spawn_session tool instead.

- A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current - turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves - one pending child; after processing it, the parent yields again if another required child is pending. - list_subsessions, check_subsession, and read_subsession provide - on-demand status and transcript inspection for deliberate progress checks or recovery. Completion - notifications, rather than polling these tools, are the normal synchronization mechanism. + At a join point, after finishing its independent work, the parent calls + yield_to_subsessions alone as the final action in its tool batch. Pi ends a tool batch early + only when every result in that batch is terminating. If any tracked child is still working, the action + ends the current agent run so the parent becomes idle. If none are working, it does not end the run and + clearly reports that there is nothing to wait for. +

+

+ A completion notice automatically wakes an idle parent. If the parent is busy, the notice queues until + the current turn ends rather than interrupting in-flight work. When multiple children finish at + different times, the parent handles each completion and calls yield_to_subsessions again + while another child is still working. +

+

+ list_subsessions, check_subsession, and read_subsession never yield + or change control flow and are not completion-polling mechanisms. They remain available for deliberate + inspection or recovery. While a child is working, agent-facing check_subsession and + read_subsession withhold partial output and transcript entries and instead direct the parent + to continue independent work or yield at the join point. Once the child is no longer working, its output + and transcript are available. Completion notifications, rather than polling inspection tools, are the + normal synchronization mechanism.

In Settings → Session daemon, these keys are saved on the selected machine. Restart the diff --git a/docs/config.md b/docs/config.md index 8eb9282..95cd453 100644 --- a/docs/config.md +++ b/docs/config.md @@ -181,11 +181,15 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. -`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, and `read_subsession`. It defaults to `false` and also requires `spawnSessions` to be enabled. +`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, `read_subsession`, and `yield_to_subsessions`. It defaults to `false` and also requires `spawnSessions` to be enabled. -Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child stops working, and inspect their status and transcripts. Calling `spawn_subsession` returns immediately. The parent can continue independent work while treating every child whose result it needs as pending. Before producing work that depends on those results, the parent reaches a join point and yields until every required child has sent a completion notice. +Tracked subsessions are join-oriented. Calling `spawn_subsession` returns immediately, so the parent can continue independent work while the child runs. Work whose result the parent does not need to join belongs in the fire-and-forget `spawn_session` tool instead. -A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves one pending child; after processing it, the parent yields again if another required child is pending. `list_subsessions`, `check_subsession`, and `read_subsession` provide on-demand status and transcript inspection for deliberate progress checks or recovery. Completion notifications, rather than polling these tools, are the normal synchronization mechanism. +At a join point, after finishing its independent work, the parent calls `yield_to_subsessions` alone as the final action in its tool batch. Pi ends a tool batch early only when every result in that batch is terminating. If any tracked child is still working, the action ends the current agent run so the parent becomes idle. If none are working, it does not end the run and clearly reports that there is nothing to wait for. + +A completion notice automatically wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. When multiple children finish at different times, the parent handles each completion and calls `yield_to_subsessions` again while another child is still working. + +`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow and are not completion-polling mechanisms. They remain available for deliberate inspection or recovery. While a child is working, agent-facing `check_subsession` and `read_subsession` withhold partial output and transcript entries and instead direct the parent to continue independent work or yield at the join point. Once the child is no longer working, its output and transcript are available. Completion notifications, rather than polling inspection tools, are the normal synchronization mechanism. In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. diff --git a/src/server/sessions/piSessionService.delegationTools.test.ts b/src/server/sessions/piSessionService.delegationTools.test.ts index 0e1f0f5..247b3d3 100644 --- a/src/server/sessions/piSessionService.delegationTools.test.ts +++ b/src/server/sessions/piSessionService.delegationTools.test.ts @@ -46,6 +46,7 @@ describe("delegation tool capability boundary", () => { "list_subsessions", "check_subsession", "read_subsession", + "yield_to_subsessions", ]); }); diff --git a/src/server/sessions/spawnSubsessionTool.integration.test.ts b/src/server/sessions/spawnSubsessionTool.integration.test.ts new file mode 100644 index 0000000..68516a7 --- /dev/null +++ b/src/server/sessions/spawnSubsessionTool.integration.test.ts @@ -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 = { + 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" }); + }); +}); diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index dd9546a..e46599d 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -25,7 +25,17 @@ function tools(deps: Partial) { 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 still working, so partial output is unavailable through agent-facing inspection. Continue independent work, or at the join point call yield_to_subsessions alone as the final action in its tool batch. Completion notifications wake the parent automatically; do not poll list_subsessions, check_subsession, or read_subsession.`; } 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 after dispatch. Tracked children are join-oriented: continue independent work, then call yield_to_subsessions at the join point. Completion notifications wake the parent automatically; do not poll."); + expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate join-oriented work; continue independently, then use yield_to_subsessions at the join point"); 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 at the join point call yield_to_subsessions rather than polling for completion."); }); - 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("Return tracked child statuses for deliberate inspection. This never yields or changes control flow and is not a completion-polling mechanism."); + expect(definitions.list.promptSnippet).toBe("list_subsessions: deliberately inspect tracked child status without yielding"); + expect(definitions.check.description).toBe("Return status, message count, and latest output for a non-working tracked child. A working child returns guidance instead of partial output. This never yields or changes control flow."); + expect(definitions.check.promptSnippet).toBe("check_subsession: inspect a non-working child's latest output without yielding"); + expect(definitions.read.description).toBe("Return a filtered, paginated transcript for a non-working tracked child. A working child returns guidance instead of partial transcript entries. This never yields or changes control flow."); + expect(definitions.read.promptSnippet).toBe("read_subsession: inspect a non-working child's transcript without yielding"); + }); + + 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("End the current agent run at a tracked-subsession join point when any child is still working, allowing completion notifications to wake the parent. If none are working, remain active and report that there is nothing to wait for. Call alone as the final action in its tool batch; do not poll."); + expect(yieldTool.promptSnippet).toBe("yield_to_subsessions: at a join point, end this run while tracked children work; call alone as the final tool action"); + expect(yieldTool.promptGuidelines).toEqual([ + "Use yield_to_subsessions only at a join point after all independent parent work is done; tracked subsessions are join-oriented, while optional fire-and-forget work belongs in spawn_session.", + "Call yield_to_subsessions alone as the final action in its tool batch. Pi ends the run only when every finalized result in that batch is terminating.", + "Do not poll list_subsessions, check_subsession, or read_subsession for completion; completion notifications wake the parent automatically.", + ]); }); 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("Yielding to working tracked subsessions: child-1, child-3. The current agent run is ending; completion notifications will wake the parent as children stop working."); + 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 currently working. Nothing was yielded; continue without waiting."); + expect(result.terminate).toBeUndefined(); }); it("check_subsession scopes by parent and returns the final result", async () => { @@ -122,6 +184,32 @@ 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(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\nchild failed"); + expect(result.terminate).toBeUndefined(); }); it("check_subsession propagates scope errors so the agent loop reports them", async () => { @@ -145,6 +233,25 @@ describe("createSubsessionToolDefinitions", () => { 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(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 () => { diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index b3c2a01..1cb3b5e 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -75,6 +75,7 @@ const SpawnSubsessionParams = Type.Object({ }); const ListSubsessionsParams = Type.Object({}); +const YieldToSubsessionsParams = Type.Object({}); const CheckSubsessionParams = Type.Object({ sessionId: Type.String({ @@ -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 still working, so partial output is unavailable through agent-facing inspection. Continue independent work, or at the join point call yield_to_subsessions alone as the final action in its tool batch. Completion notifications wake the parent automatically; do not poll list_subsessions, check_subsession, or read_subsession.`; +} + function renderEntry(entry: TranscriptEntry): string { const header = `#${String(entry.index)} ${entry.role}`; const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n"); @@ -166,7 +171,8 @@ function renderTranscript(result: SubsessionReadResult): string { } /** - * 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({ 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 after dispatch. Tracked children are join-oriented: continue independent work, then call yield_to_subsessions at the join point. Completion notifications wake the parent automatically; do not poll.", + promptSnippet: "spawn_subsession: delegate join-oriented work; continue independently, then use yield_to_subsessions at the join point", 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 at the join point call yield_to_subsessions rather than polling for completion.` }], details: result, }; }, @@ -202,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const listTool = defineTool({ 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: "Return tracked child statuses for deliberate inspection. This never yields or changes control flow and is not a completion-polling mechanism.", + promptSnippet: "list_subsessions: deliberately inspect tracked child status without yielding", 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({ 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: "Return status, message count, and latest output for a non-working tracked child. A working child returns guidance instead of partial output. This never yields or changes control flow.", + promptSnippet: "check_subsession: inspect a non-working child's latest output without yielding", 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${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({ 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: "Return a filtered, paginated transcript for a non-working tracked child. A working child returns guidance instead of partial transcript entries. This never yields or changes control flow.", + promptSnippet: "read_subsession: inspect a non-working child's transcript without yielding", 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({ + name: "yield_to_subsessions", + label: "Yield to subsessions", + description: "End the current agent run at a tracked-subsession join point when any child is still working, allowing completion notifications to wake the parent. If none are working, remain active and report that there is nothing to wait for. Call alone as the final action in its tool batch; do not poll.", + promptSnippet: "yield_to_subsessions: at a join point, end this run while tracked children work; call alone as the final tool action", + promptGuidelines: [ + "Use yield_to_subsessions only at a join point after all independent parent work is done; tracked subsessions are join-oriented, while optional fire-and-forget work belongs in spawn_session.", + "Call yield_to_subsessions alone as the final action in its tool batch. Pi ends the run only when every finalized result in that batch is terminating.", + "Do not poll list_subsessions, check_subsession, or read_subsession for completion; completion notifications wake the parent automatically.", + ], + 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 currently working. Nothing was yielded; continue without waiting." }], + details: { subsessions }, + }; + } + return { + content: [{ type: "text", text: `Yielding to working tracked subsessions: ${working.map(({ sessionId }) => sessionId).join(", ")}. The current agent run is ending; completion notifications will wake the parent as children stop working.` }], + details: { subsessions }, + terminate: true, + }; + }, + }); + + return [spawnTool, listTool, checkTool, readTool, yieldTool]; } From 0761e1509a2a81de6cb1fbef575ab0cc6ef831fd Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 23:41:20 +0200 Subject: [PATCH 2/4] fix: report remaining subsessions on completion --- .changeset/yield-to-tracked-subsessions.md | 2 +- docs/config.html | 18 +++--- docs/config.md | 4 +- .../piSessionService.spawnSubsession.test.ts | 48 ++++++++++++++-- src/server/sessions/piSessionService.ts | 16 +++++- .../sessions/spawnSubsessionTool.test.ts | 34 +++++------ src/server/sessions/spawnSubsessionTool.ts | 56 +++++++++---------- 7 files changed, 115 insertions(+), 63 deletions(-) diff --git a/.changeset/yield-to-tracked-subsessions.md b/.changeset/yield-to-tracked-subsessions.md index 84c4e32..ebdf265 100644 --- a/.changeset/yield-to-tracked-subsessions.md +++ b/.changeset/yield-to-tracked-subsessions.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add an explicit tracked-subsession yield action so parents can end their run at join points and resume from completion notifications without polling or reading partial child output. +Add explicit tracked-subsession yielding, with wake-up notices that identify children still working so parents can continue or yield again without polling or reading partial output. diff --git a/docs/config.html b/docs/config.html index 8d5375a..d191ec6 100644 --- a/docs/config.html +++ b/docs/config.html @@ -542,19 +542,17 @@ clearly reports that there is nothing to wait for.

- A completion notice automatically wakes an idle parent. If the parent is busy, the notice queues until - the current turn ends rather than interrupting in-flight work. When multiple children finish at - different times, the parent handles each completion and calls yield_to_subsessions again - while another child is still working. + A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other + tracked children still working, so the parent can continue work or call + yield_to_subsessions again at the next join point. Further notices arrive automatically; do + not poll.

list_subsessions, check_subsession, and read_subsession never yield - or change control flow and are not completion-polling mechanisms. They remain available for deliberate - inspection or recovery. While a child is working, agent-facing check_subsession and - read_subsession withhold partial output and transcript entries and instead direct the parent - to continue independent work or yield at the join point. Once the child is no longer working, its output - and transcript are available. Completion notifications, rather than polling inspection tools, are the - normal synchronization mechanism. + or change control flow. They are for deliberate inspection or recovery, not completion polling. While a + child works, agent-facing check_subsession and read_subsession withhold partial + output and direct the parent to continue independent work or yield at the join point. Output becomes + available when the child stops.

In Settings → Session daemon, these keys are saved on the selected machine. Restart the diff --git a/docs/config.md b/docs/config.md index 95cd453..3c63b28 100644 --- a/docs/config.md +++ b/docs/config.md @@ -187,9 +187,9 @@ Tracked subsessions are join-oriented. Calling `spawn_subsession` returns immedi At a join point, after finishing its independent work, the parent calls `yield_to_subsessions` alone as the final action in its tool batch. Pi ends a tool batch early only when every result in that batch is terminating. If any tracked child is still working, the action ends the current agent run so the parent becomes idle. If none are working, it does not end the run and clearly reports that there is nothing to wait for. -A completion notice automatically wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. When multiple children finish at different times, the parent handles each completion and calls `yield_to_subsessions` again while another child is still working. +A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other tracked children still working, so the parent can continue work or call `yield_to_subsessions` again at the next join point. Further notices arrive automatically; do not poll. -`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow and are not completion-polling mechanisms. They remain available for deliberate inspection or recovery. While a child is working, agent-facing `check_subsession` and `read_subsession` withhold partial output and transcript entries and instead direct the parent to continue independent work or yield at the join point. Once the child is no longer working, its output and transcript are available. Completion notifications, rather than polling inspection tools, are the normal synchronization mechanism. +`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index 0ba16fa..acd4b74 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -8,10 +8,15 @@ import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionMa 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(); @@ -38,7 +43,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 () => { @@ -777,6 +782,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). Latest output:\n\n(no output)\n\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.", + ); + + 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). Latest output:\n\n(no output)\n\nNo other tracked subsessions are working.", + ); + 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"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 69712aa..9b28ed9 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -898,6 +898,16 @@ export class PiSessionService { 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 @@ -917,7 +927,11 @@ export class PiSessionService { 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}). Latest output:\n\n${preview}\n\n${next}`; void this.notifyParentOfSubsession(link.parentSessionId, childId, text); } diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index e46599d..853517c 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -35,7 +35,7 @@ function tools(deps: Partial) { } function workingGuidance(sessionId: string): string { - return `Subsession ${sessionId} is still working, so partial output is unavailable through agent-facing inspection. Continue independent work, or at the join point call yield_to_subsessions alone as the final action in its tool batch. Completion notifications wake the parent automatically; do not poll list_subsessions, check_subsession, or read_subsession.`; + 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 { @@ -67,34 +67,34 @@ describe("createSubsessionToolDefinitions", () => { spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })), }); - expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Tracked children are join-oriented: continue independent work, then call yield_to_subsessions at the join point. Completion notifications wake the parent automatically; do not poll."); - expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate join-oriented work; continue independently, then use yield_to_subsessions at the join point"); + 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. Continue independent work, then at the join point call yield_to_subsessions rather than polling for 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("distinguishes status inspection from yielding in tool metadata", () => { const definitions = tools({}); - expect(definitions.list.description).toBe("Return tracked child statuses for deliberate inspection. This never yields or changes control flow and is not a completion-polling mechanism."); - expect(definitions.list.promptSnippet).toBe("list_subsessions: deliberately inspect tracked child status without yielding"); - expect(definitions.check.description).toBe("Return status, message count, and latest output for a non-working tracked child. A working child returns guidance instead of partial output. This never yields or changes control flow."); - expect(definitions.check.promptSnippet).toBe("check_subsession: inspect a non-working child's latest output without yielding"); - expect(definitions.read.description).toBe("Return a filtered, paginated transcript for a non-working tracked child. A working child returns guidance instead of partial transcript entries. This never yields or changes control flow."); - expect(definitions.read.promptSnippet).toBe("read_subsession: inspect a non-working child's transcript without yielding"); + 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("End the current agent run at a tracked-subsession join point when any child is still working, allowing completion notifications to wake the parent. If none are working, remain active and report that there is nothing to wait for. Call alone as the final action in its tool batch; do not poll."); - expect(yieldTool.promptSnippet).toBe("yield_to_subsessions: at a join point, end this run while tracked children work; call alone as the final tool action"); + 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([ - "Use yield_to_subsessions only at a join point after all independent parent work is done; tracked subsessions are join-oriented, while optional fire-and-forget work belongs in spawn_session.", - "Call yield_to_subsessions alone as the final action in its tool batch. Pi ends the run only when every finalized result in that batch is terminating.", - "Do not poll list_subsessions, check_subsession, or read_subsession for completion; completion notifications wake the parent automatically.", + "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.", ]); }); @@ -151,7 +151,7 @@ describe("createSubsessionToolDefinitions", () => { expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl"); expect(result.details).toEqual({ subsessions }); - expect(firstText(result.content)).toBe("Yielding to working tracked subsessions: child-1, child-3. The current agent run is ending; completion notifications will wake the parent as children stop working."); + expect(firstText(result.content)).toBe("Working: child-1, child-3. Ending this run; completion notices will wake you."); expect(result.terminate).toBe(true); }); @@ -171,7 +171,7 @@ describe("createSubsessionToolDefinitions", () => { 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 currently working. Nothing was yielded; continue without waiting."); + expect(firstText(result.content)).toBe("No tracked subsessions are working; continuing."); expect(result.terminate).toBeUndefined(); }); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 1cb3b5e..7995edd 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -67,10 +67,10 @@ 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.", })), }); @@ -79,39 +79,39 @@ 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.", })), }); @@ -120,7 +120,7 @@ function statusLine(summary: SubsessionSummary): string { } function workingInspectionGuidance(sessionId: string): string { - return `Subsession ${sessionId} is still working, so partial output is unavailable through agent-facing inspection. Continue independent work, or at the join point call yield_to_subsessions alone as the final action in its tool batch. Completion notifications wake the parent automatically; do not poll list_subsessions, check_subsession, or read_subsession.`; + 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 { @@ -184,8 +184,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const spawnTool = defineTool({ name: "spawn_subsession", label: "Spawn subsession", - description: "Start a tracked child and return after dispatch. Tracked children are join-oriented: continue independent work, then call yield_to_subsessions at the join point. Completion notifications wake the parent automatically; do not poll.", - promptSnippet: "spawn_subsession: delegate join-oriented work; continue independently, then use yield_to_subsessions at the join point", + 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(); @@ -199,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}. Continue independent work, then at the join point call yield_to_subsessions rather than polling for 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, }; }, @@ -208,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const listTool = defineTool({ name: "list_subsessions", label: "List subsessions", - description: "Return tracked child statuses for deliberate inspection. This never yields or changes control flow and is not a completion-polling mechanism.", - promptSnippet: "list_subsessions: deliberately inspect tracked child status without yielding", + 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(); @@ -225,8 +225,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const checkTool = defineTool({ name: "check_subsession", label: "Check subsession", - description: "Return status, message count, and latest output for a non-working tracked child. A working child returns guidance instead of partial output. This never yields or changes control flow.", - promptSnippet: "check_subsession: inspect a non-working child's latest output without yielding", + 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(); @@ -246,8 +246,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const readTool = defineTool({ name: "read_subsession", label: "Read subsession", - description: "Return a filtered, paginated transcript for a non-working tracked child. A working child returns guidance instead of partial transcript entries. This never yields or changes control flow.", - promptSnippet: "read_subsession: inspect a non-working child's transcript without yielding", + 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(); @@ -267,12 +267,12 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const yieldTool = defineTool({ name: "yield_to_subsessions", label: "Yield to subsessions", - description: "End the current agent run at a tracked-subsession join point when any child is still working, allowing completion notifications to wake the parent. If none are working, remain active and report that there is nothing to wait for. Call alone as the final action in its tool batch; do not poll.", - promptSnippet: "yield_to_subsessions: at a join point, end this run while tracked children work; call alone as the final tool action", + 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: [ - "Use yield_to_subsessions only at a join point after all independent parent work is done; tracked subsessions are join-oriented, while optional fire-and-forget work belongs in spawn_session.", - "Call yield_to_subsessions alone as the final action in its tool batch. Pi ends the run only when every finalized result in that batch is terminating.", - "Do not poll list_subsessions, check_subsession, or read_subsession for completion; completion notifications wake the parent automatically.", + "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) { @@ -282,12 +282,12 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const working = subsessions.filter(({ status }) => status === "working"); if (working.length === 0) { return { - content: [{ type: "text", text: "No tracked subsessions are currently working. Nothing was yielded; continue without waiting." }], + content: [{ type: "text", text: "No tracked subsessions are working; continuing." }], details: { subsessions }, }; } return { - content: [{ type: "text", text: `Yielding to working tracked subsessions: ${working.map(({ sessionId }) => sessionId).join(", ")}. The current agent run is ending; completion notifications will wake the parent as children stop working.` }], + content: [{ type: "text", text: `Working: ${working.map(({ sessionId }) => sessionId).join(", ")}. Ending this run; completion notices will wake you.` }], details: { subsessions }, terminate: true, }; From c0465c4a5c2ef093d94b4d39a6cbc353a7551267 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 14 Jul 2026 00:04:53 +0200 Subject: [PATCH 3/4] fix: delimit subsession output from guidance --- .changeset/yield-to-tracked-subsessions.md | 2 +- docs/config.html | 3 ++- docs/config.md | 2 +- .../piSessionService.spawnSubsession.test.ts | 4 ++-- src/server/sessions/piSessionService.ts | 2 +- src/server/sessions/spawnSubsessionTool.test.ts | 14 +++++++------- src/server/sessions/spawnSubsessionTool.ts | 6 +++--- 7 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.changeset/yield-to-tracked-subsessions.md b/.changeset/yield-to-tracked-subsessions.md index ebdf265..29e8089 100644 --- a/.changeset/yield-to-tracked-subsessions.md +++ b/.changeset/yield-to-tracked-subsessions.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add explicit tracked-subsession yielding, with wake-up notices that identify children still working so parents can continue or yield again without polling or reading partial output. +Add explicit tracked-subsession yielding with no-poll wake-up guidance, remaining-child status, and clear boundaries around child output. diff --git a/docs/config.html b/docs/config.html index d191ec6..7bbc07d 100644 --- a/docs/config.html +++ b/docs/config.html @@ -552,7 +552,8 @@ or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing check_subsession and read_subsession withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes - available when the child stops. + available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled + marker and the child output or transcript always comes last.

In Settings → Session daemon, these keys are saved on the selected machine. Restart the diff --git a/docs/config.md b/docs/config.md index 3c63b28..34570e3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -189,7 +189,7 @@ At a join point, after finishing its independent work, the parent calls `yield_t A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other tracked children still working, so the parent can continue work or call `yield_to_subsessions` again at the next join point. Further notices arrive automatically; do not poll. -`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. +`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled marker and the child output or transcript always comes last. In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index acd4b74..3b9f678 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -804,7 +804,7 @@ describe("PiSessionService", () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(parent.calls.sendCustomMessage[0]?.message.content).toBe( - "Subsession child-1 stopped working (idle). Latest output:\n\n(no output)\n\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.", + "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; @@ -812,7 +812,7 @@ describe("PiSessionService", () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(parent.calls.sendCustomMessage[1]?.message.content).toBe( - "Subsession child-2 stopped working (idle). Latest output:\n\n(no output)\n\nNo other tracked subsessions are working.", + "Subsession child-2 stopped working (idle).\nNo other tracked subsessions are working.\n\n--- SUBSESSION OUTPUT: child-2 ---\n(no output)", ); await service.dispose(); }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 9b28ed9..d14de8c 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -931,7 +931,7 @@ export class PiSessionService { 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}). Latest output:\n\n${preview}\n\n${next}`; + const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n--- SUBSESSION OUTPUT: ${childId} ---\n${preview}`; void this.notifyParentOfSubsession(link.parentSessionId, childId, text); } diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index 853517c..c68396f 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -183,7 +183,7 @@ 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(); }); @@ -208,7 +208,7 @@ describe("createSubsessionToolDefinitions", () => { 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\nchild failed"); + expect(firstText(result.content)).toBe("Subsession child-1 [error].\n\n--- SUBSESSION OUTPUT: child-1 ---\nchild failed"); expect(result.terminate).toBeUndefined(); }); @@ -224,15 +224,15 @@ 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 2–2 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(); }); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 7995edd..1bb7357 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -158,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. @@ -167,7 +167,7 @@ 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}`; } /** @@ -235,7 +235,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const body = result.finalText === "" ? "(no output yet)" : result.finalText; const text = result.status === "working" ? workingInspectionGuidance(result.sessionId) - : `Subsession ${result.sessionId} [${result.status}]:\n\n${body}`; + : `Subsession ${result.sessionId} [${result.status}].\n\n--- SUBSESSION OUTPUT: ${result.sessionId} ---\n${body}`; return { content: [{ type: "text", text }], details: result, From a1f749cdb6e185270a955e77848b364a2c3c68bb Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 14 Jul 2026 00:23:44 +0200 Subject: [PATCH 4/4] feat: add server session queue clearing --- .changeset/clear-session-message-queue.md | 5 + src/client/src/api/clients.test.ts | 30 +++ src/client/src/api/clients.ts | 1 + .../src/api/federatedRouteContract.test.ts | 1 + src/client/src/components/ChatView.test.ts | 100 +++++++++- src/client/src/components/ChatView.ts | 20 +- .../components/PiWebApp.clearQueue.test.ts | 151 +++++++++++++++ src/client/src/components/PiWebApp.ts | 23 ++- src/client/src/components/shared.ts | 9 +- .../sessionController.clearQueue.test.ts | 179 ++++++++++++++++++ .../src/controllers/sessionController.ts | 24 ++- src/server/app.remoteProxy.test.ts | 18 ++ .../sessiond/sessionProxyRoutes.test.ts | 11 ++ .../piSessionService.promptQueue.test.ts | 76 ++++++++ src/server/sessions/piSessionService.ts | 9 + src/server/sessions/sessionRoutes.test.ts | 68 ++++++- src/server/sessions/sessionRoutes.ts | 8 + src/shared/apiTypes.ts | 1 + src/shared/capabilities.test.ts | 20 ++ src/shared/capabilities.ts | 3 + src/shared/federatedRoutes.ts | 1 + 21 files changed, 735 insertions(+), 23 deletions(-) create mode 100644 .changeset/clear-session-message-queue.md create mode 100644 src/client/src/components/PiWebApp.clearQueue.test.ts create mode 100644 src/client/src/controllers/sessionController.clearQueue.test.ts diff --git a/.changeset/clear-session-message-queue.md b/.changeset/clear-session-message-queue.md new file mode 100644 index 0000000..32eb429 --- /dev/null +++ b/.changeset/clear-session-message-queue.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a capability-aware Clear queue action that removes queued session messages, including prompts held during compaction, without stopping active work. diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 6146bbc..67b6742 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -246,6 +246,36 @@ describe("session API compatibility", () => { expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt"); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" }); }); + + it("clears a session queue through an encoded machine route and parses the returned status", async () => { + const fetchMock = stubJsonFetch({ + sessionId: "s /?", + isStreaming: true, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + tokens: { input: 3, output: 2, cacheRead: 1, cacheWrite: 0, total: 6 }, + cost: 0.25, + ignored: "not part of SessionStatus", + }); + + await expect(sessionsApi.clearQueue({ id: "s /?", cwd: "/repo with spaces" }, "remote /?")).resolves.toEqual({ + sessionId: "s /?", + isStreaming: true, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 3, output: 2, cacheRead: 1, cacheWrite: 0, total: 6 }, + cost: 0.25, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchCall(fetchMock, 0); + expect(url).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/queue/clear"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" }); + }); }); describe("machine-scoped file suggestion API", () => { diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 35b9940..16ca7fb 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -209,6 +209,7 @@ export const sessionsApi = { deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }), messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage), status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus), + clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }), models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse), setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }), cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }), diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 28a5257..52a53c2 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -64,6 +64,7 @@ describe("federated route contract", () => { ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)), ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)), ignoreParseFailure(sessionsApi.status(session, machineId)), + ignoreParseFailure(sessionsApi.clearQueue(session, machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)), ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)), ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)), diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index df3b167..2282bf1 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -1,5 +1,6 @@ import type { TemplateResult } from "lit"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { QueuedSessionMessage, SessionStatus } from "../api"; import type { ChatLine } from "./shared"; import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView"; @@ -12,19 +13,61 @@ describe("chatQueuedMessageSections", () => { expect(sections).toEqual([ { + source: "client", heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: [{ kind: "followUp", text: "queued before start" }], }, { + source: "server", heading: "Queued messages", - detail: "1 pending · Stop clears the queue", + detail: "1 pending", messages: [{ kind: "steer", text: "server queued" }], }, ]); }); }); +describe("ChatView queued-message clear action", () => { + // Direct handler extraction keeps this node-environment test focused on the + // Clear queue template wiring without introducing a component-wide DOM shim. + it("renders an accessible server-queue action and invokes its callback", () => { + const view = new ChatView(); + const onClearServerQueue = vi.fn(); + view.status = queuedStatus([{ kind: "steer", text: "server queued" }]); + view.canClearServerQueue = true; + view.onClearServerQueue = onClearServerQueue; + + const rendered = renderQueuedMessages(view); + const markup = templateStaticMarkup(rendered); + + expect(markup).toContain('type="button"'); + expect(markup).toContain('title="Clear queued messages without stopping active work"'); + expect(markup).toContain(">Clear queue"); + templateEventHandler(rendered, "Clear queue")(new Event("click")); + expect(onClearServerQueue).toHaveBeenCalledOnce(); + }); + + it("hides the action when the selected runtime does not support clearing", () => { + const view = new ChatView(); + view.status = queuedStatus([{ kind: "followUp", text: "server queued" }]); + view.canClearServerQueue = false; + view.onClearServerQueue = vi.fn(); + + expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue"); + }); + + it("does not expose the server action for the separate client pending-start queue", () => { + const view = new ChatView(); + view.status = queuedStatus([]); + view.clientQueuedMessages = [{ kind: "followUp", text: "waiting for session start" }]; + view.canClearServerQueue = true; + view.onClearServerQueue = vi.fn(); + + expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue"); + }); +}); + describe("chatMessageMetadataLabel", () => { it("uses one full date and model label without a model prefix", () => { const timestamp = "2026-07-10T19:15:30.000Z"; @@ -103,10 +146,17 @@ interface GroupBodyRenderCall { startIndex: number; } +type RenderQueuedMessages = (this: ChatView) => TemplateResult; type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult; type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult; type TemplateEventHandler = (event: Event) => void; +function renderQueuedMessages(view: ChatView): TemplateResult { + const method: unknown = Reflect.get(view, "renderQueuedMessages"); + if (!isRenderQueuedMessages(method)) throw new Error("ChatView.renderQueuedMessages is not callable"); + return method.call(view); +} + function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult { const method: unknown = Reflect.get(view, "renderMessageGroup"); if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable"); @@ -125,6 +175,10 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] { return calls; } +function isRenderQueuedMessages(value: unknown): value is RenderQueuedMessages { + return typeof value === "function"; +} + function isRenderMessageGroup(value: unknown): value is RenderMessageGroup { return typeof value === "function"; } @@ -134,13 +188,30 @@ function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBo } function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { - const strings = templateStrings(template); - const values = templateValues(template); - for (let index = 0; index < values.length; index += 1) { - const value = values[index]; - if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value; + let handler: TemplateEventHandler | undefined; + visit(template); + if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`); + return handler; + + function visit(value: unknown): void { + if (handler !== undefined) return; + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!isTemplateResult(value)) return; + const strings = templateStrings(value); + const values = templateValues(value); + for (let index = 0; index < values.length; index += 1) { + const candidate = values[index]; + const isNearMarker = strings[index]?.includes(marker) === true || strings[index + 1]?.includes(marker) === true; + if (isNearMarker && isTemplateEventHandler(candidate)) { + handler = candidate; + return; + } + visit(candidate); + } } - throw new Error(`Expected template event handler after ${marker}`); } function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { @@ -221,3 +292,16 @@ function isTemplateResult(value: unknown): value is TemplateResult { function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); } + +function queuedStatus(queuedMessages: QueuedSessionMessage[]): SessionStatus { + return { + sessionId: "session-1", + isStreaming: true, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: queuedMessages.length, + queuedMessages, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 8cc549e..a6e62da 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -39,6 +39,7 @@ function clampNumber(value: number, min: number, max: number): number { } export interface QueuedMessageSection { + source: "client" | "server"; heading: string; detail: string; messages: QueuedSessionMessage[]; @@ -46,8 +47,8 @@ export interface QueuedMessageSection { export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] { return [ - clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued }, - serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued }, + clientQueued.length === 0 ? undefined : { source: "client", heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued }, + serverQueued.length === 0 ? undefined : { source: "server", heading: "Queued messages", detail: `${String(serverQueued.length)} pending`, messages: serverQueued }, ].filter((section): section is QueuedMessageSection => section !== undefined); } @@ -89,6 +90,8 @@ export class ChatView extends LitElement { @property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = []; @property({ attribute: false }) status?: SessionStatus; @property({ attribute: false }) activity?: SessionActivity; + @property({ type: Boolean }) canClearServerQueue = false; + @property({ attribute: false }) onClearServerQueue?: () => void; @property({ attribute: false }) onLoadMore?: () => void; @query(".chat") private chat?: HTMLDivElement; @state() private pinnedToBottom = true; @@ -123,6 +126,9 @@ export class ChatView extends LitElement { private readonly onPageHide = () => { this.saveScrollPosition(); }; + private readonly handleClearServerQueue = (): void => { + this.onClearServerQueue?.(); + }; override connectedCallback(): void { super.connectedCallback(); @@ -261,11 +267,17 @@ export class ChatView extends LitElement { } private renderQueuedMessageList(section: QueuedMessageSection) { + const canClear = section.source === "server" && this.canClearServerQueue && this.onClearServerQueue !== undefined; return html`