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 <boudra.moha@gmail.com>
(cherry picked from commit 8de52a3c99)
This commit is contained in:
paseo-bot[bot]
2026-09-28 23:36:50 +02:00
committed by Mohamed Boudra
parent b013e0d428
commit 09f87df1b2
2 changed files with 142 additions and 4 deletions
@@ -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<void> | 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<AgentRunResult> {
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", () => {
@@ -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 = {