feat: add Codex executor with session resume
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.<kind>.model;项目配置与 CLI 仍可覆盖全局值。清空后恢复 CLI 默认选择。</div>
|
||||
<div class="settings-subtitle">分别配置 Claude、Codex、Agy、Reasonix 的全局模型。仅写入 ~/.agent-workflow/config.json 中的 executors.<kind>.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',
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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)");
|
||||
|
||||
Reference in New Issue
Block a user