From d63b6fc5849b893fad666daead8588dba0ddcc03 Mon Sep 17 00:00:00 2001 From: Prateek Date: Fri, 27 Feb 2026 20:08:37 +0530 Subject: [PATCH] fix: reliable post-launch prompt delivery with ready-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the blind 5-second wait + unverified sendMessage with a proper polling loop that: 1. Polls for agent process existence (up to 30s) 2. Polls for agent idle state via detectActivity (up to 60s) 3. Sends the message once the agent is ready 4. Verifies delivery by checking for active state (3 attempts) 5. Logs warnings on failure instead of silently swallowing errors This reuses patterns from `ao send` (busy detection + retry) and the integration test (waitForTuiReady polling for ❯ prompt). Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/session-manager.test.ts | 102 +++++++++++--- packages/core/src/session-manager.ts | 131 ++++++++++++++++-- 2 files changed, 206 insertions(+), 27 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index a88ad5cfc..b6f70164f 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -546,14 +546,24 @@ describe("spawn", () => { it("sends prompt post-launch when agent.promptDelivery is 'post-launch'", async () => { vi.useFakeTimers(); + // detectActivity returns "idle" (agent ready), then "active" (processing prompt) + let sendMessageCalled = false; const postLaunchAgent = { ...mockAgent, promptDelivery: "post-launch" as const, + detectActivity: vi.fn().mockImplementation(() => (sendMessageCalled ? "active" : "idle")), + }; + const runtimeWithOutput: Runtime = { + ...mockRuntime, + getOutput: vi.fn().mockResolvedValue("❯ "), + sendMessage: vi.fn().mockImplementation(async () => { + sendMessageCalled = true; + }), }; const registryWithPostLaunch: PluginRegistry = { ...mockRegistry, get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; + if (slot === "runtime") return runtimeWithOutput; if (slot === "agent") return postLaunchAgent; if (slot === "workspace") return mockWorkspace; return null; @@ -562,11 +572,11 @@ describe("spawn", () => { const sm = createSessionManager({ config, registry: registryWithPostLaunch }); const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(120_000); await spawnPromise; // Prompt should be sent via runtime.sendMessage, not included in launch command - expect(mockRuntime.sendMessage).toHaveBeenCalledWith( + expect(runtimeWithOutput.sendMessage).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(String) }), expect.stringContaining("Fix the bug"), ); @@ -582,10 +592,10 @@ describe("spawn", () => { }); it("does not send prompt post-launch when no prompt is provided", async () => { - vi.useFakeTimers(); const postLaunchAgent = { ...mockAgent, promptDelivery: "post-launch" as const, + detectActivity: vi.fn().mockReturnValue("idle"), }; const registryWithPostLaunch: PluginRegistry = { ...mockRegistry, @@ -598,23 +608,22 @@ describe("spawn", () => { }; const sm = createSessionManager({ config, registry: registryWithPostLaunch }); - const spawnPromise = sm.spawn({ projectId: "my-app" }); // No prompt - await vi.advanceTimersByTimeAsync(5_000); - await spawnPromise; + await sm.spawn({ projectId: "my-app" }); // No prompt expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - vi.useRealTimers(); }); it("does not destroy session when post-launch prompt delivery fails", async () => { vi.useFakeTimers(); const failingRuntime: Runtime = { ...mockRuntime, + getOutput: vi.fn().mockResolvedValue("❯ "), sendMessage: vi.fn().mockRejectedValue(new Error("tmux send failed")), }; const postLaunchAgent = { ...mockAgent, promptDelivery: "post-launch" as const, + detectActivity: vi.fn().mockReturnValue("idle"), }; const registryWithFailingSend: PluginRegistry = { ...mockRegistry, @@ -628,7 +637,7 @@ describe("spawn", () => { const sm = createSessionManager({ config, registry: registryWithFailingSend }); const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(120_000); const session = await spawnPromise; // Session should still be returned successfully despite sendMessage failure @@ -639,16 +648,28 @@ describe("spawn", () => { vi.useRealTimers(); }); - it("waits before sending post-launch prompt", async () => { + it("polls for agent readiness before sending post-launch prompt", async () => { vi.useFakeTimers(); + // Simulate agent becoming idle after a few polls + let pollCount = 0; const postLaunchAgent = { ...mockAgent, promptDelivery: "post-launch" as const, + detectActivity: vi.fn().mockImplementation(() => { + pollCount++; + // First 2 polls: active (not ready yet), then idle + return pollCount <= 2 ? "active" : "idle"; + }), + }; + const runtimeWithOutput: Runtime = { + ...mockRuntime, + getOutput: vi.fn().mockResolvedValue("some output"), + sendMessage: vi.fn().mockResolvedValue(undefined), }; const registryWithPostLaunch: PluginRegistry = { ...mockRegistry, get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; + if (slot === "runtime") return runtimeWithOutput; if (slot === "agent") return postLaunchAgent; if (slot === "workspace") return mockWorkspace; return null; @@ -657,15 +678,58 @@ describe("spawn", () => { const sm = createSessionManager({ config, registry: registryWithPostLaunch }); const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - - // Advance only 4s — not enough, message should not have been sent yet - await vi.advanceTimersByTimeAsync(4_000); - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - - // Advance the remaining 1s — now it should fire - await vi.advanceTimersByTimeAsync(1_000); + // Advance through all polling sleeps and verification delays + await vi.advanceTimersByTimeAsync(120_000); await spawnPromise; - expect(mockRuntime.sendMessage).toHaveBeenCalled(); + + // detectActivity should have been called multiple times (polling) + expect(postLaunchAgent.detectActivity.mock.calls.length).toBeGreaterThanOrEqual(3); + // sendMessage should still have been called with the prompt + expect(runtimeWithOutput.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + expect.stringContaining("Fix the bug"), + ); + vi.useRealTimers(); + }); + + it("verifies prompt delivery by checking for active state", async () => { + vi.useFakeTimers(); + let sendCount = 0; + const postLaunchAgent = { + ...mockAgent, + promptDelivery: "post-launch" as const, + detectActivity: vi.fn().mockImplementation(() => { + // Before send: idle. After send: active (confirmed delivery) + return sendCount > 0 ? "active" : "idle"; + }), + }; + const runtimeWithOutput: Runtime = { + ...mockRuntime, + getOutput: vi.fn().mockResolvedValue("❯ "), + sendMessage: vi.fn().mockImplementation(async () => { + sendCount++; + }), + }; + const registryWithPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return runtimeWithOutput; + if (slot === "agent") return postLaunchAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const sm = createSessionManager({ config, registry: registryWithPostLaunch }); + const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + await vi.advanceTimersByTimeAsync(120_000); + await spawnPromise; + + // The prompt message should have been sent exactly once (no retries needed) + const promptCalls = (runtimeWithOutput.sendMessage as ReturnType).mock.calls.filter( + (call: unknown[]) => typeof call[1] === "string" && (call[1] as string).includes("Fix the bug"), + ); + expect(promptCalls).toHaveLength(1); vi.useRealTimers(); }); }); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index a6b5ca038..6df78a591 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -161,6 +161,122 @@ export interface SessionManagerDeps { registry: PluginRegistry; } +// ============================================================================= +// Post-launch prompt delivery with ready-detection +// ============================================================================= + +/** Timeouts for post-launch prompt delivery polling */ +const PROCESS_POLL_TIMEOUT_MS = 30_000; +const READY_POLL_TIMEOUT_MS = 60_000; +const POLL_INTERVAL_MS = 2_000; +const VERIFY_ATTEMPTS = 3; +const VERIFY_DELAY_MS = 2_000; + +/** + * Deliver a prompt to an agent after launch, with ready-detection and verification. + * + * Instead of a blind 5-second wait, this: + * 1. Polls for the agent process to exist (up to 30s) + * 2. Polls for the agent to be idle/ready for input (up to 60s) + * 3. Sends the message + * 4. Verifies delivery by checking for active state (3 attempts) + * + * Non-fatal: logs warnings on failure. The session stays alive and the user + * can retry with `ao send`. + */ +async function deliverPostLaunchPrompt( + runtime: Runtime, + agent: Agent, + handle: RuntimeHandle, + prompt: string, + sessionId: string, +): Promise { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + try { + // Step 1: Wait for the agent process to start (up to 30s) + const processDeadline = Date.now() + PROCESS_POLL_TIMEOUT_MS; + let processFound = false; + while (Date.now() < processDeadline) { + try { + if (await agent.isProcessRunning(handle)) { + processFound = true; + break; + } + } catch { + // isProcessRunning may fail if tmux session isn't ready yet + } + await sleep(POLL_INTERVAL_MS); + } + if (!processFound) { + console.warn( + `[ao] Session ${sessionId}: agent process not detected after ${PROCESS_POLL_TIMEOUT_MS / 1000}s. Sending prompt anyway.`, + ); + } + + // Step 2: Wait for the agent to be idle/ready for input (up to 60s) + const readyDeadline = Date.now() + READY_POLL_TIMEOUT_MS; + let agentReady = false; + while (Date.now() < readyDeadline) { + try { + const output = await runtime.getOutput(handle, 10); + const state = agent.detectActivity(output); + if (state === "idle") { + agentReady = true; + break; + } + // If waiting for input (permission/trust prompt), bail and send anyway — + // the agent may need the prompt to proceed + if (state === "waiting_input") { + break; + } + } catch { + // getOutput may fail transiently + } + await sleep(POLL_INTERVAL_MS); + } + if (!agentReady) { + console.warn( + `[ao] Session ${sessionId}: agent not idle after ${READY_POLL_TIMEOUT_MS / 1000}s. Sending prompt anyway.`, + ); + } + + // Step 3: Send the prompt + await runtime.sendMessage(handle, prompt); + + // Step 4: Verify delivery — check for active state (agent processing the prompt) + for (let attempt = 1; attempt <= VERIFY_ATTEMPTS; attempt++) { + await sleep(VERIFY_DELAY_MS); + try { + const output = await runtime.getOutput(handle, 10); + const state = agent.detectActivity(output); + if (state === "active") { + return; // Confirmed: agent is processing the prompt + } + } catch { + // getOutput may fail transiently + } + // On non-final attempts, press Enter in case the message wasn't submitted + if (attempt < VERIFY_ATTEMPTS) { + try { + await runtime.sendMessage(handle, ""); + } catch { + // best effort retry + } + } + } + + // Could not confirm delivery, but message was sent — log and move on + console.warn( + `[ao] Session ${sessionId}: prompt sent but could not confirm delivery. Retry with: ao send ${sessionId}`, + ); + } catch (err: unknown) { + // Non-fatal: agent is running but didn't receive the initial prompt. + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[ao] Session ${sessionId}: prompt delivery failed: ${msg}. Retry with: ao send ${sessionId}`); + } +} + /** Create a SessionManager instance. */ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const { config, registry } = deps; @@ -572,14 +688,13 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // This is intentionally outside the try/catch above — a prompt delivery failure // should NOT destroy the session. The agent is running; user can retry with `ao send`. if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) { - try { - // Wait for agent to start and be ready for input - await new Promise((resolve) => setTimeout(resolve, 5_000)); - await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt); - } catch { - // Non-fatal: agent is running but didn't receive the initial prompt. - // User can retry with `ao send`. - } + await deliverPostLaunchPrompt( + plugins.runtime, + plugins.agent, + handle, + agentLaunchConfig.prompt, + sessionId, + ); } return session;