diff --git a/src/adapters/codex-launch.test.ts b/src/adapters/codex-launch.test.ts new file mode 100644 index 0000000..a816714 --- /dev/null +++ b/src/adapters/codex-launch.test.ts @@ -0,0 +1,101 @@ +import { EventEmitter } from "node:events"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Track spawn calls +const spawnCalls: { cmd: string; args: string[] }[] = []; + +vi.mock("node:child_process", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + spawn: (cmd: string, args: string[], _opts: unknown) => { + spawnCalls.push({ cmd, args: [...args] }); + const child = new EventEmitter(); + Object.assign(child, { + pid: 77777, + unref: () => {}, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + return child; + }, + }; +}); + +vi.mock("../utils/resolve-binary.js", () => ({ + resolveBinaryPath: async () => "/usr/local/bin/codex", +})); + +// Import after mocks are declared (vitest hoists vi.mock) +const { CodexAdapter } = await import("./codex.js"); + +let tmpDir: string; +let adapter: InstanceType; + +beforeEach(async () => { + spawnCalls.length = 0; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentctl-codex-launch-")); + const codexDir = path.join(tmpDir, "codex"); + const sessionsMetaDir = path.join(tmpDir, "codex-sessions"); + await fs.mkdir(path.join(codexDir, "sessions"), { recursive: true }); + await fs.mkdir(sessionsMetaDir, { recursive: true }); + + adapter = new CodexAdapter({ + codexDir, + sessionsMetaDir, + getPids: async () => new Map(), + isProcessAlive: () => false, + }); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("CodexAdapter launch", () => { + it("uses -- separator so dash-prefixed prompts are not parsed as options", async () => { + const dashPrompt = "---\ntitle: spec\n---\nFix the bug"; + await adapter.launch({ + adapter: "codex", + prompt: dashPrompt, + cwd: tmpDir, + }); + + expect(spawnCalls).toHaveLength(1); + const args = spawnCalls[0].args; + // -- must appear before the prompt + const sepIdx = args.indexOf("--"); + const promptIdx = args.indexOf(dashPrompt); + expect(sepIdx).toBeGreaterThanOrEqual(0); + expect(promptIdx).toBe(sepIdx + 1); + expect(args).toEqual([ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--json", + "--cd", + tmpDir, + "--", + dashPrompt, + ]); + }); + + it("places -- separator after --model flag", async () => { + await adapter.launch({ + adapter: "codex", + prompt: "fix the bug", + model: "gpt-4o", + cwd: tmpDir, + }); + + expect(spawnCalls).toHaveLength(1); + const args = spawnCalls[0].args; + const sepIdx = args.indexOf("--"); + const modelIdx = args.indexOf("--model"); + expect(modelIdx).toBeLessThan(sepIdx); + expect(args[args.length - 1]).toBe("fix the bug"); + expect(args[args.length - 2]).toBe("--"); + }); +}); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index fcde699..1255c1d 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -252,7 +252,9 @@ export class CodexAdapter implements AgentAdapter { const cwd = opts.cwd || process.cwd(); args.push("--cd", cwd); - args.push(opts.prompt); + // Use -- separator to prevent prompts starting with dashes from being + // interpreted as CLI options by the downstream arg parser. + args.push("--", opts.prompt); const env = buildSpawnEnv(undefined, opts.env); diff --git a/src/adapters/opencode-launch.test.ts b/src/adapters/opencode-launch.test.ts index d917eaa..e9cb4f1 100644 --- a/src/adapters/opencode-launch.test.ts +++ b/src/adapters/opencode-launch.test.ts @@ -74,7 +74,13 @@ describe("OpenCodeAdapter launch", () => { const modelIdx = args.indexOf("--model"); const promptIdx = args.indexOf("fix the bug"); expect(modelIdx).toBeLessThan(promptIdx); - expect(args).toEqual(["run", "--model", "deepseek-r1", "fix the bug"]); + expect(args).toEqual([ + "run", + "--model", + "deepseek-r1", + "--", + "fix the bug", + ]); }); it("omits --model flag when opts.model is not set", async () => { @@ -87,6 +93,24 @@ describe("OpenCodeAdapter launch", () => { expect(spawnCalls).toHaveLength(1); const args = spawnCalls[0].args; expect(args).not.toContain("--model"); - expect(args).toEqual(["run", "fix the bug"]); + expect(args).toEqual(["run", "--", "fix the bug"]); + }); + + it("uses -- separator so dash-prefixed prompts are not parsed as options", async () => { + const dashPrompt = "---\ntitle: spec\n---\nFix the bug"; + await adapter.launch({ + adapter: "opencode", + prompt: dashPrompt, + cwd: tmpDir, + }); + + expect(spawnCalls).toHaveLength(1); + const args = spawnCalls[0].args; + // -- must appear before the prompt + const sepIdx = args.indexOf("--"); + const promptIdx = args.indexOf(dashPrompt); + expect(sepIdx).toBeGreaterThanOrEqual(0); + expect(promptIdx).toBe(sepIdx + 1); + expect(args).toEqual(["run", "--", dashPrompt]); }); }); diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index dcc70d9..850613d 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -301,7 +301,9 @@ export class OpenCodeAdapter implements AgentAdapter { if (opts.model) { args.push("--model", opts.model); } - args.push(opts.prompt); + // Use -- separator to prevent prompts starting with dashes from being + // interpreted as CLI options by the downstream arg parser. + args.push("--", opts.prompt); const env = buildSpawnEnv(undefined, opts.env); const cwd = opts.cwd || process.cwd(); diff --git a/src/adapters/pi-rust-launch.test.ts b/src/adapters/pi-rust-launch.test.ts index a114443..a909243 100644 --- a/src/adapters/pi-rust-launch.test.ts +++ b/src/adapters/pi-rust-launch.test.ts @@ -179,4 +179,24 @@ describe("PiRustAdapter launch", () => { expect(args).toContain("--provider"); expect(args).toContain("openrouter"); }); + + it("uses -- separator so dash-prefixed prompts are not parsed as options", async () => { + const dashPrompt = "---\ntitle: spec\n---\nFix the bug"; + await adapter.launch({ + adapter: "pi-rust", + prompt: dashPrompt, + cwd: tmpDir, + }); + + expect(spawnCalls).toHaveLength(1); + const args = spawnCalls[0].args; + // -- must appear before the prompt + const sepIdx = args.indexOf("--"); + const promptIdx = args.indexOf(dashPrompt); + expect(sepIdx).toBeGreaterThanOrEqual(0); + expect(promptIdx).toBe(sepIdx + 1); + // prompt should be the last arg, after -- + expect(args[args.length - 1]).toBe(dashPrompt); + expect(args[args.length - 2]).toBe("--"); + }); }); diff --git a/src/adapters/pi-rust.ts b/src/adapters/pi-rust.ts index 27974c2..ac454f3 100644 --- a/src/adapters/pi-rust.ts +++ b/src/adapters/pi-rust.ts @@ -328,7 +328,9 @@ export class PiRustAdapter implements AgentAdapter { } async launch(opts: LaunchOpts): Promise { - const args = ["--print", "--mode", "json", opts.prompt]; + // Use -- separator to prevent prompts starting with dashes from being + // interpreted as CLI options by the downstream arg parser. + const args = ["--print", "--mode", "json", "--", opts.prompt]; if (opts.model) { const { provider, model } = parseProviderModel(