From e82ecf509fa50d7ae26da47b445bbd0e3fff2c6a Mon Sep 17 00:00:00 2001 From: iamasx Date: Sun, 3 May 2026 22:46:38 +0530 Subject: [PATCH] fix(core): deliver prompt inline via positional arg, remove post-launch polling Claude Code's positional [prompt] argument keeps it in interactive mode; only -p/--print triggers headless one-shot exit. The entire post-launch polling mechanism was built on the wrong assumption. Changes: - Claude Code plugin: pass prompt as positional arg in getLaunchCommand - Core types: remove promptDelivery field from Agent interface - Session manager: remove post-launch polling/retry block - Prompt builder: clarify wording ("title, description, and labels" instead of "full issue details"), unify # format - Tracker-github: match prompt wording update - CLI spawn: remove dead-code promptDelivered warning Closes #1582 --- packages/cli/src/commands/spawn.ts | 11 - .../core/src/__tests__/prompt-builder.test.ts | 34 ++- .../__tests__/session-manager/spawn.test.ts | 186 +++---------- packages/core/src/prompt-builder.ts | 9 +- packages/core/src/session-manager.ts | 58 +--- packages/core/src/types.ts | 9 - .../src/prompt-delivery.integration.test.ts | 256 ------------------ .../agent-claude-code/src/index.test.ts | 34 ++- .../plugins/agent-claude-code/src/index.ts | 18 +- packages/plugins/tracker-github/src/index.ts | 2 +- 10 files changed, 115 insertions(+), 502 deletions(-) delete mode 100644 packages/integration-tests/src/prompt-delivery.integration.test.ts diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index ae5d5bb68..8ae155039 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -158,17 +158,6 @@ async function spawnSession( ); console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`); - // Warn if prompt delivery failed (for post-launch agents like Claude Code) - const promptDelivered = session.metadata?.promptDelivered; - if (promptDelivered === "false") { - console.warn( - chalk.yellow( - ` ⚠ Prompt delivery failed — agent may be idle.\n` + - ` Use '${chalk.cyan("ao send " + session.id + ' "message..."')}' to send instructions manually.`, - ), - ); - } - // Open terminal tab if requested if (openTab) { try { diff --git a/packages/core/src/__tests__/prompt-builder.test.ts b/packages/core/src/__tests__/prompt-builder.test.ts index 71e33a1a4..5274066b0 100644 --- a/packages/core/src/__tests__/prompt-builder.test.ts +++ b/packages/core/src/__tests__/prompt-builder.test.ts @@ -60,7 +60,7 @@ describe("buildPrompt split output", () => { expect(systemPrompt).not.toContain("## Additional Instructions"); expect(taskPrompt).toContain("Focus on the API layer only."); - expect(taskPrompt).not.toContain("Work on issue: INT-1343"); + expect(taskPrompt).not.toContain("Work on issue #INT-1343"); expect(taskPrompt).not.toContain("Layered Prompt System"); }); @@ -83,15 +83,16 @@ describe("buildPrompt", () => { expect(taskPrompt).toBeUndefined(); }); - it("includes base prompt when issue is provided", () => { + it("includes base prompt when issue is provided without context", () => { const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); expect(systemPrompt).toContain(BASE_AGENT_PROMPT); - expect(systemPrompt).toContain("Work on issue: INT-1343"); - expect(taskPrompt).toBe("Work on issue: INT-1343"); + expect(systemPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("Issue details were not pre-fetched"); }); it("includes project context", () => { @@ -115,18 +116,20 @@ describe("buildPrompt", () => { expect(systemPrompt).not.toContain("Repository:"); }); - it("includes issue ID in task section", () => { + it("tells agent to fetch issue when context is missing", () => { const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(systemPrompt).toContain("Work on issue: INT-1343"); + expect(systemPrompt).toContain("Work on issue #INT-1343"); expect(systemPrompt).toContain("feat/INT-1343"); - expect(taskPrompt).toBe("Work on issue: INT-1343"); + expect(taskPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("Issue details were not pre-fetched"); + expect(taskPrompt).toContain("gh issue view INT-1343"); }); - it("includes issue context when provided", () => { + it("tells agent details are pre-fetched when context is provided", () => { const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", @@ -136,10 +139,23 @@ describe("buildPrompt", () => { expect(systemPrompt).toContain("## Issue Details"); expect(systemPrompt).toContain("Layered Prompt System"); expect(systemPrompt).toContain("Priority: High"); - expect(taskPrompt).toBe("Work on issue: INT-1343"); + expect(taskPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("start implementing without re-fetching the issue"); expect(taskPrompt).not.toContain("Layered Prompt System"); }); + it("normalizes issue ID with leading # to avoid double-hash", () => { + const { systemPrompt, taskPrompt } = buildPrompt({ + project, + projectId: "test-app", + issueId: "#42", + }); + expect(systemPrompt).toContain("Work on issue #42"); + expect(systemPrompt).not.toContain("##42"); + expect(taskPrompt).toContain("Work on issue #42"); + expect(taskPrompt).not.toContain("##42"); + }); + it("includes inline agentRules", () => { project.agentRules = "Always run pnpm test before pushing."; const { systemPrompt } = buildPrompt({ diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index e9915c546..667ceb920 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -1015,33 +1015,17 @@ describe("spawn", () => { 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; + it("passes prompt inline to agent via getLaunchCommand", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + + // Prompt should be passed to getLaunchCommand, not sent via runtime.sendMessage + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("Fix the bug"), }), - }; - - 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(); + expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); }); it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => { @@ -1073,7 +1057,7 @@ describe("spawn", () => { expect(systemPrompt).toContain("Session Lifecycle"); expect(systemPrompt).toContain("## Project Context"); expect(systemPrompt).toContain("## Task"); - expect(systemPrompt).toContain("Work on issue: INT-1343"); + expect(systemPrompt).toContain("Work on issue #INT-1343"); expect(systemPrompt).not.toContain("## Additional Instructions"); }); @@ -1144,149 +1128,61 @@ describe("spawn", () => { expect(opencodeConfig.instructions[0]).toContain("worker-prompt-app-1.md"); const systemPromptPath = opencodeConfig.instructions[0]!; - expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue: INT-1343"); + expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue #INT-1343"); expect(readFileSync(systemPromptPath, "utf-8")).not.toContain("## Additional Instructions"); const agentsMdPath = getWorkspaceAgentsMdPath(workspacePath); expect(existsSync(agentsMdPath)).toBe(false); }); - it("does not send prompt post-launch when agent.promptDelivery is not set", async () => { + it("passes generated task prompt to agent for issue-only spawns", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); - await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + await sm.spawn({ projectId: "my-app", issueId: "INT-1343" }); - // Default agent (no promptDelivery) should NOT trigger sendMessage for prompt - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - }); - - it("does not send a post-launch message when no task prompt is available", 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" }); - await vi.advanceTimersByTimeAsync(5_000); - const session = await spawnPromise; - - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - expect(session.metadata.promptDelivered).toBeUndefined(); - vi.useRealTimers(); - }); - - it("sends a minimal post-launch task for issue-only spawns", 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", issueId: "INT-1343" }); - await vi.advanceTimersByTimeAsync(5_000); - const session = await spawnPromise; - - expect(mockRuntime.sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.any(String) }), - "Work on issue: INT-1343", - ); - expect(session.metadata.promptDelivered).toBe("true"); - - const callArgs = vi.mocked(postLaunchAgent.getLaunchCommand).mock.calls[0][0]; - expect(callArgs.prompt).toBe("Work on issue: INT-1343"); + const callArgs = vi.mocked(mockAgent.getLaunchCommand).mock.calls[0][0]; + expect(callArgs.prompt).toContain("Work on issue #INT-1343"); expect(callArgs.systemPromptFile).toContain("worker-prompt-app-1.md"); const systemPrompt = readFileSync(callArgs.systemPromptFile!, "utf-8"); expect(systemPrompt).toContain("## Task"); - expect(systemPrompt).toContain("Work on issue: INT-1343"); - vi.useRealTimers(); + expect(systemPrompt).toContain("Work on issue #INT-1343"); }); - it("does not destroy session when post-launch prompt delivery fails", async () => { - vi.useFakeTimers(); - const failingRuntime: Runtime = { + it("installs workspace hooks before launching the agent", async () => { + const callOrder: string[] = []; + const trackingAgent = { + ...mockAgent, + setupWorkspaceHooks: vi.fn().mockImplementation(() => { + callOrder.push("setupWorkspaceHooks"); + return Promise.resolve(); + }), + }; + const trackingRuntime: Runtime = { ...mockRuntime, - sendMessage: vi.fn().mockRejectedValue(new Error("tmux send failed")), + create: vi.fn().mockImplementation((...args: Parameters) => { + callOrder.push("runtime.create"); + return vi.mocked(mockRuntime.create)(...args); + }), }; - const postLaunchAgent = { - ...mockAgent, - promptDelivery: "post-launch" as const, - }; - const registryWithFailingSend: PluginRegistry = { + const trackingRegistry: PluginRegistry = { ...mockRegistry, get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return failingRuntime; - if (slot === "agent") return postLaunchAgent; + if (slot === "runtime") return trackingRuntime; + if (slot === "agent") return trackingAgent; if (slot === "workspace") return mockWorkspace; return null; }), }; - const sm = createSessionManager({ config, registry: registryWithFailingSend }); - const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - // With retry logic (3 attempts at 3s, 6s, 9s delays before each attempt), need to advance 18s for all retries - await vi.advanceTimersByTimeAsync(18_000); - const session = await spawnPromise; + const sm = createSessionManager({ config, registry: trackingRegistry }); + await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - // 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(); - // Verify promptDelivered is set to false in metadata - expect(session.metadata.promptDelivered).toBe("false"); - vi.useRealTimers(); - }, 30_000); - - 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 2s — not enough, message should not have been sent yet - await vi.advanceTimersByTimeAsync(2_000); - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - - // Advance the remaining 1s — now the first attempt should fire (3s total = 3000 * 1) - await vi.advanceTimersByTimeAsync(1_000); - await spawnPromise; - expect(mockRuntime.sendMessage).toHaveBeenCalled(); - vi.useRealTimers(); - }, 20_000); + const hooksIdx = callOrder.indexOf("setupWorkspaceHooks"); + const createIdx = callOrder.indexOf("runtime.create"); + expect(hooksIdx).toBeGreaterThanOrEqual(0); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(hooksIdx).toBeLessThan(createIdx); + }); describe("rollback on failure", () => { it("cleans up reserved metadata when workspace creation fails", async () => { diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index f207a8f3b..ea40ef9c4 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -116,10 +116,11 @@ function buildConfigLayer(config: PromptBuildConfig): string { } if (issueId) { + const normalizedId = issueId.replace(/^#/, ""); lines.push(`\n## Task`); - lines.push(`Work on issue: ${issueId}`); + lines.push(`Work on issue #${normalizedId}`); lines.push( - `Create a branch named so that it auto-links to the issue tracker (e.g. feat/${issueId}).`, + `Create a branch named so that it auto-links to the issue tracker (e.g. feat/${normalizedId}).`, ); } @@ -203,7 +204,9 @@ export function buildPrompt( taskPrompt: config.userPrompt ? config.userPrompt : config.issueId - ? `Work on issue: ${config.issueId}` + ? config.issueContext + ? `Work on issue #${config.issueId.replace(/^#/, "")}. The issue title, description, and labels are already in your system prompt — start implementing without re-fetching the issue. Fetch comments or linked issues only if you need additional context.` + : `Work on issue #${config.issueId.replace(/^#/, "")}. Issue details were not pre-fetched — start by reading the issue (e.g. \`gh issue view ${config.issueId.replace(/^#/, "")}\`), then implement.` : undefined, }; } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 5da9981cd..6e6551cc9 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1291,6 +1291,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await plugins.agent.preLaunchSetup(workspacePath); } + // Install workspace hooks before launching the agent so that + // PostToolUse hooks (e.g. Claude Code's metadata-updater) are + // in place before the agent's first tool call. + if (plugins.agent.setupWorkspaceHooks) { + await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir }); + } + if (plugins.agent.name !== "claude-code") { + await setupPathWrapperWorkspace(workspacePath); + } + const handle = await plugins.runtime.create({ sessionId: tmuxName ?? sessionId, // Use tmux name for runtime if available workspacePath, @@ -1408,53 +1418,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM invalidateCache(); // Past this point every resource that needed an undo is on disk in its - // final form. Dismiss the stack so the prompt-delivery loop below (which - // is intentionally non-fatal) cannot trigger a rollback. + // final form. Dismiss the stack so nothing below can trigger a rollback. cleanupStack.dismiss(); - // Send the task-specific 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). Prompt delivery failure must NOT destroy the - // session — the agent is running; user can retry with `ao send`. - let promptDelivered = false; - if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) { - const maxRetries = 3; - const baseDelayMs = 3_000; - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - // Wait for agent to start and be ready for input - // Use exponential backoff: 3s, 6s, 9s between attempts - await new Promise((resolve) => setTimeout(resolve, baseDelayMs * attempt)); - await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt); - promptDelivered = true; - break; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - console.error( - `[session-manager] Prompt delivery attempt ${attempt}/${maxRetries} failed: ${lastError.message}`, - ); - } - } - - if (!promptDelivered) { - console.error( - `[session-manager] FAILED to deliver prompt to session ${sessionId} after ${maxRetries} attempts. ` + - `User must send manually with 'ao send'. Last error: ${lastError?.message}`, - ); - } - - session.metadata["promptDelivered"] = String(promptDelivered); - } else if (agentLaunchConfig.prompt) { - session.metadata["promptDelivered"] = "true"; - } - - if (session.metadata["promptDelivered"]) { - updateMetadata(sessionsDir, sessionId, session.metadata); - invalidateCache(); - } - + // Prompt is delivered inline via the agent's launch command (positional argument). + // No post-launch polling needed — the prompt is part of process invocation. recordActivityEvent({ projectId: spawnConfig.projectId, sessionId, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b7eac5aea..40366d278 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -457,15 +457,6 @@ 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 deleted file mode 100644 index 3e5046dd3..000000000 --- a/packages/integration-tests/src/prompt-delivery.integration.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * 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 "@aoagents/ao-plugin-agent-claude-code"; -import runtimeTmuxPlugin from "@aoagents/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 8d431e14e..d671f9f4e 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -199,7 +199,6 @@ 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", () => { @@ -244,17 +243,22 @@ describe("getLaunchCommand", () => { expect(cmd).toContain("--model 'claude-opus-4-6'"); }); - it("does not include -p flag (prompt delivered post-launch)", () => { + it("includes prompt as positional argument with -- separator (not -p flag)", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the bug" })); expect(cmd).not.toContain("-p"); - expect(cmd).not.toContain("Fix the bug"); + expect(cmd).toContain("-- 'Fix the bug'"); }); - it("combines all options without prompt", () => { + it("combines all options with prompt as positional arg after --", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "permissionless", model: "opus", prompt: "Hello" }), ); - expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus'"); + expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus' -- 'Hello'"); + }); + + it("handles prompts starting with dashes safely via -- separator", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "--investigate this" })); + expect(cmd).toContain("-- '--investigate this'"); }); it("omits --dangerously-skip-permissions when permissions=default", () => { @@ -268,25 +272,24 @@ describe("getLaunchCommand", () => { expect(cmd).not.toContain("-p"); }); - it("includes --append-system-prompt alongside omitted -p", () => { + it("includes --append-system-prompt and prompt as positional arg after --", () => { 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"); + expect(cmd).toContain("-- 'Do the task'"); }); - it("uses systemPromptFile via shell substitution alongside omitted -p", () => { + it("uses systemPromptFile via shell substitution with prompt after --", () => { 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"); + expect(cmd).toContain("-- 'Do the task'"); }); }); @@ -870,14 +873,17 @@ describe("hook setup — relative path (symlink-safe)", () => { expect(hookCommand).not.toMatch(/^\//); }); - it("postLaunchSetup writes a relative hook command (not absolute)", async () => { + it("postLaunchSetup is a no-op (hooks installed pre-launch via setupWorkspaceHooks)", async () => { + mockWriteFile.mockClear(); await agent.postLaunchSetup!( makeSession({ workspacePath: "/Users/equinox/.worktrees/integrator/integrator-10" }), ); - const hookCommand = getWrittenHookCommand(); - expect(hookCommand).toBe(".claude/metadata-updater.sh"); - expect(hookCommand).not.toMatch(/^\//); + // No files should be written — hooks are installed before launch + const settingsWrites = mockWriteFile.mock.calls.filter( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), + ); + expect(settingsWrites).toHaveLength(0); }); it("different worktree paths produce identical settings.json content", async () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 18927ee79..ab5dea4e4 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -683,8 +683,6 @@ 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. // This command must be safe for both shell and execFile contexts. @@ -708,9 +706,12 @@ function createClaudeCodeAgent(): Agent { parts.push("--append-system-prompt", shellEscape(config.systemPrompt)); } - // 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). + // The positional [prompt] argument auto-submits as the first user turn + // and keeps Claude in interactive mode. -p / --print is what triggers + // headless one-shot exit, not the presence of a prompt. + if (config.prompt) { + parts.push("--", shellEscape(config.prompt)); + } return parts.join(" "); }, @@ -876,10 +877,9 @@ function createClaudeCodeAgent(): Agent { await setupHookInWorkspace(workspacePath, ".claude/metadata-updater.sh"); }, - async postLaunchSetup(session: Session): Promise { - if (!session.workspacePath) return; - - await setupHookInWorkspace(session.workspacePath, ".claude/metadata-updater.sh"); + async postLaunchSetup(_session: Session): Promise { + // Hooks are installed pre-launch via setupWorkspaceHooks so that + // PostToolUse hooks exist before the agent's first tool call. }, }; } diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index 8bb0ef4d9..06baf8aab 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -257,7 +257,7 @@ function createGitHubTracker(): Tracker { lines.push( "", - "The issue context above is complete and current. You should not need to call gh issue view unless you need additional context beyond what is provided here.", + "The issue title, description, and labels above are current. Fetch comments or linked issues via `gh` only if you need additional context beyond what is provided here.", "", "Please implement the changes described in this issue. When done, commit and push your changes.", );