mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
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 <minhthong@gmail.com>
---------
Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Thomas Van
parent
068f272a00
commit
1c62748084
@@ -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;
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
releaseChild = resolve;
|
||||
});
|
||||
const childAtGate = new Promise<void>((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"],
|
||||
|
||||
Reference in New Issue
Block a user