fix(core): deliver prompt inline via positional arg, remove post-launch polling (#1583)
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
This commit is contained in:
parent
b9b20e19d1
commit
1981d471ca
|
|
@ -248,17 +248,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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1098,33 +1098,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 () => {
|
||||
|
|
@ -1156,7 +1140,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");
|
||||
});
|
||||
|
||||
|
|
@ -1227,149 +1211,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<Runtime["create"]>) => {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1292,6 +1292,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,
|
||||
|
|
@ -1410,53 +1420,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,
|
||||
|
|
|
|||
|
|
@ -464,15 +464,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (!session.workspacePath) return;
|
||||
|
||||
await setupHookInWorkspace(session.workspacePath, ".claude/metadata-updater.sh");
|
||||
async postLaunchSetup(_session: Session): Promise<void> {
|
||||
// Hooks are installed pre-launch via setupWorkspaceHooks so that
|
||||
// PostToolUse hooks exist before the agent's first tool call.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,7 +278,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.",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue