From 91dd7cc15f6370fbbbaf997abc6308cf7f88e0c0 Mon Sep 17 00:00:00 2001 From: prateek Date: Fri, 27 Feb 2026 18:11:06 +0530 Subject: [PATCH] fix: keep Claude Code interactive after initial prompt (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: keep Claude Code interactive after initial prompt Claude Code's -p flag runs in one-shot mode (exits after responding), which prevents follow-up messages via `ao send`. Instead, launch Claude interactively and deliver the initial prompt post-launch via runtime.sendMessage(). Adds `promptDelivery` property to the Agent interface so each agent plugin can declare whether prompts should be inlined in the launch command or sent after the agent starts. Co-Authored-By: Claude Opus 4.6 * fix: make post-launch prompt delivery non-fatal and add test coverage - Move sendMessage call outside the try/catch that destroys the session. A prompt delivery failure should not kill a running agent — user can retry with `ao send`. - Add tests: no-prompt + post-launch agent, sendMessage failure resilience, 5s delay verification, systemPrompt/systemPromptFile alongside omitted -p. Co-Authored-By: Claude Opus 4.6 * test: add integration test for prompt delivery (proves bug and fix) Two real-Claude integration tests that contrast: 1. `-p` mode: Claude exits after responding (the bug) 2. Interactive + sendMessage: Claude stays alive, follow-up works (the fix) Runs in CI with ANTHROPIC_API_KEY. Skips when prerequisites missing. Co-Authored-By: Claude Opus 4.6 * fix(test): skip interactive test without auth, improve TUI readiness detection The integration test failed in CI because interactive Claude requires full login auth (not just ANTHROPIC_API_KEY). Skip the interactive suite when `claude auth status` reports not logged in. Also fix local flakiness: replace blind 5s sleep with polling for Claude's TUI prompt character (❯) before sending the first message, and increase scrollback capture from 200 to 500 lines. Co-Authored-By: Claude Opus 4.6 * fix(test): skip interactive test in CI, fix TUI readiness detection The interactive test ran in CI despite hasInteractiveAuth() — Claude reports logged in when ANTHROPIC_API_KEY is set, but interactive mode requires OAuth. Use `!process.env.CI` as the skip condition instead. Also fix waitForTuiReady false positive: the OAuth screen's "Paste code here if prompted >" matched the `>` regex. Now checks the last non-empty line for Claude's specific ❯ prompt character, and bails early if the OAuth/login screen is detected. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../src/__tests__/session-manager.test.ts | 125 +++++++++ packages/core/src/session-manager.ts | 15 + packages/core/src/types.ts | 9 + .../src/prompt-delivery.integration.test.ts | 256 ++++++++++++++++++ .../agent-claude-code/src/index.test.ts | 42 ++- .../plugins/agent-claude-code/src/index.ts | 7 +- 6 files changed, 436 insertions(+), 18 deletions(-) create mode 100644 packages/integration-tests/src/prompt-delivery.integration.test.ts diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 5722ebaaa..facf4364f 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -435,6 +435,131 @@ describe("spawn", () => { expect(session.branch).toMatch(/^session\/app-\d+$/); expect(session.branch).not.toBe("main"); }); + + it("sends prompt post-launch when agent.promptDelivery is 'post-launch'", async () => { + vi.useFakeTimers(); + const postLaunchAgent = { + ...mockAgent, + promptDelivery: "post-launch" as const, + }; + const registryWithPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + 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(5_000); + await spawnPromise; + + // Prompt should be sent via runtime.sendMessage, not included in launch command + expect(mockRuntime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + expect.stringContaining("Fix the bug"), + ); + vi.useRealTimers(); + }); + + it("does not send prompt post-launch when agent.promptDelivery is not set", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + + // Default agent (no promptDelivery) should NOT trigger sendMessage for prompt + expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); + }); + + it("does not send prompt post-launch when no prompt is provided", async () => { + vi.useFakeTimers(); + const postLaunchAgent = { + ...mockAgent, + promptDelivery: "post-launch" as const, + }; + const registryWithPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + 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" }); // No prompt + await vi.advanceTimersByTimeAsync(5_000); + await spawnPromise; + + 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, + sendMessage: vi.fn().mockRejectedValue(new Error("tmux send failed")), + }; + const postLaunchAgent = { + ...mockAgent, + promptDelivery: "post-launch" as const, + }; + const registryWithFailingSend: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return failingRuntime; + if (slot === "agent") return postLaunchAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const sm = createSessionManager({ config, registry: registryWithFailingSend }); + const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + await vi.advanceTimersByTimeAsync(5_000); + const session = await spawnPromise; + + // Session should still be returned successfully despite sendMessage failure + expect(session.id).toBe("app-1"); + expect(session.status).toBe("spawning"); + // Runtime should NOT have been destroyed + expect(failingRuntime.destroy).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("waits before sending post-launch prompt", async () => { + vi.useFakeTimers(); + const postLaunchAgent = { + ...mockAgent, + promptDelivery: "post-launch" as const, + }; + const registryWithPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + 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" }); + + // 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); + await spawnPromise; + expect(mockRuntime.sendMessage).toHaveBeenCalled(); + vi.useRealTimers(); + }); }); describe("list", () => { diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 9ccc2c007..67c745464 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -555,6 +555,21 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { throw err; } + // Send initial prompt post-launch for agents that need it (e.g. Claude Code + // exits after -p, so we send the prompt after it starts in interactive mode). + // 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`. + } + } + return session; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7860db3a0..e83efc172 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -265,6 +265,15 @@ export interface Agent { /** Process name to look for (e.g. "claude", "codex", "aider") */ readonly processName: string; + /** + * How the initial prompt should be delivered to the agent. + * - "inline" (default): prompt is included in the launch command (e.g. -p flag) + * - "post-launch": prompt is sent via runtime.sendMessage() after the agent starts, + * keeping the agent in interactive mode. Use this for agents where inlining + * the prompt causes one-shot/exit behavior (e.g. Claude Code's -p flag). + */ + readonly promptDelivery?: "inline" | "post-launch"; + /** Get the shell command to launch this agent */ getLaunchCommand(config: AgentLaunchConfig): string; diff --git a/packages/integration-tests/src/prompt-delivery.integration.test.ts b/packages/integration-tests/src/prompt-delivery.integration.test.ts new file mode 100644 index 000000000..d045f3936 --- /dev/null +++ b/packages/integration-tests/src/prompt-delivery.integration.test.ts @@ -0,0 +1,256 @@ +/** + * Integration test: Claude Code prompt delivery — `-p` (one-shot) vs interactive mode. + * + * Demonstrates the bug and the fix: + * + * 1. "claude -p exits after work" — launches Claude with -p, proves the process + * exits after responding. This is the bug: the agent can't receive follow-ups. + * + * 2. "interactive claude stays alive" — launches Claude without -p, delivers + * the prompt post-launch via runtime.sendMessage(), proves the process stays + * alive and can receive follow-up messages. + * + * Requires: tmux, claude binary, ANTHROPIC_API_KEY. + */ + +import { execFile } from "node:child_process"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import claudeCodePlugin from "@composio/ao-plugin-agent-claude-code"; +import runtimeTmuxPlugin from "@composio/ao-plugin-runtime-tmux"; +import { + isTmuxAvailable, + killSessionsByPrefix, + createSession, + killSession, + capturePane, +} from "./helpers/tmux.js"; +import { pollUntilEqual, sleep } from "./helpers/polling.js"; +import { makeTmuxHandle } from "./helpers/session-factory.js"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Prerequisites +// --------------------------------------------------------------------------- + +const SESSION_PREFIX = "ao-inttest-prompt-"; + +/** Lines of scrollback to capture — generous to account for Claude's TUI output. */ +const CAPTURE_LINES = 500; + +async function findClaudeBinary(): Promise { + try { + await execFileAsync("which", ["claude"], { timeout: 5_000 }); + return "claude"; + } catch { + return null; + } +} + +/** + * Wait for Claude Code's TUI to be ready for input. + * Polls the tmux pane for Claude's prompt character (❯) on its own line. + * Returns false if the OAuth/login screen is detected instead. + */ +async function waitForTuiReady( + sessionName: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const output = await capturePane(sessionName, CAPTURE_LINES); + + // Bail early if OAuth/login screen is showing — TUI won't become ready + if (/sign in|oauth|Paste code here/i.test(output)) return false; + + // Claude Code shows ❯ when idle. Check the last non-empty line specifically + // (not the end of the full output) to avoid matching "prompted >" etc. + const lastLine = output + .split("\n") + .filter((l) => l.trim().length > 0) + .pop(); + if (lastLine && /^\s*❯\s*$/.test(lastLine)) return true; + + await sleep(2_000); + } + return false; +} + +const tmuxOk = await isTmuxAvailable(); +const claudeBin = await findClaudeBinary(); +const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY); +const canRun = tmuxOk && claudeBin !== null && hasApiKey; +// Interactive mode requires OAuth login. ANTHROPIC_API_KEY alone only works with -p. +// CI environments (GitHub Actions) typically only have API keys, not OAuth sessions. +const canRunInteractive = canRun && !process.env.CI; + +// --------------------------------------------------------------------------- +// Test 1: -p flag causes Claude to exit (the bug) +// --------------------------------------------------------------------------- + +describe.skipIf(!canRun)("claude -p exits after completing work (the bug)", () => { + const agent = claudeCodePlugin.create(); + const sessionName = `${SESSION_PREFIX}print-${Date.now()}`; + let tmpDir: string; + + beforeAll(async () => { + await killSessionsByPrefix(`${SESSION_PREFIX}print-`); + const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-print-")); + tmpDir = await realpath(raw); + + // Create a bash session (keeps tmux pane alive after Claude exits) + await createSession(sessionName, "bash", tmpDir); + await sleep(500); + + // Launch Claude with -p (print-and-exit mode) + await execFileAsync( + "tmux", + [ + "send-keys", + "-t", + sessionName, + "-l", + "CLAUDECODE= claude --dangerously-skip-permissions -p 'Respond with exactly: SENTINEL_P_DELIVERED'", + ], + { timeout: 10_000 }, + ); + await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], { + timeout: 5_000, + }); + }, 30_000); + + afterAll(async () => { + await killSession(sessionName); + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }, 30_000); + + it("claude exits after responding to -p prompt", async () => { + const handle = makeTmuxHandle(sessionName); + + // Wait for Claude to start + await pollUntilEqual(() => agent.isProcessRunning(handle), true, { + timeoutMs: 30_000, + intervalMs: 1_000, + }); + + // Wait for it to exit — -p mode always exits after responding + const exited = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { + timeoutMs: 90_000, + intervalMs: 2_000, + }); + expect(exited).toBe(false); + + // Verify the work was done (Claude did respond before exiting) + const output = await capturePane(sessionName, CAPTURE_LINES); + expect(output).toContain("SENTINEL_P_DELIVERED"); + + // This is the problem: process is gone, ao send has nothing to talk to + const running = await agent.isProcessRunning(handle); + expect(running).toBe(false); + }, 120_000); +}); + +// --------------------------------------------------------------------------- +// Test 2: interactive mode + post-launch prompt keeps Claude alive (the fix) +// --------------------------------------------------------------------------- + +describe.skipIf(!canRunInteractive)( + "interactive claude stays alive after post-launch prompt (the fix)", + () => { + const agent = claudeCodePlugin.create(); + const runtime = runtimeTmuxPlugin.create(); + const sessionName = `${SESSION_PREFIX}interactive-${Date.now()}`; + let tmpDir: string; + const handle = makeTmuxHandle(sessionName); + + beforeAll(async () => { + await killSessionsByPrefix(`${SESSION_PREFIX}interactive-`); + const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-interactive-")); + tmpDir = await realpath(raw); + + // Create a bash session + await createSession(sessionName, "bash", tmpDir); + await sleep(500); + + // Launch Claude WITHOUT -p (interactive mode) — this is the fix + await execFileAsync( + "tmux", + [ + "send-keys", + "-t", + sessionName, + "-l", + "CLAUDECODE= claude --dangerously-skip-permissions", + ], + { timeout: 10_000 }, + ); + await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], { + timeout: 5_000, + }); + + // Wait for Claude to start + await pollUntilEqual(() => agent.isProcessRunning(handle), true, { + timeoutMs: 30_000, + intervalMs: 1_000, + }); + + // Wait for Claude's TUI to be fully ready (shows prompt character ❯) + await waitForTuiReady(sessionName, 60_000); + + // Deliver the initial prompt post-launch (what the fix does) + await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_I_DELIVERED"); + + // Wait for Claude to process the prompt and return to idle (❯) + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + const output = await capturePane(sessionName, CAPTURE_LINES); + // Check that Claude responded (sentinel in output) AND is back at idle prompt (❯) + const lastLine = output + .split("\n") + .filter((l) => l.trim().length > 0) + .pop(); + if (output.includes("SENTINEL_I_DELIVERED") && lastLine && /^\s*❯\s*$/.test(lastLine)) { + break; + } + await sleep(2_000); + } + }, 180_000); + + afterAll(async () => { + await killSession(sessionName); + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }, 30_000); + + it("prompt was delivered and processed", async () => { + const output = await capturePane(sessionName, CAPTURE_LINES); + expect(output).toContain("SENTINEL_I_DELIVERED"); + }); + + it("claude is still running after completing the task", async () => { + const running = await agent.isProcessRunning(handle); + expect(running).toBe(true); + }); + + it("can receive and process a follow-up message", async () => { + await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_FOLLOWUP_OK"); + + const deadline = Date.now() + 90_000; + let output = ""; + while (Date.now() < deadline) { + output = await capturePane(sessionName, CAPTURE_LINES); + if (output.includes("SENTINEL_FOLLOWUP_OK")) break; + await sleep(2_000); + } + + expect(output).toContain("SENTINEL_FOLLOWUP_OK"); + + // Still alive after follow-up + const stillRunning = await agent.isProcessRunning(handle); + expect(stillRunning).toBe(true); + }, 120_000); + }, +); diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 937c86ef0..8d1a3bd80 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -125,6 +125,7 @@ describe("plugin manifest & exports", () => { const agent = create(); expect(agent.name).toBe("claude-code"); expect(agent.processName).toBe("claude"); + expect(agent.promptDelivery).toBe("post-launch"); }); it("default export is a valid PluginModule", () => { @@ -157,27 +158,17 @@ describe("getLaunchCommand", () => { expect(cmd).toContain("--model 'claude-opus-4-6'"); }); - it("shell-escapes prompt argument", () => { + it("does not include -p flag (prompt delivered post-launch)", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the bug" })); - expect(cmd).toContain("-p 'Fix the bug'"); + expect(cmd).not.toContain("-p"); + expect(cmd).not.toContain("Fix the bug"); }); - it("escapes dangerous characters in prompt", () => { - const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" })); - // Single-quoted strings prevent shell expansion - expect(cmd).toContain("-p '$(rm -rf /); `evil`; $HOME'"); - }); - - it("escapes single quotes in prompt using POSIX method", () => { - const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's a test" })); - expect(cmd).toContain("-p 'it'\\''s a test'"); - }); - - it("combines all options", () => { + it("combines all options without prompt", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "skip", model: "opus", prompt: "Hello" }), ); - expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus' -p 'Hello'"); + expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus'"); }); it("omits optional flags when not provided", () => { @@ -186,6 +177,27 @@ describe("getLaunchCommand", () => { expect(cmd).not.toContain("--model"); expect(cmd).not.toContain("-p"); }); + + it("includes --append-system-prompt alongside omitted -p", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPrompt: "You are a helper", prompt: "Do the task" }), + ); + expect(cmd).toContain("--append-system-prompt"); + expect(cmd).toContain("You are a helper"); + // -p as a standalone flag (not substring of --append-system-prompt) + expect(cmd).not.toMatch(/\s-p\s/); + expect(cmd).not.toContain("Do the task"); + }); + + it("uses systemPromptFile via shell substitution alongside omitted -p", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", prompt: "Do the task" }), + ); + expect(cmd).toContain('--append-system-prompt "$(cat'); + expect(cmd).toContain("/tmp/prompt.md"); + expect(cmd).not.toMatch(/\s-p\s/); + expect(cmd).not.toContain("Do the task"); + }); }); // ========================================================================= diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index cb2abbc26..0d9d1e7c5 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -582,6 +582,7 @@ function createClaudeCodeAgent(): Agent { return { name: "claude-code", processName: "claude", + promptDelivery: "post-launch", getLaunchCommand(config: AgentLaunchConfig): string { // Note: CLAUDECODE is unset via getEnvironment() (set to ""), not here. @@ -605,9 +606,9 @@ function createClaudeCodeAgent(): Agent { parts.push("--append-system-prompt", shellEscape(config.systemPrompt)); } - if (config.prompt) { - parts.push("-p", shellEscape(config.prompt)); - } + // NOTE: prompt is NOT included here — it's delivered post-launch via + // runtime.sendMessage() to keep Claude in interactive mode. + // Using -p causes one-shot mode (Claude exits after responding). return parts.join(" "); },