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:
parent
d1fbb09eaf
commit
91dd7cc15f
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue