From 1c62748084e50c88ed037b7eafca16916fea2ca2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:54:02 +0200 Subject: [PATCH] Notify callers when detached agents finish (#7528) * fix(server): notify the caller when a detached child finishes `create_agent` with a detached relationship and `notifyOnFinish` arms a finish watcher with `requireParentOwnership: true`. At fire time that guard checks that the child is still labelled as the caller's, but resolveCreateAgentIntent strips the parent label from a detached child on purpose, so the check can never pass and the caller never hears the child it created finish. The create path now requires parent ownership only for a child created with a parent. The guard itself is unchanged: a parented child whose label later points elsewhere stays silent, as agent-prompt.test.ts ("detaching a child ends its parent-owned finish notification") requires. Only the create call knows the caller asked for a detached child, which is why the fix lives there. Tests drive the real finish watcher from createAgentCommand, with the finish published as the manager publishes it: a detached child notifies its caller once, and a parented child moved to another parent does not. Requiring ownership always fails the first; never requiring it fails the second. Fixes #6908 * test(server): verify detached completion through agent-scoped MCP Exercise creation, stored parent labels, provider completion, and caller delivery with the real daemon and injected provider clients. Cover detached and parented creation and suppression after reparenting without replacing manager methods. Co-authored-by: Thomas Van --------- Co-authored-by: Thomas Van Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/create-agent/create.ts | 3 +- .../src/server/agent/mcp-parity.e2e.test.ts | 118 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 05f98285e1..b67935a0a8 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -206,7 +206,8 @@ export async function createAgentCommand( agentStorage: dependencies.agentStorage, childAgentId: snapshot.id, callerAgentId: input.callerAgentId, - requireParentOwnership: true, + // Detached agents have no parent label, but still notify their creator. + requireParentOwnership: !(input.detached ?? false), logger: dependencies.logger, }) : undefined; diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index bfb6be7964..efb14e6d31 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -313,6 +313,124 @@ afterAll(async () => { }); describe("Suite A: Core Fixes", () => { + test.each([ + { relationship: "detached", reparent: false, notifications: 1 }, + { relationship: "subagent", reparent: false, notifications: 1 }, + { relationship: "subagent", reparent: true, notifications: 0 }, + ] as const)( + "create_agent $relationship (reparent=$reparent) sends $notifications finish notification(s)", + async ({ relationship, reparent, notifications }) => { + let releaseChild!: () => void; + let childReachedGate!: () => void; + const heldTurn = new Promise((resolve) => { + releaseChild = resolve; + }); + const childAtGate = new Promise((resolve) => { + childReachedGate = resolve; + }); + const callerPrompts: string[] = []; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn(prompt, config) { + if (config.title === "Notification caller" && typeof prompt === "string") + callerPrompts.push(prompt); + }, + async beforeTurnComplete(prompt) { + if ( + typeof prompt === "string" && + parseAgentMessage(prompt).text === "Hold notification child" + ) { + childReachedGate(); + await heldTurn; + } + }, + }), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let mcp: McpClient | undefined; + try { + await client.connect(); + const caller = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Notification caller", + modeId: "bypassPermissions", + }); + mcp = await createMcpClient( + buildExpectedAgentMcpUrl({ + host: "127.0.0.1", + port: daemon.port, + agentId: caller.id, + }), + ); + const created = await callToolStructured(mcp, "create_agent", { + relationship: { kind: relationship }, + workspace: { kind: "current" }, + provider: "codex/gpt-5.4", + title: "Notification child", + initialPrompt: "Hold notification child", + notifyOnFinish: true, + settings: { modeId: "full-access" }, + }); + const childId = str(created.agentId); + await childAtGate; + expect(daemon.daemon.agentManager.getAgent(childId)?.labels?.[PARENT_AGENT_ID_LABEL]).toBe( + relationship === "subagent" ? caller.id : undefined, + ); + if (reparent) { + const other = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Other parent", + modeId: "bypassPermissions", + }); + await daemon.daemon.agentManager.setLabels(childId, { + [PARENT_AGENT_ID_LABEL]: other.id, + }); + } + releaseChild(); + await waitFor({ + timeoutMs: 10000, + label: "child completion", + check: () => + daemon.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null, + }); + if (notifications) { + await waitFor({ + timeoutMs: 10000, + label: "caller finish notification", + check: () => (callerPrompts.length > 0 ? true : null), + }); + expect(parseAgentMessage(callerPrompts[0]).source).toEqual({ + kind: "agent-notification", + agentId: childId, + title: "Notification child", + event: "finished", + }); + expect(parseAgentMessage(callerPrompts[0]).text).toContain( + "Notification child) finished.", + ); + await waitFor({ + timeoutMs: 10000, + label: "caller notification drained", + check: () => + daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null, + }); + } else { + // Allow the terminal event's asynchronous storage reads and notification dispatch to drain. + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(callerPrompts).toHaveLength(notifications); + } finally { + releaseChild(); + await mcp?.close(); + await client.close(); + await daemon.close(); + } + }, + 30000, + ); + test.each([ ["Stop", "cancelAgent"], ["Reload", "refreshAgent"],