fix: reliable post-launch prompt delivery with ready-detection

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 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-27 20:08:37 +05:30
parent 4cda43795b
commit d63b6fc584
2 changed files with 206 additions and 27 deletions

View File

@ -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<typeof vi.fn>).mock.calls.filter(
(call: unknown[]) => typeof call[1] === "string" && (call[1] as string).includes("Fix the bug"),
);
expect(promptCalls).toHaveLength(1);
vi.useRealTimers();
});
});

View File

@ -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<void> {
const sleep = (ms: number) => new Promise<void>((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;