From 09f87df1b2120d88a63c67bd3a5de79ec7da4717 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:54:22 +0100 Subject: [PATCH] Report a background send_agent_prompt's started turn as running (#5386) * Report a background send_agent_prompt's started turn as running The background branch read the target's status right after dispatch, before the provider acknowledged the turn, so an idle target read as idle. Wait for the run to start first, as create_agent and the app's send path already do. * Check the caller still gets the finish notification after a background send * Acknowledge the held turn start explicitly in the send_agent_prompt test * Cover immediate completion after background turn start --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra (cherry picked from commit 8de52a3c99086f07d0c323043b7529fa0d778c60) --- .../src/server/agent/mcp-server.test.ts | 132 ++++++++++++++++++ .../src/server/agent/tools/paseo-tools.ts | 14 +- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index e695c84048..dbe0885d5c 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -3704,18 +3704,29 @@ class HeldTurnAgentSession implements AgentSession { readonly prompts: string[] = []; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private activeTurnId: string | null = null; + private turnStartGate: Promise | null = null; constructor( readonly provider: AgentProvider, private readonly holdTurns: boolean, ) {} + /** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */ + holdTurnStart(): () => void { + let release!: () => void; + this.turnStartGate = new Promise((resolve) => { + release = resolve; + }); + return release; + } + async run(): Promise { return { sessionId: this.id, finalText: "", timeline: [] }; } async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); + await this.turnStartGate; const turnId = randomUUID(); this.activeTurnId = turnId; setTimeout(() => { @@ -4085,6 +4096,127 @@ describe("send_agent_prompt MCP tool", () => { await removeAgentStateDir(agentManager, storage, workdir); } }); + + it("reports a background prompt's accepted turn as running", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-background-send-status-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + const childSession = childClient.sessions[0]!; + const acknowledgeTurnStart = childSession.holdTurnStart(); + + const pending = invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Follow up", + }); + await vi.waitFor(() => expect(childSession.prompts).toEqual(["Follow up"])); + acknowledgeTurnStart(); + const response = await pending; + + expect(response.structuredContent).toMatchObject({ success: true, status: "running" }); + expect(agentManager.getAgent(child.id)?.lifecycle).toBe("running"); + + childSession.finishTurn(); + await vi.waitFor(() => { + const parentPrompts = parentClient.sessions[0]!.prompts; + expect(parentPrompts).toHaveLength(1); + expect(parentPrompts[0]).toContain(child.id); + expect(parentPrompts[0]).toContain("finished"); + }); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + + it("accepts a background turn completed as the manager publishes its start", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-fast-background-send-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + let finished = false; + const unsubscribe = agentManager.subscribe( + (event) => { + if ( + !finished && + event.type === "agent_state" && + event.agent.id === child.id && + event.agent.lifecycle === "running" + ) { + finished = true; + childClient.sessions[0]!.finishTurn(); + } + }, + { agentId: child.id, replayState: false }, + ); + + const response = await invokeToolWithParsedInput( + registeredTool(server, "send_agent_prompt"), + { + agentId: child.id, + prompt: "Fast reply", + }, + ); + unsubscribe(); + expect(response.structuredContent).toMatchObject({ success: true, status: "idle" }); + await vi.waitFor(() => { + const parentPrompts = parentClient.sessions[0]!.prompts; + expect(parentPrompts).toHaveLength(1); + expect(parentPrompts[0]).toContain(child.id); + expect(parentPrompts[0]).toContain("finished"); + }); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); }); describe("update_agent MCP tool", () => { diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 6ad1c60574..07f9f21098 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -61,7 +61,11 @@ import { toScheduleSummary, waitForAgentWithTimeout, } from "../mcp-shared.js"; -import { sendPromptToAgent, setupFinishNotification } from "../agent-prompt.js"; +import { + sendPromptToAgent, + setupFinishNotification, + waitForAgentRunStartWithTimeout, +} from "../agent-prompt.js"; import { respondToAgentPermission } from "../permission-response.js"; import { archiveAgentCommand, @@ -1924,7 +1928,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase return true; } - await sendPromptToAgent({ + const { disposition } = await sendPromptToAgent({ agentManager, agentStorage, agentId, @@ -1963,8 +1967,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const notifying = armFinishNotification(); - // Return immediately if background=true - // Re-fetch snapshot since the state may have changed + // Return once the provider has accepted the turn, so the status reports it running. + if (disposition === "turn_started") { + await waitForAgentRunStartWithTimeout(agentManager, agentId); + } const currentSnapshot = agentManager.getAgent(agentId); const responseData = {