feat: add Codex executor with session resume

This commit is contained in:
2026-07-22 22:15:15 +08:00
parent d38151ad78
commit 214b7c9a30
19 changed files with 1855 additions and 33 deletions
+29
View File
@@ -227,6 +227,35 @@ test("logFilePath: onProgress reports pid and bytesLogged", async () => {
}
});
test("spawnStreamingChild with input handles EPIPE when child closes stdin early", async () => {
const { stream } = collectSink();
// A child that immediately closes stdin and exits
const script = "process.stdin.destroy(); process.stdout.write('done\\n'); process.exit(0);";
const r = await spawnStreamingChild(process.execPath, ["-e", script], {
stdoutSink: stream,
stderrSink: stream,
input: "some very long input that will trigger EPIPE when stdin is closed",
});
assert.equal(r.status, 0);
assert.match(r.stdout, /done/);
});
test("spawnStreamingChild with input does not crash on async EPIPE", async () => {
const { stream } = collectSink();
// A child that reads once then closes stdin
const script = [
"process.stdin.once('data', () => { process.stdin.destroy(); });",
"process.stdout.write('got-data\\n');",
].join("");
const r = await spawnStreamingChild(process.execPath, ["-e", script], {
stdoutSink: stream,
stderrSink: stream,
input: "trigger\nmore-data-that-exceeds-what-child-reads",
});
assert.equal(r.status, 0);
assert.match(r.stdout, /got-data/);
});
test("logFilePath: write stream error resolves spawnStreamingChild with result.error", async () => {
const tmpDir = fs.mkdtempSync("/tmp/agent-workflow-log-err-test-");
try {
+19 -1
View File
@@ -68,6 +68,8 @@ export async function spawnStreamingChild(
onProgress?: (info: StreamProgress) => void;
/** Live callback for each stdout chunk (for real-time parsing, e.g. Claude JSON lines). */
onStdoutData?: (chunk: string) => void;
/** Text to write to stdin (enables pipe mode). */
input?: string;
},
): Promise<ChildRunResult> {
const stdoutSink = options.stdoutSink ?? process.stdout;
@@ -79,10 +81,26 @@ export async function spawnStreamingChild(
const spawnOpts: SpawnOptions = {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
stdio: options.input ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
detached: options.processGroup === true && process.platform !== "win32",
};
const child = spawn(command, argv, spawnOpts);
// Write stdin input if provided
if (options.input && child.stdin) {
// Attach error listener before writing to avoid unhandled EPIPE
child.stdin.on("error", (err: Error) => {
// EPIPE is expected if the child closes stdin early
if ((err as NodeJS.ErrnoException).code === "EPIPE") return;
// Log other errors (will be captured via stderr or exit code)
});
try {
child.stdin.write(options.input);
child.stdin.end();
} catch {
// Ignore EPIPE if the child closed stdin early
}
}
let stdout = "";
let stderr = "";
let settled = false;
+3
View File
@@ -93,6 +93,8 @@ export {
probeExecutor,
validateClaudeSessionForRepository,
validateClaudeSessionForWorkflow,
validateCodexSessionForRepository,
validateCodexSessionForWorkflow,
validateReasonixSessionForRepository,
} from "./workflow-executor.js";
@@ -155,6 +157,7 @@ export type {
ExecutorSessionHandle,
ReasonixSessionHandle,
ClaudeSessionHandle,
CodexSessionHandle,
AgySessionHandle,
CycleRecord,
ScopeViolation,
+2 -2
View File
@@ -105,10 +105,10 @@ function parsePositiveInt(flag: string, value: string): number {
export const WORKFLOW_USAGE = `
Usage:
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--reviewer <codex|claude>] [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
agent-workflow start --executor <reasonix|claude|agy|codex> --plan <plan.json|-> [--reviewer <codex|claude>] [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
agent-workflow review --run <run-id> --reviewer <codex|claude>
agent-workflow retry-review --run <run-id> --reviewer <codex|claude>
agent-workflow retry-execute --run <run-id> [--session <claude-session-id>]
agent-workflow retry-execute --run <run-id> [--session <claude-or-codex-session-id>]
agent-workflow decide --run <run-id> --input <decision.json|-> --reviewer <codex|claude>
agent-workflow extend --run <run-id> --additional-cycles <n>
agent-workflow status --run <run-id> [--json]
+19 -7
View File
@@ -29,6 +29,8 @@ import {
probeExecutor,
validateClaudeSessionForRepository,
validateClaudeSessionForWorkflow,
validateCodexSessionForRepository,
validateCodexSessionForWorkflow,
validateReasonixSessionForRepository,
} from "./workflow-executor.js";
import {
@@ -304,6 +306,9 @@ function validateTaskSessionHandle(
case "claude":
validateClaudeSessionForRepository(handle.sessionId, repoRoot);
return;
case "codex":
validateCodexSessionForRepository(handle.sessionId, repoRoot);
return;
case "agy":
if (handle.degraded || !handle.conversationId || !/^[0-9a-f-]{36}$/i.test(handle.conversationId)) {
throw new WorkflowTaskBindingError("Task binding contains an Agy session without a reliable conversation ID.");
@@ -497,6 +502,9 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
if (reviewer === "claude" && config.executor === "claude") {
throw new WorkflowEngineError("Reviewer 'claude' cannot be used with executor 'claude'. Claude cannot self-review.", 4);
}
if (reviewer === "codex" && config.executor === "codex") {
throw new WorkflowEngineError("Reviewer 'codex' cannot be used with executor 'codex'. Codex cannot self-review.", 4);
}
const vcsProvider = createVcsProvider(vcsKind);
const codexThreadId = (opts.codexThreadId ?? process.env.CODEX_THREAD_ID)?.trim();
@@ -1530,8 +1538,8 @@ export interface RetryExecuteWorkflowOpts {
export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Promise<void> {
const { state, dir } = requireState(opts.runId);
if (opts.sessionId && state.executor !== "claude") {
throw new WorkflowEngineError("--session can only be used with a Claude executor run.", 4);
if (opts.sessionId && state.executor !== "claude" && state.executor !== "codex") {
throw new WorkflowEngineError("--session can only be used with a Claude or Codex executor run.", 4);
}
const staleExecution = state.status === "executing"
&& state.enginePid !== undefined
@@ -1609,9 +1617,13 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
if (opts.sessionId) {
try {
validateClaudeSessionForWorkflow(opts.sessionId, state.repoRoot, state.plan.title);
if (state.executor === "claude") {
validateClaudeSessionForWorkflow(opts.sessionId, state.repoRoot, state.plan.title);
} else if (state.executor === "codex") {
validateCodexSessionForWorkflow(opts.sessionId, state.repoRoot, state.plan.title);
}
} catch (err) {
throw new WorkflowEngineError(`Cannot rebind Claude session: ${(err as Error).message}`, 4);
throw new WorkflowEngineError(`Cannot rebind ${state.executor} session: ${(err as Error).message}`, 4);
}
}
@@ -1640,11 +1652,11 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
throw err;
}
const previousSessionId = state.sessionHandle?.kind === "claude"
const previousSessionId = (state.sessionHandle?.kind === "claude" || state.sessionHandle?.kind === "codex")
? state.sessionHandle.sessionId
: undefined;
if (opts.sessionId) {
state.sessionHandle = { kind: "claude", sessionId: opts.sessionId };
state.sessionHandle = { kind: state.executor as "claude" | "codex", sessionId: opts.sessionId };
} else if (recoveredReasonixSession) {
state.sessionHandle = { kind: "reasonix", sessionFilePath: recoveredReasonixSession };
}
@@ -1664,7 +1676,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
runId: state.runId,
timestamp: state.updatedAt,
cycleIndex: state.currentCycle,
data: { previousSessionId, sessionId: opts.sessionId },
data: { previousSessionId, sessionId: opts.sessionId, executor: state.executor },
});
} else if (recoveredReasonixSession) {
appendEvent(dir, {
+477
View File
@@ -63,6 +63,38 @@ exit ${exitCode}
return scriptPath;
}
/**
* Creates a fake CLI script that records argv (null-delimited), stdin content,
* and the environment (CODEX_THREAD_ID presence) for testing exact adapter invocation.
*/
function writeRecordingFake(dir: string, opts: {
exitCode?: number;
stdout?: string;
stderr?: string;
argvFile?: string;
stdinFile?: string;
envFile?: string;
}): string {
const scriptPath = path.join(dir, "recording-fake.sh");
const argvFile = opts.argvFile ?? path.join(dir, "recorded-argv.txt");
const stdinFile = opts.stdinFile ?? path.join(dir, "recorded-stdin.txt");
const envFile = opts.envFile ?? path.join(dir, "recorded-env.txt");
const exitCode = opts.exitCode ?? 0;
const stdout = (opts.stdout ?? "").replace(/'/g, "'\\''");
const stderr = (opts.stderr ?? "").replace(/'/g, "'\\''");
const script = `#!/bin/bash
printf '%s\\0' "$@" > "${argvFile}"
cat - > "${stdinFile}"
echo "CODEX_THREAD_ID=\${CODEX_THREAD_ID:-<unset>}" > "${envFile}"
echo '${stdout}'
echo '${stderr}' >&2
exit ${exitCode}
`;
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return scriptPath;
}
// ---------------------------------------------------------------------------
// Reasonix adapter tests
// ---------------------------------------------------------------------------
@@ -947,4 +979,449 @@ exit 1
await expect(probeExecutor("reasonix", { binary: "/nonexistent/path/reasonix" }))
.rejects.toThrow(/agent-workflow\.json/);
});
// ---------------------------------------------------------------------------
// Codex adapter tests
// ---------------------------------------------------------------------------
it("Codex adapter start passes exact args and stdin, strips CODEX_THREAD_ID", async () => {
const argvFile = path.join(tmpDir, "recorded-argv.txt");
const stdinFile = path.join(tmpDir, "recorded-stdin.txt");
const envFile = path.join(tmpDir, "recorded-env.txt");
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"abc-def-123","thread":{"id":"nested-id"}}\n{"type":"item.started","item":{"tool_name":"Bash","input":"echo hello"}}\n{"type":"turn.completed"}\n{"type":"result","result":"done"}',
argvFile, stdinFile, envFile,
});
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin, model: "gemini-2.5-pro" },
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).toEqual([
"exec", "--json",
"--sandbox", "workspace-write",
"--cd", tmpDir,
"--model", "gemini-2.5-pro",
"-",
]);
const capturedStdin = fs.readFileSync(stdinFile, "utf8");
expect(capturedStdin.length).toBeGreaterThan(10);
expect(capturedStdin).toContain("Do something.");
const capturedEnv = fs.readFileSync(envFile, "utf8");
expect(capturedEnv).toContain("CODEX_THREAD_ID=<unset>");
expect(result.failed).toBe(false);
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: "abc-def-123" });
});
it("Codex adapter start without model does not pass --model", async () => {
const argvFile = path.join(tmpDir, "recorded-argv-no-model.txt");
const stdinFile = path.join(tmpDir, "recorded-stdin-no-model.txt");
const envFile = path.join(tmpDir, "recorded-env-no-model.txt");
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"no-model-id"}',
argvFile, stdinFile, envFile,
});
const adapter = createExecutorAdapter("codex");
await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).not.toContain("--model");
expect(capturedArgs).toContain("-");
});
it("Codex adapter resume passes exact args and stdin, strips CODEX_THREAD_ID", async () => {
const argvFile = path.join(tmpDir, "recorded-resume-argv.txt");
const stdinFile = path.join(tmpDir, "recorded-resume-stdin.txt");
const envFile = path.join(tmpDir, "recorded-resume-env.txt");
const sessionId = "resume-session-456";
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"resume-session-456"}',
argvFile, stdinFile, envFile,
});
const adapter = createExecutorAdapter("codex");
const result = await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin, model: "gemini-2.5-flash" },
handle: { kind: "codex", sessionId },
acceptedFindings: "# Fix issues\n\n## Finding fix-1\n- Problem: test\n",
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).toEqual([
"exec", "resume",
"-c", 'sandbox_mode="workspace-write"',
"--json",
"--model", "gemini-2.5-flash",
sessionId,
"-",
]);
const capturedEnv = fs.readFileSync(envFile, "utf8");
expect(capturedEnv).toContain("CODEX_THREAD_ID=<unset>");
expect(result.failed).toBe(false);
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: "resume-session-456" });
});
it("Codex adapter resume without model does not pass --model", async () => {
const argvFile = path.join(tmpDir, "recorded-resume-no-model.txt");
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"resume-id"}',
argvFile,
});
const adapter = createExecutorAdapter("codex");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin },
handle: { kind: "codex", sessionId: "resume-id" },
acceptedFindings: "# fix\n",
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).not.toContain("--model");
});
it("Codex adapter extracts thread_id from top-level field; retains nested thread.id as BWC", async () => {
const fakeBin = path.join(tmpDir, "codex-bwc");
const threadId = "top-level-thread-id";
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread_id":"${threadId}","thread":{"id":"nested-id"}}'
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: threadId });
});
it("Codex adapter falls back to nested thread.id when thread_id absent", async () => {
const fakeBin = path.join(tmpDir, "codex-nested");
const nestedId = "nested-thread-id-only";
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread":{"id":"${nestedId}"}}'
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: nestedId });
});
it("Codex adapter partial output with thread ID then nonzero exit marks failed, no handle", async () => {
const fakeBin = path.join(tmpDir, "codex-partial-fail");
const partialThreadId = "partially-emitted-thread-id";
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread_id":"${partialThreadId}"}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.exitCode).toBe(1);
expect(result.sessionHandle).toBeUndefined();
});
it("Codex adapter parses item.started with tool_name", async () => {
const fakeBin = path.join(tmpDir, "codex-tool-name");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread_id":"tid-1"}'
echo '{"type":"item.started","item":{"tool_name":"Read","input":{"file_path":"src/main.ts"}}}'
echo '{"type":"item.completed"}'
echo '{"type":"turn.completed"}'
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(false);
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: "tid-1" });
});
it("Codex adapter parses message-based error events", async () => {
const fakeBin = path.join(tmpDir, "codex-msg-error");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"error","message":{"content":"API rate limit hit"}}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.failureReason).toContain("API rate limit hit");
});
it("Codex adapter rejects empty session ID as failure", async () => {
const fakeBin = path.join(tmpDir, "codex-no-session");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.failureReason).toContain("no resumable session ID");
expect(result.sessionHandle).toBeUndefined();
});
it("Codex adapter parses error events as failure", async () => {
const fakeBin = path.join(tmpDir, "codex-error");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"error","error":"API rate limit exceeded"}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.exitCode).toBe(1);
});
it("validateCodexSessionForRepository finds rollout-named session in active dir", async () => {
const { validateCodexSessionForRepository } = await import("./workflow-executor.js");
const sessionId = "c5e7a123-4b6d-8f90-1a2b-3c4d5e6f7890";
const rolloutFile = `rollout-20260501-${sessionId}.jsonl`;
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, rolloutFile),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
const result = validateCodexSessionForRepository(sessionId, tmpDir);
expect(result).toBeDefined();
expect(result).toContain(rolloutFile);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForRepository finds rollout-named session in archived_sessions dir", async () => {
const { validateCodexSessionForRepository } = await import("./workflow-executor.js");
const sessionId = "d8f6b234-5c7e-9a01-2b3c-4d5e6f7a8901";
const rolloutFile = `rollout-20260502-${sessionId}.jsonl`;
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const archivedDir = path.join(tmpDir, ".codex", "archived_sessions");
fs.mkdirSync(archivedDir, { recursive: true });
fs.writeFileSync(
path.join(archivedDir, rolloutFile),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
const result = validateCodexSessionForRepository(sessionId, tmpDir);
expect(result).toBeDefined();
expect(result).toContain(rolloutFile);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow succeeds with matching repo, plan title, and rollout filename", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "a1b2c3d4-5e6f-7890-abcd-ef1234567890";
const rolloutFile = `rollout-20260503-${sessionId}.jsonl`;
const planTitle = "My Frozen Plan Title";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
// Include the plan title in a message content so validation passes
fs.writeFileSync(
path.join(sessionsDir, rolloutFile),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir, planTitle } }) + "\n" +
JSON.stringify({ type: "message", message: { content: planTitle } }) + "\n",
);
const result = validateCodexSessionForWorkflow(sessionId, tmpDir, planTitle);
expect(result).toContain(rolloutFile);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow rejects session with mismatched ID", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "e9a7c345-6d8f-0b12-3c4d-5e6f7a8b9012";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
// Write file with mismatched ID
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260504-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: "different-id", cwd: tmpDir } }) + "\n",
);
expect(() =>
validateCodexSessionForWorkflow(sessionId, tmpDir, "Test Plan"),
).toThrow(/does not belong to repository/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow rejects session with wrong repo", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "f0b8d456-7e9a-1c23-4d5e-6f7a8b9c0123";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260505-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: "/different/repo" } }) + "\n",
);
expect(() =>
validateCodexSessionForWorkflow(sessionId, tmpDir, "Test Plan"),
).toThrow(/does not belong to repository/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow rejects session with wrong plan title", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "a2b3c4d5-6e7f-8901-abcd-ef2345678901";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260506-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
expect(() =>
validateCodexSessionForWorkflow(sessionId, tmpDir, "Non-existent Title"),
).toThrow(/does not contain frozen plan/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForRepository rejects duplicate candidates across dirs", async () => {
const { validateCodexSessionForRepository } = await import("./workflow-executor.js");
const sessionId = "b3c4d5e6-7f8a-9012-bcde-f34567890123";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
// Create same session file in both sessions and archived_sessions
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260507-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
const archivedDir = path.join(tmpDir, ".codex", "archived_sessions");
fs.mkdirSync(archivedDir, { recursive: true });
fs.writeFileSync(
path.join(archivedDir, `rollout-20260508-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
expect(() =>
validateCodexSessionForRepository(sessionId, tmpDir),
).toThrow(/Multiple/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
});
+358 -1
View File
@@ -16,6 +16,7 @@ import { spawnStreamingChild } from "./child-process.js";
import type {
AgySessionHandle,
ClaudeSessionHandle,
CodexSessionHandle,
ExecutorAdapter,
ExecutorConfig,
ExecutorKind,
@@ -72,7 +73,7 @@ function getSafetyConstraints(vcsKind?: VcsKind): string {
function startProgressSidecar(
dir: string,
executor: "reasonix" | "claude" | "agy",
executor: ExecutorKind,
cycleIndex: number,
attemptIndex: number,
logFilePath: string | undefined,
@@ -1172,6 +1173,360 @@ class AgyAdapter implements ExecutorAdapter {
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Codex adapter
// ---------------------------------------------------------------------------
function extractCodexSessionId(output: string): string | undefined {
try {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
// Real Codex thread.started carries thread_id at event top level
if (parsed.type === "thread.started") {
if (typeof parsed.thread_id === "string") return parsed.thread_id;
const thread = parsed.thread as Record<string, unknown> | undefined;
if (thread && typeof thread.id === "string") return thread.id;
}
// Legacy fallback
if (typeof parsed.session_id === "string") return parsed.session_id;
if (parsed.type === "session_meta" && typeof parsed.id === "string") return parsed.id;
}
} catch {
// fall through
}
const m = output.match(/"session_id"\s*:\s*"([^"]+)"/);
return m?.[1];
}
export function validateCodexSessionForWorkflow(
sessionId: string,
repoRoot: string,
planTitle: string,
): string {
const { sessionFile, content } = readCodexSessionForRepository(sessionId, repoRoot);
if (!content.includes(planTitle)) {
throw new Error(`Codex session ${sessionId} does not contain frozen plan title: ${planTitle}`);
}
return sessionFile;
}
export function validateCodexSessionForRepository(sessionId: string, repoRoot: string): string {
return readCodexSessionForRepository(sessionId, repoRoot).sessionFile;
}
function readCodexSessionForRepository(
sessionId: string,
repoRoot: string,
): { sessionFile: string; content: string } {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
throw new Error(`Invalid Codex session ID: ${sessionId}`);
}
const homeDir = process.env.HOME || os.homedir();
const searchDirs = [
path.join(homeDir, ".codex", "sessions"),
path.join(homeDir, ".codex", "archived_sessions"),
];
const candidates: string[] = [];
const findSessionFile = (dir: string) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
findSessionFile(path.join(dir, entry.name));
} else if (entry.isFile()) {
// Real rollout filenames: rollout-<timestamp>-<uuid>.jsonl
const name = entry.name;
if (name.endsWith(".jsonl") && (name.includes(sessionId) || name === `${sessionId}.jsonl`)) {
candidates.push(path.join(dir, entry.name));
}
}
}
};
for (const searchDir of searchDirs) {
findSessionFile(searchDir);
}
if (candidates.length !== 1) {
throw new Error(
candidates.length === 0
? `Codex session file not found for ID: ${sessionId}`
: `Multiple Codex session files found for ID: ${sessionId}`,
);
}
const sessionFile = candidates[0];
const fd = fs.openSync(sessionFile, "r");
const maxBytes = 4 * 1024 * 1024;
const buffer = Buffer.alloc(maxBytes);
let content: string;
try {
const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0);
content = buffer.toString("utf8", 0, bytesRead);
} finally {
fs.closeSync(fd);
}
let repoMatches = false;
let sessionMatches = false;
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as Record<string, unknown>;
if (entry.type === "session_meta") {
const payload = entry.payload as Record<string, unknown> | undefined;
// Parse session ID from payload.id
const entryId = (payload?.id as string) ?? (entry.id as string) ?? (entry.session_id as string);
if (entryId === sessionId) sessionMatches = true;
// Parse working directory from payload.cwd
const cwd = (payload?.cwd as string) ?? (entry.cwd as string);
if (cwd && (path.resolve(cwd) === path.resolve(repoRoot) || path.normalize(cwd) === path.normalize(repoRoot))) {
repoMatches = true;
}
}
if (entry.session_id === sessionId) sessionMatches = true;
if (entry.cwd && path.resolve(entry.cwd as string) === path.resolve(repoRoot)) repoMatches = true;
} catch {
// Ignore a trailing partial line
}
}
if (!sessionMatches || !repoMatches) {
throw new Error(`Codex session ${sessionId} does not belong to repository ${repoRoot}`);
}
return { sessionFile, content };
}
function deriveCodexActivity(line: string): string | undefined {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) return undefined;
try {
const ev = JSON.parse(trimmed) as Record<string, unknown>;
if (ev.type === "item.started" && typeof ev.item === "object" && ev.item) {
const item = ev.item as Record<string, unknown>;
// Real Codex item events use item.tool_name or item.name
if (typeof item.tool_name === "string") return `Tool: ${item.tool_name}`;
if (typeof item.name === "string") return `Tool: ${item.name}`;
if (typeof item.type === "string") return `Item: ${item.type}`;
}
if (ev.type === "item.completed") return "Item completed";
if (ev.type === "turn.completed") return "Turn completed";
if (ev.type === "thread.started") return "Thread started";
if (ev.type === "tool" && typeof ev.name === "string") return `Tool: ${ev.name}`;
if (ev.type === "message") return "Message...";
if (ev.type === "completion") return "Completion...";
if (ev.type === "initialization") return "Initializing...";
// Real Codex error may be in message or error field
if (ev.type === "error") {
const msg = typeof ev.message === "string" ? ev.message : typeof ev.error === "string" ? ev.error : undefined;
if (msg) return `Error: ${msg}`;
}
} catch {
// fall through
}
return undefined;
}
function extractCodexFailureReason(output: string): string | undefined {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
// Real Codex error includes message field
if (parsed.type === "error") {
const msg = typeof parsed.message === "string" && parsed.message.trim() ? parsed.message :
typeof parsed.error === "string" && parsed.error.trim() ? parsed.error :
typeof parsed.message === "object" && parsed.message !== null && typeof (parsed.message as Record<string, unknown>).content === "string" ? (parsed.message as Record<string, unknown>).content as string :
typeof parsed.error === "object" && parsed.error !== null && typeof (parsed.error as Record<string, unknown>).content === "string" ? (parsed.error as Record<string, unknown>).content as string : undefined;
if (msg) return msg.trim();
}
} catch {
// skip
}
}
return undefined;
}
function codexExecutorEnv(): NodeJS.ProcessEnv {
const env = executorEnv();
delete env.CODEX_THREAD_ID;
return env;
}
class CodexAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "codex";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "codex"), ["--version"], {
stdio: "ignore",
env: codexExecutorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "codex");
const prompt = buildStartPrompt(opts);
// Codex CLI accepts prompt via stdin using '-' argument
const args: string[] = [
"exec",
"--json",
"--sandbox", "workspace-write",
"--cd", opts.repoRoot,
...(opts.config.model ? ["--model", opts.config.model] : []),
"-",
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "codex", opts.cycleIndex, attemptIndex, logFile);
let lineBuffer = "";
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: codexExecutorEnv(),
input: prompt,
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
onStdoutData: (chunk: string) => {
lineBuffer += chunk;
while (true) {
const nl = lineBuffer.indexOf("\n");
if (nl < 0) break;
const line = lineBuffer.slice(0, nl);
lineBuffer = lineBuffer.slice(nl + 1);
const activity = deriveCodexActivity(line);
if (activity) {
const isToolActivity = !activity.startsWith("Error:");
if (isToolActivity) {
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
} else {
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
}
}
}
},
});
let failed = child.status !== 0 || !!child.error;
const capturedId = extractCodexSessionId(child.stdout) ?? extractCodexSessionId(child.stderr);
let failureReason = child.error?.message
?? extractCodexFailureReason(child.stdout)
?? extractCodexFailureReason(child.stderr);
if (!failed && !capturedId) {
failed = true;
failureReason = "Codex execution completed successfully but yielded no resumable session ID.";
}
stopProgressSidecar(opts.runDir, sidecarTimer, failed);
const durationMs = Date.now() - startMs;
const handle: CodexSessionHandle | undefined = failed
? undefined
: { kind: "codex", sessionId: capturedId! };
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
...(handle ? { sessionHandle: handle } : {}),
failed,
...(failureReason ? { failureReason } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as CodexSessionHandle;
const bin = resolveExecutable(opts.config, "codex");
const prompt = buildFixPrompt(opts);
// resume uses -c sandbox_mode= override (--sandbox is rejected by codex exec resume)
const args: string[] = [
"exec", "resume",
"-c", 'sandbox_mode="workspace-write"',
"--json",
...(opts.config.model ? ["--model", opts.config.model] : []),
handle.sessionId,
"-",
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "codex", opts.cycleIndex, attemptIndex, logFile);
let lineBuffer = "";
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: codexExecutorEnv(),
input: prompt,
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
onStdoutData: (chunk: string) => {
lineBuffer += chunk;
while (true) {
const nl = lineBuffer.indexOf("\n");
if (nl < 0) break;
const line = lineBuffer.slice(0, nl);
lineBuffer = lineBuffer.slice(nl + 1);
const activity = deriveCodexActivity(line);
if (activity) {
const isToolActivity = !activity.startsWith("Error:");
if (isToolActivity) {
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
} else {
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
}
}
}
},
});
const failed = child.status !== 0 || !!child.error;
stopProgressSidecar(opts.runDir, sidecarTimer, failed);
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractCodexFailureReason(child.stdout)
?? extractCodexFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed,
...(failureReason ? { failureReason } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
}
}
// Factory
// ---------------------------------------------------------------------------
@@ -1183,6 +1538,8 @@ export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter {
return new ClaudeAdapter();
case "agy":
return new AgyAdapter();
case "codex":
return new CodexAdapter();
default: {
const _exhaustive: never = kind;
throw new Error(`Unknown executor kind: ${_exhaustive}`);
+177
View File
@@ -145,9 +145,14 @@ describe("discoverExecutorModels isolation", () => {
fs.mkdirSync(binDir, { recursive: true });
const agyBin = path.join(binDir, "agy");
const reasonixBin = path.join(binDir, "reasonix");
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(agyBin, "#!/bin/sh\nprintf '%s\\n' 'Test Agy Model'\n", { mode: 0o755 });
fs.writeFileSync(reasonixBin, `#!/bin/sh
printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}'
`, { mode: 0o755 });
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' 'codex-model-1'
printf '%s\\n' 'codex-model-2'
`, { mode: 0o755 });
const configDir = path.join(tmpHome, ".agent-workflow");
@@ -157,6 +162,7 @@ printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}
executors: {
agy: { binary: agyBin },
reasonix: { binary: reasonixBin },
codex: { binary: codexBin },
},
}));
});
@@ -252,3 +258,174 @@ printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
});
describe("Codex model discovery JSON format", () => {
let tmpHome: string;
let originalHome: string | undefined;
let binDir: string;
let originalFetch: typeof globalThis.fetch | undefined;
let originalPath: string;
const ANTHROPIC_VARS = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;
const savedVars: Record<string, string | undefined> = {};
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-codex-json-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
originalPath = process.env.PATH ?? "";
for (const key of ANTHROPIC_VARS) {
savedVars[key] = process.env[key];
delete process.env[key];
}
originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => {
throw new Error("fetch not mocked for Claude in Codex test");
}) as unknown as typeof fetch;
binDir = path.join(tmpHome, "bin");
fs.mkdirSync(binDir, { recursive: true });
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
// Ensure other executor bins (agy, reasonix) are NOT found in PATH
process.env.PATH = `/dev/null:/usr/bin:/bin`;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
for (const key of ANTHROPIC_VARS) {
if (savedVars[key] === undefined) delete process.env[key];
else process.env[key] = savedVars[key];
}
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
process.env.PATH = originalPath;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it("parses models[].slug JSON format from codex debug models", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' '{"models":[{"slug":"gemini-2.5-pro"},{"slug":"gemini-2.5-flash"},{"slug":"gemini-2.0-pro-exp-02-05"}]}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("gemini-2.5-pro");
expect(result.codex.models).toContain("gemini-2.5-flash");
expect(result.codex.models).toContain("gemini-2.0-pro-exp-02-05");
// The raw JSON string must NOT be exposed as a single model entry
expect(result.codex.models).not.toContain(expect.stringContaining("models"));
});
it("parses data[].id as alternate format from codex debug models", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' '{"data":[{"id":"model-a"},{"id":"model-b"}]}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("model-a");
expect(result.codex.models).toContain("model-b");
});
it("applies a manual model override via config file", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' '{"models":[{"slug":"gemini-2.5-pro"},{"slug":"gemini-2.5-flash"}]}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin, model: "custom-gemini-2.5" } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("gemini-2.5-pro");
// Manual override is in result.current, NOT in result.codex.models
expect(result.current.codex).toBe("custom-gemini-2.5");
});
it("falls through to line-delimited parsing when JSON has no valid slug/id", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' 'codex-model-a'
printf '%s\\n' 'codex-model-b'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("codex-model-a");
expect(result.codex.models).toContain("codex-model-b");
});
it("falls back to --bundled when primary fails, then succeeds", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
# First invocation (debug models) fails, second (debug models --bundled) succeeds
if [ "$*" = "debug models" ]; then
exit 1
fi
if [ "$*" = "debug models --bundled" ]; then
printf '%s\\n' '{"models":[{"slug":"bundled-gemini-2.5-pro"}]}'
exit 0
fi
exit 1
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("bundled-gemini-2.5-pro");
});
it("reports error when both primary and --bundled fail", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
exit 1
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("error");
});
});
+108 -2
View File
@@ -10,6 +10,12 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/** Internal marker to skip line-delimited fallback after successful JSON parse with no models. */
class SkippableError extends Error {
constructor(msg: string) { super(msg); this.name = "SkippableError"; }
}
import type { ExecutorConfig, ExecutorKind } from "./workflow-types.js";
import { EXECUTOR_KINDS } from "./workflow-types.js";
import { loadGlobalWorkflowConfig } from "./workflow-config.js";
@@ -31,6 +37,7 @@ export interface ExecutorModelDiscovery {
export interface ModelDiscoveryResult {
claude: ExecutorModelDiscovery;
codex: ExecutorModelDiscovery;
agy: ExecutorModelDiscovery;
reasonix: ExecutorModelDiscovery;
/** Current global model overrides, if any. */
@@ -365,6 +372,103 @@ async function discoverAgyModels(config: ExecutorConfig): Promise<ExecutorModelD
}
}
async function discoverCodexModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
// Truthful Codex fallback — no default model assumption
const defaults: string[] = [];
const bin = getBinary(config, "codex");
// Helper: parse JSON models[].slug or data[].id from Codex debug models output
function parseCodexModelsJson(json: unknown): string[] {
if (Array.isArray(json)) {
// Array of { slug: string, ... } or array of strings
return json.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
const obj = item as Record<string, unknown>;
if (typeof obj.slug === "string") return obj.slug;
if (typeof obj.id === "string") return obj.id;
}
return undefined;
}).filter((m): m is string => !!m);
}
// Object with { models: [...] } or { data: [...] } shape
const obj = json as Record<string, unknown>;
// Prefer data[] over models[] for Backward compatibility
const models = Array.isArray(obj.data) ? obj.data :
Array.isArray(obj.models) ? obj.models : undefined;
if (models) {
return models.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
const objItem = item as Record<string, unknown>;
if (typeof objItem.slug === "string") return objItem.slug;
if (typeof objItem.id === "string") return objItem.id;
}
return undefined;
}).filter((m): m is string => !!m);
}
return [];
}
// Try primary discovery first
try {
const result = await runCommand(bin, ["debug", "models"]);
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `codex debug models exited with code ${result.status ?? "unknown"}`);
}
const stdout = result.stdout?.trim() ?? "";
// Try JSON parsing for models[].slug format
if (stdout.startsWith("{") || stdout.startsWith("[")) {
try {
const parsed = JSON.parse(stdout) as unknown;
const models = parseCodexModelsJson(parsed);
if (models.length > 0) return { status: "ok", models, defaults };
// JSON parsed successfully but has no models — skip line-delimited fallback
throw new SkippableError("JSON returned no models");
} catch (err) {
if (err instanceof SkippableError) throw err;
// Fall through to line-delimited parsing (malformed JSON)
}
}
const models = parseLineDelimitedModels(stdout);
if (models.length > 0) return { status: "ok", models, defaults };
} catch {
// Fall through to --bundled retry
}
// Retry with --bundled flag
try {
const result = await runCommand(bin, ["debug", "models", "--bundled"]);
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `codex debug models --bundled exited with code ${result.status ?? "unknown"}`);
}
const stdout = result.stdout?.trim() ?? "";
if (stdout.startsWith("{") || stdout.startsWith("[")) {
try {
const parsed = JSON.parse(stdout) as unknown;
const models = parseCodexModelsJson(parsed);
if (models.length > 0) return { status: "ok", models, defaults };
// JSON parsed successfully but has no models — skip line-delimited fallback
} catch {
// Fall through to line-delimited parsing (malformed JSON)
}
}
if (!stdout.startsWith("{") && !stdout.startsWith("[")) {
const models = parseLineDelimitedModels(stdout);
if (models.length > 0) return { status: "ok", models, defaults };
}
} catch (err) {
return { status: "error", error: cleanError(err), defaults };
}
return { status: "ok", models: defaults, defaults };
}
async function discoverReasonixModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
const defaults: string[] = [];
const bin = getBinary(config, "reasonix");
@@ -400,15 +504,17 @@ export async function discoverExecutorModels(): Promise<ModelDiscoveryResult> {
const configs = {
claude: getExecutorConfig("claude"),
codex: getExecutorConfig("codex"),
agy: getExecutorConfig("agy"),
reasonix: getExecutorConfig("reasonix"),
};
const [claude, agy, reasonix] = await Promise.all([
const [claude, codex, agy, reasonix] = await Promise.all([
discoverClaudeModels(configs.claude),
discoverCodexModels(configs.codex),
discoverAgyModels(configs.agy),
discoverReasonixModels(configs.reasonix),
]);
return { claude, agy, reasonix, current };
return { claude, codex, agy, reasonix, current };
}
+288
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadProjectWorkflowConfig, resolveWorkflowConfig } from "./workflow-config.js";
import type { HostDecisionV1 } from "./workflow-types.js";
import {
WorkflowLockError,
acquireLock,
@@ -185,6 +186,35 @@ describe("Resolved workflow config", () => {
expect(config.executorConfig.model).toBe("claude-sonnet");
});
it("resolves defaultExecutor: codex with binary override", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "codex",
executors: {
codex: { binary: "/custom/codex" },
},
});
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.executor).toBe("codex");
expect(config.executorConfig.binary).toBe("/custom/codex");
});
it("CLI executor override takes precedence over global defaultExecutor: codex", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "codex",
executors: {
codex: { binary: "/codex/bin" },
claude: { binary: "/claude/bin" },
},
});
const config = resolveWorkflowConfig(repoRoot, { executor: "claude" });
expect(config.executor).toBe("claude");
expect(config.executorConfig.binary).toBe("/claude/bin");
});
it("rejects invalid model values", () => {
writeProjectConfig({
version: "1",
@@ -591,6 +621,7 @@ printf '{"type":"result","result":"done","session_id":"${sessionId}"}\\n'
expect(events.find((event) => event.kind === "executor_session_rebound")?.data).toEqual({
previousSessionId: "97a606e8-cfa3-4996-83a8-ddb7ece4234b",
sessionId,
executor: "claude",
});
});
@@ -627,6 +658,263 @@ printf '{"type":"result","result":"done","session_id":"${sessionId}"}\\n'
expect(readState(dir)?.sessionHandle).toEqual(state.sessionHandle);
expect(fs.existsSync(path.join(dir, "events.jsonl"))).toBe(false);
});
it("rejects codex+codex self-review combination", async () => {
const { startWorkflow } = await import("./workflow-engine.js");
const planPath = path.join(tmpDir, "plan-codex-self-review.json");
fs.writeFileSync(planPath, JSON.stringify(plan()));
await expect(startWorkflow({
cwd: repoRoot,
planInput: planPath,
runId: "codex-self-review-1",
executor: "codex",
reviewer: "codex",
})).rejects.toThrow(/cannot self-review/);
});
it("accepts claude reviewer with codex executor (controlled fake binary)", async () => {
const { startWorkflow } = await import("./workflow-engine.js");
const { readState } = await import("./workflow-state.js");
// Create a fake Codex binary that produces valid output
const fakeCodexPath = path.join(tmpDir, "fake-codex-start-resume");
const sessionUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
fs.writeFileSync(fakeCodexPath, `#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "fake-codex 0.1.0"; exit 0
fi
echo '{"type":"thread.started","thread":{"id":"${sessionUuid}"}}'
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
echo '{"type":"result","result":"Done"}'
exit 0
`, { mode: 0o755 });
// Write config so codex binary is found, then commit to satisfy clean-tree check
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: fakeCodexPath } },
}));
const { execSync } = await import("child_process");
execSync("git add agent-workflow.json && git commit -m 'add codex config for test'", { cwd: repoRoot });
// Set HOME so codex probe looks at our tmpDir (no real codex needed)
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const planPath = path.join(tmpDir, "plan-claude-review-codex-exec.json");
fs.writeFileSync(planPath, JSON.stringify(plan()));
// Start the workflow - should NOT throw self-review error, and should succeed
const { runId } = await startWorkflow({
cwd: repoRoot,
planInput: planPath,
runId: "claude-review-codex-exec",
executor: "codex",
reviewer: "claude",
maxCycles: 3,
});
const state = readState(runDir(repoRoot, runId))!;
expect(state.executor).toBe("codex");
expect(state.reviewer).toBe("claude");
expect(state.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
expect(state.status).toBe("awaiting_review");
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("full lifecycle with claude reviewer and codex executor through review/fix/resume/accept", async () => {
const { startWorkflow, reviewWorkflow, decideWorkflow } = await import("./workflow-engine.js");
const { readState } = await import("./workflow-state.js");
const fakeCodexPath = path.join(tmpDir, "fake-codex-lifecycle");
const sessionUuid = "lifecycle-uuid-1111-2222-3333-444444444444";
const resumeMarker = path.join(tmpDir, "codex-resume-called");
const resumedSessionFile = path.join(tmpDir, "codex-resumed-session.txt");
fs.writeFileSync(fakeCodexPath, `#!/bin/bash
set -e
if [[ "$1" == "--version" ]]; then
echo "fake-codex 0.1.0"; exit 0
fi
if [[ "$1" == "exec" && "$2" == "resume" ]]; then
echo "resume_called" > '${resumeMarker}'
resumedId="\${@: -2:1}"
echo "$resumedId" > '${resumedSessionFile}'
echo "// Resume change" >> '${repoRoot}/test.txt'
echo '{"type":"thread.started","thread_id":"'"$resumedId"'","thread":{"id":"'"$resumedId"'"}}'
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
exit 0
fi
echo "// Initial content" > '${repoRoot}/test.txt'
echo '{"type":"thread.started","thread_id":"${sessionUuid}","thread":{"id":"${sessionUuid}"}}'
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: fakeCodexPath } },
}));
const { execSync } = await import("child_process");
execSync("git add agent-workflow.json && git commit -m 'add codex config for lifecycle test'", { cwd: repoRoot });
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const planPath = path.join(tmpDir, "plan-lifecycle.json");
fs.writeFileSync(planPath, JSON.stringify(plan()));
// STEP 1: START
const { runId } = await startWorkflow({
cwd: repoRoot,
planInput: planPath,
runId: "codex-lifecycle",
executor: "codex",
reviewer: "claude",
maxCycles: 3,
});
let state = readState(runDir(repoRoot, runId))!;
expect(state.executor).toBe("codex");
expect(state.reviewer).toBe("claude");
expect(state.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
expect(state.status).toBe("awaiting_review");
expect(state.currentCycle).toBe(1);
// STEP 2: REVIEW CYCLE 1
await reviewWorkflow({ runId, cwd: repoRoot, reviewer: "claude" });
state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("awaiting_host");
expect(state.currentCycle).toBe(1);
// STEP 3: FIX DECISION → triggers resume
const fixDecision: HostDecisionV1 = {
version: "1",
reviewer: "claude",
outcome: "fix",
findingDecisions: [
{ findingId: "finding-1", disposition: "accept", summary: "test.txt should have more content" },
],
decidedAt: new Date().toISOString(),
};
const fixDecisionPath = path.join(tmpDir, "decision-fix.json");
fs.writeFileSync(fixDecisionPath, JSON.stringify(fixDecision));
await decideWorkflow({ runId, cwd: repoRoot, reviewer: "claude", decisionInput: fixDecisionPath });
// Assert resume was called with correct session ID
expect(fs.existsSync(resumeMarker)).toBe(true);
expect(fs.readFileSync(resumedSessionFile, "utf8").trim()).toBe(sessionUuid);
// State should now be awaiting_review (cycle 2)
state = readState(runDir(repoRoot, runId))!;
expect(state.currentCycle).toBe(2);
expect(state.status).toBe("awaiting_review");
// STEP 4: REVIEW CYCLE 2
await reviewWorkflow({ runId, cwd: repoRoot, reviewer: "claude" });
state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("awaiting_host");
// STEP 5: ACCEPT DECISION (process.exit(0) is mocked)
const origExit = process.exit;
(process as any).exit = (code?: number) => {
if (code === 0) throw new Error("MockExit0");
origExit(code);
};
try {
const acceptDecision: HostDecisionV1 = {
version: "1",
reviewer: "claude",
outcome: "accept",
findingDecisions: [],
verificationEvidence: ["Test evidence"],
decidedAt: new Date().toISOString(),
};
const acceptPath = path.join(tmpDir, "decision-accept.json");
fs.writeFileSync(acceptPath, JSON.stringify(acceptDecision));
await expect(decideWorkflow({ runId, cwd: repoRoot, reviewer: "claude", decisionInput: acceptPath }))
.rejects.toThrow("MockExit0");
} finally {
process.exit = origExit;
}
// Verify completed state was persisted before process.exit
state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("completed");
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("retryExecuteWorkflow validates codex session rebind", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
const runId = "codex-rebind-valid";
const sessionId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const dir = runDir(repoRoot, runId);
// Create a valid codex session file
const codexDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(codexDir, { recursive: true });
fs.writeFileSync(
path.join(codexDir, `${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: repoRoot } }) + "\n" +
JSON.stringify({ type: "initialization", message: { content: "# Implementation Task: Test Plan" } }) + "\n",
);
// Set up a fake codex binary that returns a valid thread.started event
const fakeCodexPath = path.join(tmpDir, "fake-codex-for-rebind");
fs.writeFileSync(fakeCodexPath, `#!/bin/bash
# Simulate codex exec resume - echo a thread.started event
echo '{"type":"thread.started","thread":{"id":"${sessionId}"}}'
exit 0
`, { mode: 0o755 });
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: plan(),
executor: "codex",
maxCycles: 3,
});
state.executorConfig = { binary: fakeCodexPath };
state.status = "blocked";
state.stopDescription = "Executor exited with failure: codex execution failed";
state.currentCycle = 1;
state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }];
state.sessionHandle = { kind: "codex", sessionId: "old-id-0000-0000-0000-000000000000" };
fs.mkdirSync(dir, { recursive: true });
writeState(dir, state);
await retryExecuteWorkflow({ runId, sessionId });
const recovered = readState(dir)!;
expect(recovered.sessionHandle).toEqual({ kind: "codex", sessionId });
const events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8")
.trim().split("\n").map((line) => JSON.parse(line));
expect(events.find((event) => event.kind === "executor_session_rebound")?.data).toEqual({
previousSessionId: "old-id-0000-0000-0000-000000000000",
sessionId,
executor: "codex",
});
});
});
// ---------------------------------------------------------------------------
+181
View File
@@ -169,3 +169,184 @@ echo 'started'
abortWorkflow({ runId: fresh.runId, reason: "test cleanup" });
});
});
describe("Codex task-level executor session reuse", () => {
let tmpDir: string;
let repoRoot: string;
let stateRoot: string;
let homeDir: string;
let previousHome: string | undefined;
let previousWorkflowDir: string | undefined;
const threadId = "019f6e2d-333c-7900-bbf4-8ad8a9ee162f";
const sessionUuid = "b7a8c901-2345-4def-9012-3456789abcde";
let codexBin: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-codex-reuse-"));
repoRoot = path.join(tmpDir, "repo");
stateRoot = path.join(tmpDir, "state");
homeDir = path.join(tmpDir, "home");
codexBin = path.join(tmpDir, "fake-codex.sh");
fs.mkdirSync(repoRoot);
fs.mkdirSync(homeDir);
previousHome = process.env.HOME;
previousWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.HOME = homeDir;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
// Create a valid codex session file in ~/.codex/sessions/
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `${sessionUuid}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: repoRoot } }) + "\n" +
JSON.stringify({ type: "initialization", message: { content: "# Implementation Task: Test Codex Plan" } }) + "\n",
);
// Create a fake Codex CLI that records args and emits thread.started on start/resume
const uuid = sessionUuid; // captured for bash var expansion
fs.writeFileSync(codexBin, `#!/bin/bash
# Fake Codex CLI: record args to NUL-delimited file, emit thread.started
printf '%s\\0' "$@" >> '${path.join(tmpDir, "codex-args.bin")}'
if [[ "$1" == "--version" ]]; then
echo "fake-codex 0.1.0"
exit 0
fi
if [[ "$1" == "exec" && "$2" == "resume" ]]; then
# Session ID is immediately before the final "-" prompt marker
resumedId="\${@: -2:1}"
echo '{"type":"thread.started","thread_id":"'"$resumedId"'","thread":{"id":"'"$resumedId"'"}}'
echo '{"type":"turn.completed"}'
echo '{"type":"result","result":"Resume complete"}'
exit 0
elif [[ "$1" == "exec" ]]; then
echo "{\\"type\\":\\"thread.started\\",\\"thread\\":{\\"id\\":\\"${uuid}\\"}}"
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
exit 0
else
exit 1
fi
`, { mode: 0o755 });
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
fs.writeFileSync(path.join(repoRoot, "tracked.txt"), "unchanged\n");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot, stdio: "ignore" });
});
afterEach(() => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousWorkflowDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
else process.env.AGENT_WORKFLOW_DIR = previousWorkflowDir;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writePlan(name: string): string {
const planPath = path.join(tmpDir, `${name}.json`);
fs.writeFileSync(planPath, JSON.stringify({
version: "1",
title: name,
planMarkdown: `Implement ${name}.`,
scope: ["tracked.txt"],
acceptanceCriteria: [`${name} is complete`],
verificationCommands: [["git", "status", "--short"]],
}));
return planPath;
}
it("resumes the same Codex session when a later plan belongs to the current task", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex First Plan"),
codexThreadId: threadId,
});
const firstState = readState(runDir(repoRoot, first.runId))!;
const firstTaskId = firstState.taskId;
expect(firstState.taskSessionReused).toBe(false);
expect(firstState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
expect(readTaskBinding(repoRoot, threadId)?.executors.codex?.handle).toEqual(firstState.sessionHandle);
abortWorkflow({ runId: first.runId, reason: "test boundary" });
const second = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Second Plan"),
codexThreadId: threadId,
});
const secondState = readState(runDir(repoRoot, second.runId))!;
expect(secondState.taskId).toBe(firstTaskId);
expect(secondState.taskSessionReused).toBe(true);
// The resumed session must use the EXACT same ID
expect(secondState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
const args = fs.readFileSync(path.join(tmpDir, "codex-args.bin"), "utf8").split("\0").filter(Boolean);
expect(args).toContain("resume");
expect(args).toContain(sessionUuid);
const events = fs.readFileSync(path.join(runDir(repoRoot, second.runId), "events.jsonl"), "utf8");
expect(events).toContain('"kind":"executor_session_reused"');
abortWorkflow({ runId: second.runId, reason: "test cleanup" });
});
it("blocks reuse when Codex session file is missing from ~/.codex/sessions", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Recoverable Task"),
codexThreadId: threadId,
});
abortWorkflow({ runId: first.runId, reason: "test boundary" });
// Remove the session file so validation fails
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.rmSync(path.join(sessionsDir, `${sessionUuid}.jsonl`), { force: true });
await expect(startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Follow-up Plan"),
codexThreadId: threadId,
})).rejects.toThrow(/Codex session file not found/);
});
it("blocks reuse when Codex session belongs to a different repository", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Recoverable Task 2"),
codexThreadId: threadId,
});
abortWorkflow({ runId: first.runId, reason: "test boundary" });
// Rewrite the session file with a different cwd
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.writeFileSync(
path.join(sessionsDir, `${sessionUuid}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: "/different/repo" } }) + "\n",
);
await expect(startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Wrong Repo Plan"),
codexThreadId: threadId,
})).rejects.toThrow(/does not belong to repository/);
});
});
+9 -2
View File
@@ -19,7 +19,7 @@ export type ReviewerKind = (typeof REVIEWER_KINDS)[number];
// Executors
// ---------------------------------------------------------------------------
export const EXECUTOR_KINDS = ["reasonix", "claude", "agy"] as const;
export const EXECUTOR_KINDS = ["reasonix", "claude", "agy", "codex"] as const;
export type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
// ---------------------------------------------------------------------------
@@ -143,6 +143,11 @@ export interface ClaudeSessionHandle {
sessionId: string;
}
export interface CodexSessionHandle {
kind: "codex";
sessionId: string;
}
export interface AgySessionHandle {
kind: "agy";
/** Conversation ID from agy output, if available. */
@@ -154,7 +159,8 @@ export interface AgySessionHandle {
export type ExecutorSessionHandle =
| ReasonixSessionHandle
| ClaudeSessionHandle
| AgySessionHandle;
| AgySessionHandle
| CodexSessionHandle;
export interface ScopeViolation {
id: string;
@@ -346,6 +352,7 @@ export interface WorkflowProjectConfig {
reasonix?: ExecutorConfig;
claude?: ExecutorConfig;
agy?: ExecutorConfig;
codex?: ExecutorConfig;
};
}
+24 -2
View File
@@ -763,6 +763,7 @@ export function getAssetsHtml(): string {
<input type="text" id="search-box" class="search-input" placeholder="搜索项目或运行记录...">
<select id="executor-filter" class="select-input">
<option value="">全部执行器</option>
<option value="codex">Codex</option>
<option value="claude">Claude Code</option>
<option value="reasonix">Reasonix</option>
<option value="agy">Agy</option>
@@ -938,11 +939,31 @@ export function getAssetsHtml(): string {
<div class="settings-header">
<div>
<h2 id="settings-title">全局执行器模型配置</h2>
<div class="settings-subtitle">分别配置 Claude、Agy、Reasonix 的全局模型。仅写入 ~/.agent-workflow/config.json 中的 executors.&lt;kind&gt;.model;项目配置与 CLI 仍可覆盖全局值。清空后恢复 CLI 默认选择。</div>
<div class="settings-subtitle">分别配置 Claude、Codex、Agy、Reasonix 的全局模型。仅写入 ~/.agent-workflow/config.json 中的 executors.&lt;kind&gt;.model;项目配置与 CLI 仍可覆盖全局值。清空后恢复 CLI 默认选择。</div>
</div>
<button id="close-settings-btn" class="icon-btn" title="关闭" aria-label="关闭全局配置">✕</button>
</div>
<div class="settings-body">
<div class="executor-settings-card" data-executor="codex">
<div class="executor-settings-title">
<h3>Codex</h3>
<span class="status-pill loading" id="status-codex">加载中</span>
</div>
<div class="settings-field">
<label for="model-select-codex">已发现模型</label>
<div class="settings-field-row">
<select id="model-select-codex" class="select-input"></select>
<button class="control-btn" type="button" onclick="applyDiscoveredModel('codex')">填入</button>
</div>
</div>
<div class="settings-field">
<label for="model-input-codex">手动输入模型</label>
<input id="model-input-codex" class="search-input" type="text" placeholder="例如 gemini-2.5-pro 或留空使用默认">
<div class="settings-help">通过 codex debug models 发现候选;发现失败时仍可手动输入。</div>
<div class="settings-error" id="error-codex"></div>
</div>
</div>
<div class="executor-settings-card" data-executor="claude">
<div class="executor-settings-title">
<h3>Claude Code</h3>
@@ -2458,8 +2479,9 @@ export function getAssetsHtml(): string {
document.getElementById('executor-filter').addEventListener('change', renderRuns);
document.getElementById('status-filter').addEventListener('change', renderRuns);
const EXECUTOR_KINDS_UI = ['claude', 'agy', 'reasonix'];
const EXECUTOR_KINDS_UI = ['codex', 'claude', 'agy', 'reasonix'];
const EXECUTOR_LABELS = {
codex: 'Codex',
claude: 'Claude Code',
agy: 'Agy',
reasonix: 'Reasonix',
+78
View File
@@ -174,6 +174,84 @@ describe("workflow-web-parser - Agy raw fallback", () => {
});
});
describe("workflow-web-parser - Codex stream-json", () => {
it("parses thread.started event", () => {
const line = '{"type":"thread.started","thread":{"id":"abc-123"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "system",
content: "Thread started: abc-123",
meta: { type: "thread.started", thread: { id: "abc-123" } },
raw: line,
});
});
it("parses item.started event with tool name", () => {
const line = '{"type":"item.started","item":{"name":"Bash","input":"ls -la"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "tool_call",
content: "Tool: Bash",
meta: { type: "item.started", item: { name: "Bash", input: "ls -la" } },
raw: line,
});
});
it("parses item.completed event", () => {
const line = '{"type":"item.completed","item":{"name":"Bash"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "result",
content: "Item completed",
meta: { type: "item.completed", item: { name: "Bash" } },
raw: line,
});
});
it("parses turn.completed event", () => {
const line = '{"type":"turn.completed"}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "result",
content: "Turn completed",
meta: { type: "turn.completed" },
raw: line,
});
});
it("parses error event", () => {
const line = '{"type":"error","error":"API rate limit exceeded"}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "error",
content: "API rate limit exceeded",
meta: { type: "error", error: "API rate limit exceeded" },
raw: line,
});
});
it("parses tool event as fallback", () => {
const line = '{"type":"tool","name":"Read","input":{"file_path":"src/index.ts"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "tool_call",
content: "Tool: Read",
meta: { type: "tool", name: "Read", input: { file_path: "src/index.ts" } },
raw: line,
});
});
it("falls through to raw for non-JSON lines", () => {
const line = "plain text output";
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "raw",
content: line,
raw: line,
});
});
});
describe("parseLogContent", () => {
it("splits text by lines and parses all", () => {
const content = "line 1\nline 2\r\nline 3\n";
+56
View File
@@ -181,6 +181,62 @@ export function parseLogLine(line: string, executor: string): ParsedLogEntry[] {
}
}
// 1b. Codex stream-json parser (similar to Claude JSONL format)
if (executor === "codex" && trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
const parsed = JSON.parse(trimmed) as Record<string, any>;
const type = String(parsed.type ?? "");
if (type === "thread.started") {
const threadId = parsed.thread_id ?? parsed.thread?.id ?? "";
return [{ type: "system", content: threadId ? `Thread started: ${threadId}` : "Thread started", meta: parsed, raw: line }];
}
if (type === "item.started") {
const item = parsed.item;
if (item && typeof item.tool_name === "string") {
return [{ type: "tool_call", content: `Tool: ${item.tool_name}`, meta: parsed, raw: line }];
}
if (item && typeof item.name === "string") {
return [{ type: "tool_call", content: `Tool: ${item.name}`, meta: parsed, raw: line }];
}
if (item && typeof item.type === "string") {
return [{ type: "tool_call", content: `Item: ${item.type}`, meta: parsed, raw: line }];
}
return [{ type: "raw", content: "Item started", meta: parsed, raw: line }];
}
if (type === "item.completed") {
return [{ type: "result", content: "Item completed", meta: parsed, raw: line }];
}
if (type === "turn.completed") {
return [{ type: "result", content: "Turn completed", meta: parsed, raw: line }];
}
if (type === "error") {
const errMsg = String(parsed.message ?? parsed.error ?? "Codex error");
return [{ type: "error", content: errMsg, meta: parsed, raw: line }];
}
if (type === "result" || parsed.result !== undefined) {
if (parsed.is_error === true) {
return [{ type: "error", content: String(parsed.result ?? parsed.error ?? "Error"), meta: parsed, raw: line }];
}
return [{ type: "result", content: String(parsed.result ?? ""), meta: parsed, raw: line }];
}
// Activity/status events
if (type === "tool" && typeof parsed.name === "string") {
return [{ type: "tool_call", content: `Tool: ${parsed.name}`, meta: parsed, raw: line }];
}
return [{ type: "raw", content: trimmed, meta: parsed, raw: line }];
} catch {
// Fall through
}
}
// 2. Reasonix parser
if (executor === "reasonix") {
const clean = line.replace(ANSI_REGEX, "");
+12
View File
@@ -405,6 +405,18 @@ printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}
expect(res.body).toContain("`/api/runs/${runId}/stream`");
});
it("includes Codex executor card in settings HTML", async () => {
const res = await makeRequest(`${baseUrl}/`);
// Codex settings card with right data attributes and IDs
expect(res.body).toMatch(/<div class="executor-settings-card" data-executor="codex">/);
expect(res.body).toMatch(/id="model-select-codex"/);
expect(res.body).toMatch(/id="model-input-codex"/);
expect(res.body).toMatch(/id="status-codex"/);
expect(res.body).toMatch(/id="error-codex"/);
// Codex option in executor filter
expect(res.body).toMatch(/<option value="codex">Codex<\/option>/);
});
it("includes responsive mobile layout CSS for sidebar", async () => {
const res = await makeRequest(`${baseUrl}/`);
expect(res.body).toContain("@media (max-width: 768px)");