Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/adapters/codex-launch.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:child_process")>();
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<typeof CodexAdapter>;

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("--");
});
});
4 changes: 3 additions & 1 deletion src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
28 changes: 26 additions & 2 deletions src/adapters/opencode-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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]);
});
});
4 changes: 3 additions & 1 deletion src/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/adapters/pi-rust-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("--");
});
});
4 changes: 3 additions & 1 deletion src/adapters/pi-rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,9 @@ export class PiRustAdapter implements AgentAdapter {
}

async launch(opts: LaunchOpts): Promise<AgentSession> {
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(
Expand Down