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;