fix: keep Claude Code interactive after initial prompt (#145)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-27 18:11:06 +05:30 committed by GitHub
parent d1fbb09eaf
commit 91dd7cc15f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 436 additions and 18 deletions

View File

@ -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", () => {

View File

@ -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;
}

View File

@ -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;

View File

@ -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<string | null> {
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<boolean> {
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);
},
);

View File

@ -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");
});
});
// =========================================================================

View File

@ -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(" ");
},