Keep delegated-agent notifications alive through permission prompts (#3177)

* fix(agents): preserve permission notification callbacks

Permission requests consumed the one-shot completion watcher, so callers never heard about later approvals or the final result. Keep the watcher alive across permission checkpoints and include the actionable request payload in each notification.

* fix(agents): notify concurrent permission requests
This commit is contained in:
Mohamed Boudra
2026-08-11 00:14:27 +02:00
committed by GitHub
parent 6aa9f92156
commit 334bf62370
3 changed files with 261 additions and 24 deletions
+2
View File
@@ -49,6 +49,8 @@ Runtime ownership is resolved from explicit workspace ID and caller context, nev
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. `notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
## Provider-managed child agents ## Provider-managed child agents
@@ -46,8 +46,13 @@ interface FinishNotificationScenarioOptions {
interface FinishNotificationScenario { interface FinishNotificationScenario {
startWatchingChild(): void; startWatchingChild(): void;
requestChildPermission(requestId?: string): void;
resolveChildPermission(requestId?: string): void;
resolveChildPermissionFromState(requestId?: string): void;
resolveChildPermissionWhileIdle(requestId?: string): void;
finishChild(): void; finishChild(): void;
finishChildAndReadParentPrompt(): Promise<string>; finishChildAndReadParentPrompt(): Promise<string>;
parentPrompts(): string[];
wasParentPrompted(): boolean; wasParentPrompted(): boolean;
} }
@@ -57,11 +62,13 @@ function createFinishNotificationScenario(
let subscriber: ((event: AgentManagerEvent) => void) | null = null; let subscriber: ((event: AgentManagerEvent) => void) | null = null;
let resolveParentPrompt: ((prompt: string) => void) | null = null; let resolveParentPrompt: ((prompt: string) => void) | null = null;
let parentPrompted = false; let parentPrompted = false;
const parentPrompts: string[] = [];
const childAgent: ManagedAgent = Object.create(null); const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent"); Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "lifecycle", "idle"); Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" }); Reflect.set(childAgent, "config", { title: "Child Agent" });
Reflect.set(childAgent, "pendingPermissions", new Map());
const callerAgent: ManagedAgent = Object.create(null); const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent"); Reflect.set(callerAgent, "id", "caller-agent");
@@ -91,6 +98,7 @@ function createFinishNotificationScenario(
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
parentPrompted = true; parentPrompted = true;
parentPrompts.push(prompt);
resolveParentPrompt?.(prompt); resolveParentPrompt?.(prompt);
return (async function* noop() {})(); return (async function* noop() {})();
}); });
@@ -123,6 +131,65 @@ function createFinishNotificationScenario(
logger: options?.logger ?? createTestLogger(), logger: options?.logger ?? createTestLogger(),
}); });
}, },
requestChildPermission(requestId = "permission-1") {
childAgent.lifecycle = "running";
childAgent.pendingPermissions.set(requestId, {
id: requestId,
provider: "claude",
kind: "tool",
name: "Run command",
description: "Write the QA sentinel",
input: {
file_path: "/tmp/permission-qa.txt",
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
},
});
subscriber?.({
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_requested",
provider: "codex",
request: childAgent.pendingPermissions.get(requestId)!,
},
});
},
resolveChildPermission(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_resolved",
provider: "codex",
requestId,
resolution: { behavior: "allow" },
},
});
},
resolveChildPermissionFromState(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
subscriber?.({ type: "agent_state", agent: childAgent });
},
resolveChildPermissionWhileIdle(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
childAgent.lifecycle = "idle";
subscriber?.({ type: "agent_state", agent: childAgent });
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_resolved",
provider: "codex",
requestId,
resolution: { behavior: "allow" },
},
});
},
finishChild() { finishChild() {
childAgent.lifecycle = "running"; childAgent.lifecycle = "running";
subscriber?.({ subscriber?.({
@@ -144,6 +211,9 @@ function createFinishNotificationScenario(
return parentPrompt; return parentPrompt;
}, },
parentPrompts() {
return parentPrompts;
},
wasParentPrompted() { wasParentPrompted() {
return parentPrompted; return parentPrompted;
}, },
@@ -209,6 +279,107 @@ test("finish notifications tell the parent the child's last assistant message",
); );
}); });
test("finish notifications survive permission responses", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => {
expect(scenario.parentPrompts()).toHaveLength(1);
});
expect(scenario.parentPrompts()[0]).toContain("needs permission.");
const permissionPayload = scenario
.parentPrompts()[0]
.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1];
expect(permissionPayload).toBeDefined();
expect(JSON.parse(permissionPayload!)).toEqual({
agentId: "child-agent",
requestId: "permission-1",
request: {
id: "permission-1",
provider: "claude",
kind: "tool",
name: "Run command",
description: "Write the QA sentinel",
input: {
file_path: "/tmp/permission-qa.txt",
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
},
},
});
scenario.resolveChildPermission();
scenario.finishChild();
await vi.waitFor(() => {
expect(scenario.parentPrompts()).toHaveLength(2);
});
expect(scenario.parentPrompts()[1]).toContain("finished.");
});
test("an idle permission resolution waits for the resumed run to finish", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
scenario.resolveChildPermissionWhileIdle();
scenario.requestChildPermission("permission-2");
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
expect(scenario.parentPrompts().every((prompt) => prompt.includes("needs permission."))).toBe(
true,
);
scenario.resolveChildPermission("permission-2");
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(scenario.parentPrompts()[2]).toContain("finished.");
});
test("finish notifications report every concurrently pending permission", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission("permission-1");
scenario.requestChildPermission("permission-2");
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
expect(
scenario.parentPrompts().map((prompt) => {
const payload = prompt.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1];
return JSON.parse(payload!).requestId;
}),
).toEqual(["permission-1", "permission-2"]);
scenario.resolveChildPermission("permission-1");
scenario.resolveChildPermission("permission-2");
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(scenario.parentPrompts()[2]).toContain("finished.");
});
test("finish notifications survive repeated permission cycles", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
scenario.resolveChildPermissionFromState();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
scenario.resolveChildPermission();
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(
scenario.parentPrompts().map((prompt) => prompt.match(/(needs permission|finished)\./)?.[1]),
).toEqual(["needs permission", "needs permission", "finished"]);
});
test("detaching a child ends its parent-owned finish notification", async () => { test("detaching a child ends its parent-owned finish notification", async () => {
const scenario = createFinishNotificationScenario({ const scenario = createFinishNotificationScenario({
childParentAgentId: null, childParentAgentId: null,
@@ -258,6 +429,7 @@ it("does not notify archived callers", async () => {
Reflect.set(childAgent, "id", "child-agent"); Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "lifecycle", "idle"); Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" }); Reflect.set(childAgent, "config", { title: "Child Agent" });
Reflect.set(childAgent, "pendingPermissions", new Map());
const callerAgent: ManagedAgent = Object.create(null); const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent"); Reflect.set(callerAgent, "id", "caller-agent");
@@ -1,6 +1,10 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js"; import type {
AgentPermissionRequest,
AgentPromptInput,
AgentRunOptions,
} from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js"; import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js"; import { ensureAgentLoaded } from "./agent-loading.js";
@@ -254,15 +258,36 @@ interface FinishNotificationBodyInput {
title: string; title: string;
reason: "finished" | "errored" | "needs permission"; reason: "finished" | "errored" | "needs permission";
lastAssistantMessage: string | null; lastAssistantMessage: string | null;
permissionRequest?: AgentPermissionRequest;
} }
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
const lastAssistantMessage = params.lastAssistantMessage?.trim(); const sections = [statusLine];
if (!lastAssistantMessage) { if (params.reason === "needs permission" && params.permissionRequest) {
return statusLine; sections.push(
"Respond with `respond_to_permission` using the `agentId` and `requestId` below.",
`<permission-request>\n${JSON.stringify(
{
agentId: params.childAgentId,
requestId: params.permissionRequest.id,
request: params.permissionRequest,
},
null,
2,
)}\n</permission-request>`,
);
} }
return `${statusLine}\n\n<agent-response>\n${lastAssistantMessage}\n</agent-response>`; const lastAssistantMessage = params.lastAssistantMessage?.trim();
if (lastAssistantMessage) {
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
}
return sections.join("\n\n");
}
interface NotifySafelyOptions {
terminal?: boolean;
permissionRequest?: AgentPermissionRequest;
} }
export function setupFinishNotification(params: SetupFinishNotificationParams): void { export function setupFinishNotification(params: SetupFinishNotificationParams): void {
@@ -275,16 +300,21 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
logger, logger,
} = params; } = params;
let hasSeenRunning = false; let hasSeenRunning = false;
let fired = false; let stopped = false;
const notifiedPermissionRequestIds = new Set<string>();
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
let notificationQueue = Promise.resolve();
async function notify(reason: "finished" | "errored" | "needs permission"): Promise<void> { function stop(): void {
if (fired) { if (stopped) return;
return; stopped = true;
}
fired = true;
unsubscribe?.(); unsubscribe?.();
}
async function notify(
reason: "finished" | "errored" | "needs permission",
permissionRequest?: AgentPermissionRequest,
): Promise<void> {
const callerRecord = await agentStorage.get(callerAgentId); const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) { if (callerRecord?.archivedAt) {
return; return;
@@ -301,6 +331,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
title, title,
reason, reason,
lastAssistantMessage, lastAssistantMessage,
permissionRequest,
}); });
await sendPromptToAgent({ await sendPromptToAgent({
@@ -313,24 +344,38 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
}); });
} }
function notifySafely(reason: "finished" | "errored" | "needs permission"): void { function notifySafely(
void notify(reason).catch((error) => { reason: "finished" | "errored" | "needs permission",
logger.error( options: NotifySafelyOptions = {},
{ err: error, childAgentId, callerAgentId, reason }, ): void {
"Failed to notify caller agent", if (stopped) return;
); if (options.terminal ?? true) stop();
}); notificationQueue = notificationQueue
.then(() => notify(reason, options.permissionRequest))
.catch((error) => {
logger.error(
{ err: error, childAgentId, callerAgentId, reason },
"Failed to notify caller agent",
);
});
} }
unsubscribe = agentManager.subscribe( unsubscribe = agentManager.subscribe(
(event) => { (event) => {
if (fired) { if (stopped) {
return; return;
} }
if (event.type === "agent_state") { if (event.type === "agent_state") {
for (const requestId of notifiedPermissionRequestIds) {
if (!event.agent.pendingPermissions.has(requestId)) {
notifiedPermissionRequestIds.delete(requestId);
}
}
if (event.agent.lifecycle === "running") { if (event.agent.lifecycle === "running") {
hasSeenRunning = true; if (event.agent.pendingPermissions.size === 0) {
hasSeenRunning = true;
}
return; return;
} }
if (event.agent.lifecycle === "error") { if (event.agent.lifecycle === "error") {
@@ -342,15 +387,33 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return; return;
} }
if (event.agent.lifecycle === "closed") { if (event.agent.lifecycle === "closed") {
fired = true; stop();
unsubscribe?.();
return; return;
} }
return; return;
} }
if (event.event.type === "permission_requested") { if (event.event.type === "permission_requested") {
notifySafely("needs permission"); // A permission pause is an intermediate checkpoint. Forget the run
// observed before it so an idle state during follow-up startup cannot
// masquerade as the final completion.
hasSeenRunning = false;
if (!notifiedPermissionRequestIds.has(event.event.request.id)) {
notifiedPermissionRequestIds.add(event.event.request.id);
notifySafely("needs permission", {
terminal: false,
permissionRequest: event.event.request,
});
}
return;
}
if (event.event.type === "permission_resolved") {
notifiedPermissionRequestIds.delete(event.event.requestId);
const childAgent = agentManager.getAgent(childAgentId);
if (childAgent?.pendingPermissions.size === 0) {
hasSeenRunning = childAgent.lifecycle === "running";
}
} }
}, },
{ agentId: childAgentId, replayState: false }, { agentId: childAgentId, replayState: false },
@@ -363,7 +426,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
// transitioning to "running"). // transitioning to "running").
const childSnapshot = agentManager.getAgent(childAgentId); const childSnapshot = agentManager.getAgent(childAgentId);
if (!childSnapshot || childSnapshot.lifecycle === "closed") { if (!childSnapshot || childSnapshot.lifecycle === "closed") {
unsubscribe(); stop();
return; return;
} }
if (childSnapshot.lifecycle === "running") { if (childSnapshot.lifecycle === "running") {