Delegate to Codex too

c2b0889cdb2d · AtlantisPleb · · parent 1b07ec31dec5

Delegate to Codex too

The fourth harness, in the shape the other three share. `codex exec
--json` emits newline-delimited events, so the lane maps a structured
stream into what the fleet already draws rather than parsing prose —
the same reason the Devin lane moved to ACP and the Claude lane uses
stream-json.

The child runs under the user's own Codex environment. This session
never reads, copies, or forwards a foreign credential, and attribution
stays honest: Codex chooses its own model unless the caller names one,
so the lane reports what Codex reported and otherwise says it was not
reported rather than inventing a name.

A sandbox rather than an approval mode, because `codex exec` has no
approval flag and a delegated child has nobody to ask: it needs a
stated boundary instead of a prompt. The default is workspace-write —
the checkout it was pointed at and nothing outside it — and a caller
who wants narrower or wider passes one.

Built by a Devin child through the openagents coder's delegate tool.
The child reached for `--ask-for-approval`, which `codex exec` does not
have and which fails the command outright; caught in review against
`codex exec --help` on this machine, along with the test that asserted
it. 831 CLI tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • added packages/openagents-cli/test/coder-delegate-codex.test.ts

Diff

5 files changed, +868 -9

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2470,
7
    "filesScanned": 2471,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:6b3d3d24bcf4c883ce8e7c533b6d8f547bf5aee845e8631fb086f84af9bc4b16",
4
  "sourceDigest": "sha256:bf21afe12adf02f3bdb957627605f1726f8a01d04a21f9dd9ae891016754b4cb",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (65 tracked test files)"
1879
          "ref": "packages/openagents-cli (66 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +20 -6

@@ -40,6 +40,7 @@ import {

40 40
  ClaudeCodeHarness,
41 41
  DelegateFleet,
42 42
  DevinHarness,
43
  CodexHarness,
43 44
  describePrompt,
44 45
  firstAvailableChildModel,
45 46
  OpencodeHarness,

@@ -1726,12 +1727,16 @@ async function buildDelegation(options: {

1726 1727
          )
1727 1728
        : /^devin(:.+)?$/.test(choice)
1728 1729
          ? new DevinHarness(choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {})
1729
          : new OpencodeHarness({
1730
              model: choice,
1731
              ...(command === undefined ? {} : { command }),
1732
              ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1733
              autoApprove: options.autoApprove,
1734
            });
1730
          : /^codex(:.+)?$/.test(choice)
1731
            ? new CodexHarness(
1732
                choice.startsWith("codex:") ? { model: choice.slice(6) } : {},
1733
              )
1734
            : new OpencodeHarness({
1735
                model: choice,
1736
                ...(command === undefined ? {} : { command }),
1737
                ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1738
                autoApprove: options.autoApprove,
1739
              });
1735 1740
1736 1741
    return {
1737 1742
      fleet: new DelegateFleet(registry, harness, {

@@ -1786,6 +1791,15 @@ async function buildDelegation(options: {

1786 1791
    };
1787 1792
  }
1788 1793
1794
  if (askedFor !== undefined && /^codex(:(.+))?$/.test(askedFor)) {
1795
    const model = /^codex:(.+)$/.exec(askedFor)?.[1];
1796
    const lane = laneFor(model === undefined ? "codex" : `codex:${model}`);
1797
    return {
1798
      delegation: { registry, ...lane, models: CHILD_MODELS, fleetFor },
1799
      close: () => Promise.resolve(),
1800
    };
1801
  }
1802
1789 1803
  let model: string;
1790 1804
  let configPath: string | undefined;
1791 1805
  let close: () => Promise<void>;
packages/openagents-cli/src/coder-delegate.ts modified +381

@@ -380,6 +380,185 @@ export function parseClaudeEvent(line: string): DelegateEvent | undefined {

380 380
  return undefined;
381 381
}
382 382
383
/**
384
 * Read one line of `codex exec --json` output.
385
 *
386
 * Codex's exec mode prints a JSONL event stream. The mapping is deliberately
387
 * lossy: this fleet only needs to show that the child started, what it is
388
 * doing, what it said, and how many tokens it used. We trust the `type`
389
 * discriminator and fall back to common field names, so the parser survives
390
 * minor shape changes. Anything that does not map cleanly is ignored.
391
 *
392
 * Mapped:
393
 *   - `thread.started` (thread_id / id / session_id) -> `session`
394
 *   - `turn.completed` (usage)                      -> `tokens`
395
 *   - `item.started` / `item.completed` (text)      -> `text`
396
 *   - `item.started` / `item.completed` (tool)      -> `tool`
397
 *   - `error`                                       -> `error`
398
 *
399
 * Lossy:
400
 *   - Item details and output are reduced to a target phrase or a text value.
401
 *     Rich output, tool result objects, file content, and completion details
402
 *     are dropped after the target or text is extracted.
403
 *   - No separate `tool.completed` event exists in `DelegateEvent`, so tool
404
 *     completion details are ignored.
405
 *   - Unknown item types and turn lifecycle events are dropped.
406
 */
407
export function parseCodexEvent(line: string): DelegateEvent | undefined {
408
  const trimmed = line.trim();
409
  if (trimmed.length === 0 || !trimmed.startsWith("{")) return undefined;
410
411
  let event: Record<string, unknown>;
412
  try {
413
    event = JSON.parse(trimmed) as Record<string, unknown>;
414
  } catch {
415
    return undefined;
416
  }
417
418
  const type = stringField(event, "type");
419
420
  if (type === "thread.started") {
421
    const sessionId =
422
      stringField(event, "thread_id") ??
423
      stringField(event, "id") ??
424
      stringField(event, "session_id");
425
    if (sessionId !== undefined) return { type: "session", sessionId };
426
    return undefined;
427
  }
428
429
  if (
430
    type === "turn.completed" ||
431
    type === "thread.completed" ||
432
    type === "turn.finished" ||
433
    type === "usage"
434
  ) {
435
    const usage = isRecord(event["usage"]) ? event["usage"] : undefined;
436
    if (usage !== undefined) {
437
      const input =
438
        numberField(usage, "input_tokens") ??
439
        numberField(usage, "input") ??
440
        numberField(usage, "prompt_tokens");
441
      const output =
442
        numberField(usage, "output_tokens") ??
443
        numberField(usage, "output") ??
444
        numberField(usage, "completion_tokens");
445
      if (input !== undefined && output !== undefined) {
446
        return { type: "tokens", input, output };
447
      }
448
    }
449
    return undefined;
450
  }
451
452
  if (type === "item.started" || type === "item.completed") {
453
    const item = isRecord(event["item"]) ? event["item"] : event;
454
    if (isRecord(item)) {
455
      return parseCodexItem(item);
456
    }
457
    return undefined;
458
  }
459
460
  if (type === "error") {
461
    return { type: "error", message: describeCodexError(event) };
462
  }
463
464
  return undefined;
465
}
466
467
/** The phrase a Codex item is working on, whether it is a tool or a file. */
468
function codexToolTarget(input: Record<string, unknown>, item: Record<string, unknown>): string | undefined {
469
  for (const key of [
470
    "command",
471
    "file_path",
472
    "path",
473
    "file",
474
    "url",
475
    "pattern",
476
    "query",
477
    "description",
478
    "content",
479
    "text",
480
    "name",
481
  ]) {
482
    const value = stringField(input, key);
483
    if (value !== undefined && value.length > 0) return value;
484
  }
485
  const itemFile = stringField(item, "file");
486
  if (itemFile !== undefined && itemFile.length > 0) return itemFile;
487
  return undefined;
488
}
489
490
/** The input object for a Codex tool item, wherever the arguments live. */
491
function codexItemInput(item: Record<string, unknown>): Record<string, unknown> {
492
  if (isRecord(item["arguments"])) return item["arguments"];
493
  if (isRecord(item["input"])) return item["input"];
494
  const fn = item["function"];
495
  if (isRecord(fn) && isRecord(fn["arguments"])) return fn["arguments"];
496
  const tool = item["tool"];
497
  if (isRecord(tool) && isRecord(tool["input"])) return tool["input"];
498
  const content = item["content"];
499
  if (isRecord(content)) return content;
500
  return {};
501
}
502
503
/** Read one Codex item (a tool, a message, or a file) into a fleet event. */
504
function parseCodexItem(item: Record<string, unknown>): DelegateEvent | undefined {
505
  const role = stringField(item, "role");
506
  const itemType = stringField(item, "type") ?? "";
507
  const isAssistant =
508
    role === "assistant" || itemType === "assistant" || itemType === "message";
509
510
  if (isAssistant) {
511
    const content = item["content"];
512
    if (typeof content === "string") return { type: "text", value: content };
513
    if (isRecord(content)) {
514
      const text = stringField(content, "text") ?? stringField(content, "content");
515
      if (text !== undefined) return { type: "text", value: text };
516
    }
517
    const output = item["output"];
518
    if (typeof output === "string") return { type: "text", value: output };
519
    if (isRecord(output)) {
520
      const text = stringField(output, "text") ?? stringField(output, "content");
521
      if (text !== undefined) return { type: "text", value: text };
522
    }
523
  }
524
525
  const isTool =
526
    itemType === "function" ||
527
    itemType === "tool" ||
528
    itemType === "command" ||
529
    itemType === "file" ||
530
    item["function"] !== undefined ||
531
    item["tool"] !== undefined;
532
  if (isTool) {
533
    const callId =
534
      stringField(item, "id") ??
535
      stringField(item, "item_id") ??
536
      stringField(item, "call_id") ??
537
      "codex_tool";
538
    const name =
539
      stringField(item, "name") ??
540
      (isRecord(item["function"]) ? stringField(item["function"], "name") : undefined) ??
541
      (isRecord(item["tool"]) ? stringField(item["tool"], "name") : undefined) ??
542
      itemType;
543
    const input = codexItemInput(item);
544
    const target = codexToolTarget(input, item);
545
    return { type: "tool", callId, name, target };
546
  }
547
548
  return undefined;
549
}
550
551
/** The sentence behind a Codex error event. */
552
function describeCodexError(event: Record<string, unknown>): string {
553
  const error = isRecord(event["error"]) ? event["error"] : {};
554
  return (
555
    stringField(event, "message") ??
556
    stringField(error, "message") ??
557
    stringField(event, "error") ??
558
    "the child agent reported an error"
559
  );
560
}
561
383 562
/** The phrase a Claude tool_use block is working on. */
384 563
function claudeTarget(input: Record<string, unknown>): string | undefined {
385 564
  for (const key of [

@@ -876,6 +1055,197 @@ export class ClaudeCodeHarness implements DelegateHarness {

876 1055
  }
877 1056
}
878 1057
1058
/**
1059
 * How a Codex child is run.
1060
 */
1061
export interface CodexHarnessOptions {
1062
  /**
1063
   * The `codex exec --sandbox` policy for the child. Defaults to
1064
   * `workspace-write`: the checkout it was pointed at, and nothing outside it.
1065
   */
1066
  readonly sandbox?: string | undefined;
1067
  /** The binary. Defaults to `codex`. */
1068
  readonly command?: string | undefined;
1069
  /** The model name passed to `-m, --model`. If unset, the harness reports `not reported`. */
1070
  readonly model?: string | undefined;
1071
  /**
1072
   * The approval policy for unattended runs.
1073
   *
1074
   * Codex's exec mode can ask for approval before running commands. A
1075
   * delegated child has nobody to ask, so the default is `never`. Pass another
1076
   * value only when the caller's own policy requires it.
1077
   */
1078
  readonly permissionMode?: string | undefined;
1079
  /** Extra environment for the child. */
1080
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
1081
}
1082
1083
/**
1084
 * Children run by the OpenAI `codex` CLI in `exec --json` mode.
1085
 *
1086
 * `codex exec --json` emits newline-delimited JSON events. This harness drives
1087
 * it like the others: it streams events, writes the raw transcript, supports
1088
 * resuming with `exec resume`, and can be stopped cleanly.
1089
 *
1090
 * It inherits the user's `codex` environment and credentials. It does not read,
1091
 * copy, or forward any Codex credentials from this process.
1092
 */
1093
export class CodexHarness implements DelegateHarness {
1094
  readonly agent = "codex";
1095
  private readonly options: CodexHarnessOptions;
1096
  private reportedModel: string | undefined;
1097
1098
  constructor(options: CodexHarnessOptions = {}) {
1099
    this.options = options;
1100
  }
1101
1102
  get model(): string {
1103
    return this.options.model ?? this.reportedModel ?? "not reported";
1104
  }
1105
1106
  async *run(
1107
    input: {
1108
      readonly prompt: string;
1109
      readonly cwd: string;
1110
      readonly transcriptPath: string;
1111
      readonly resumeSessionId?: string | undefined;
1112
    },
1113
    signal: AbortSignal,
1114
  ): AsyncIterable<DelegateEvent> {
1115
    const command = this.options.command ?? "codex";
1116
1117
    // `--json` puts Codex in newline-delimited event mode.
1118
    //
1119
    // Sandbox rather than approval mode: `codex exec` has no
1120
    // `--ask-for-approval`, and a delegated child has nobody to ask anyway, so
1121
    // what it needs is a stated boundary rather than a prompt. The default is
1122
    // `workspace-write` — the child may edit the checkout it was pointed at
1123
    // and nothing outside it — and a caller who wants a narrower or wider one
1124
    // passes it. Checked against `codex exec --help` rather than assumed.
1125
    const sandbox = this.options.sandbox ?? "workspace-write";
1126
    const args =
1127
      input.resumeSessionId === undefined
1128
        ? ["exec", "--json", "--sandbox", sandbox]
1129
        : ["exec", "resume", "--json", "--sandbox", sandbox];
1130
    if (this.options.model !== undefined) {
1131
      args.push("-m", this.options.model);
1132
    }
1133
    if (input.resumeSessionId === undefined) {
1134
      args.push(input.prompt);
1135
    } else {
1136
      args.push(input.resumeSessionId, input.prompt);
1137
    }
1138
1139
    mkdirSync(dirname(input.transcriptPath), { recursive: true });
1140
    const transcript = createWriteStream(input.transcriptPath, { flags: "a" });
1141
1142
    const child = spawn(command, args, {
1143
      cwd: input.cwd,
1144
      env: { ...process.env, ...this.options.env },
1145
      stdio: ["ignore", "pipe", "pipe"],
1146
      detached: true,
1147
    });
1148
1149
    const queue: DelegateEvent[] = [];
1150
    let notify: (() => void) | undefined;
1151
    const wake = () => {
1152
      notify?.();
1153
      notify = undefined;
1154
    };
1155
1156
    let stderr = "";
1157
    let stdout = "";
1158
    let pending = "";
1159
    let reported: string | undefined;
1160
    let exited = false;
1161
    let failure: string | undefined;
1162
1163
    const onAbort = () => {
1164
      killTree(child, "SIGTERM");
1165
      const grace = setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS);
1166
      grace.unref();
1167
      child.once("close", () => clearTimeout(grace));
1168
    };
1169
    signal.addEventListener("abort", onAbort, { once: true });
1170
1171
    child.stdout.setEncoding("utf8");
1172
    child.stdout.on("data", (chunk: string) => {
1173
      transcript.write(chunk);
1174
      stdout = `${stdout}${chunk}`.slice(-4000);
1175
      pending += chunk;
1176
      let newline = pending.indexOf("\n");
1177
      while (newline >= 0) {
1178
        const line = pending.slice(0, newline);
1179
        pending = pending.slice(newline + 1);
1180
        // If Codex reports which model is answering, use it for the lane label
1181
        // when the caller did not name one explicitly.
1182
        try {
1183
          const raw = JSON.parse(line) as Record<string, unknown>;
1184
          if (raw["type"] === "thread.started" && this.options.model === undefined) {
1185
            const maybe = stringField(raw, "model");
1186
            if (maybe !== undefined) this.reportedModel = maybe;
1187
          }
1188
        } catch {
1189
          // Not JSON; the line will be handled by parseCodexEvent below.
1190
        }
1191
        const event = parseCodexEvent(line);
1192
        if (event !== undefined) {
1193
          if (event.type === "error") reported = event.message;
1194
          queue.push(event);
1195
        }
1196
        newline = pending.indexOf("\n");
1197
      }
1198
      wake();
1199
    });
1200
1201
    child.stderr.setEncoding("utf8");
1202
    child.stderr.on("data", (chunk: string) => {
1203
      transcript.write(chunk);
1204
      stderr = `${stderr}${chunk}`.slice(-4000);
1205
    });
1206
1207
    child.on("error", (cause: Error) => {
1208
      failure =
1209
        (cause as NodeJS.ErrnoException).code === "ENOENT"
1210
          ? `The \`${command}\` harness is not on the path.`
1211
          : cause.message;
1212
      exited = true;
1213
      wake();
1214
    });
1215
1216
    child.on("close", (code) => {
1217
      const trailing = parseCodexEvent(pending);
1218
      if (trailing !== undefined) {
1219
        if (trailing.type === "error") reported = trailing.message;
1220
        queue.push(trailing);
1221
      }
1222
      if (failure === undefined && code !== 0 && !signal.aborted) {
1223
        failure = reported ?? describeExit(code, stderr, stdout);
1224
      }
1225
      exited = true;
1226
      wake();
1227
    });
1228
1229
    try {
1230
      while (true) {
1231
        while (queue.length > 0) {
1232
          const event = queue.shift();
1233
          if (event !== undefined) yield event;
1234
        }
1235
        if (exited) break;
1236
        await new Promise<void>((resolve) => {
1237
          notify = resolve;
1238
        });
1239
      }
1240
    } finally {
1241
      signal.removeEventListener("abort", onAbort);
1242
      transcript.end();
1243
    }
1244
1245
    if (failure !== undefined) throw new Error(failure);
1246
  }
1247
}
1248
879 1249
export const FREE_CHILD_MODELS: ReadonlyArray<string> = [
880 1250
  // Ox Alpha, free and unlimited while it lasts. The slug says neither `ox`
881 1251
  // nor `alpha`: opencode's own normalization maps `x-preview-f` to `ox-alpha`

@@ -917,6 +1287,7 @@ export const CHILD_LANE_ALIASES: Readonly<Record<string, string>> = {

917 1287
  [SELF_CHILD_LANE]: SELF_CHILD_LANE,
918 1288
  gemini: SELF_CHILD_LANE,
919 1289
  claude: "claude",
1290
  codex: "codex",
920 1291
};
921 1292
922 1293
/**

@@ -995,6 +1366,15 @@ export const CHILD_LANES: ReadonlyArray<ChildLane> = [

995 1366
    served: "Anthropic via Claude Code, on this machine's Claude credentials",
996 1367
    bestFor: "work that needs Claude's toolset and tolerates its own cost boundary",
997 1368
  },
1369
  {
1370
    name: "codex",
1371
    harness: "codex (the OpenAI Codex CLI, its own tools)",
1372
    // Codex exec may report a model in `thread.started`; the harness only
1373
    // advertises one the caller asked for.
1374
    model: "not reported",
1375
    served: "OpenAI, on this machine's Codex credentials",
1376
    bestFor: "work that needs the Codex agent and tolerates its own cost boundary",
1377
  },
998 1378
];
999 1379
1000 1380
/** One lane as a line a model can read. */

@@ -1026,6 +1406,7 @@ export const resolveChildLane = (name: string): string | undefined => {

1026 1406
  if (asked.length === 0) return undefined;
1027 1407
  if (/^claude(:.+)?$/.test(asked)) return asked;
1028 1408
  if (/^devin(:.+)?$/.test(asked)) return asked;
1409
  if (/^codex(:.+)?$/.test(asked)) return asked;
1029 1410
  const aliased = CHILD_LANE_ALIASES[asked];
1030 1411
  if (aliased !== undefined) return aliased;
1031 1412
  return FREE_CHILD_MODELS.includes(asked) ? asked : undefined;
packages/openagents-cli/test/coder-delegate-codex.test.ts added +464

@@ -0,0 +1,464 @@

1
import { chmodSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import {
7
  CodexHarness,
8
  parseCodexEvent,
9
  type DelegateEvent,
10
} from "../src/coder-delegate.js";
11
12
/**
13
 * A stand-in for `codex exec --json`, so the tests cost nothing and never
14
 * call out.
15
 *
16
 * It emits the JSONL lines the harness expects and, optionally, records the
17
 * arguments or working directory it was called with before exiting.
18
 */
19
const fakeCodex = (options: {
20
  readonly lines?: ReadonlyArray<string>;
21
  readonly exitCode?: number;
22
  readonly hang?: boolean;
23
  readonly recordArgsTo?: string;
24
  readonly recordCwdTo?: string;
25
  readonly stderr?: string;
26
} = {}): string => {
27
  const directory = mkdtempSync(join(tmpdir(), "codex-"));
28
  const script = join(directory, "codex.mjs");
29
30
  writeFileSync(
31
    script,
32
    `
33
import { appendFileSync } from "node:fs";
34
const recordTo = ${JSON.stringify(options.recordArgsTo ?? null)};
35
const recordCwd = ${JSON.stringify(options.recordCwdTo ?? null)};
36
if (recordTo) appendFileSync(recordTo, process.argv.slice(2).join(" ") + "\\n");
37
if (recordCwd) appendFileSync(recordCwd, process.cwd() + "\\n");
38
if (${options.hang === true}) { setInterval(() => {}, 1000); }
39
${(options.lines ?? [])
40
  .map((line) => `process.stdout.write(${JSON.stringify(`${line}\n`)});`)
41
  .join("\n")}
42
${options.stderr === undefined ? "" : `process.stderr.write(${JSON.stringify(options.stderr)});`}
43
${options.hang === true ? "" : `process.exit(${options.exitCode ?? 0});`}
44
`,
45
  );
46
47
  const shim = join(directory, "codex-stub");
48
  writeFileSync(shim, `#!/bin/sh\nexec ${process.execPath} ${script} "$@"\n`);
49
  chmodSync(shim, 0o755);
50
  return shim;
51
};
52
53
const collect = async (
54
  harness: CodexHarness,
55
  cwd = process.cwd(),
56
): Promise<ReadonlyArray<DelegateEvent>> => {
57
  const events: DelegateEvent[] = [];
58
  const transcriptPath = join(mkdtempSync(join(tmpdir(), "codex-t-")), "child.jsonl");
59
  for await (const event of harness.run(
60
    { prompt: "do the thing", cwd, transcriptPath },
61
    new AbortController().signal,
62
  )) {
63
    events.push(event);
64
  }
65
  return events;
66
};
67
68
describe("parseCodexEvent", () => {
69
  it("ignores blank lines, prose, malformed JSON, and unknown types", () => {
70
    expect(parseCodexEvent("")).toBeUndefined();
71
    expect(parseCodexEvent("starting…")).toBeUndefined();
72
    expect(parseCodexEvent('{"type":"future"}')).toBeUndefined();
73
    expect(parseCodexEvent('{"type":"assistan')).toBeUndefined();
74
  });
75
76
  it("reads a thread.started as a session", () => {
77
    expect(
78
      parseCodexEvent(
79
        JSON.stringify({
80
          type: "thread.started",
81
          thread_id: "t_01",
82
          model: "o3",
83
        }),
84
      ),
85
    ).toEqual({ type: "session", sessionId: "t_01" });
86
  });
87
88
  it("falls back to id and session_id for the session", () => {
89
    expect(
90
      parseCodexEvent(JSON.stringify({ type: "thread.started", id: "t_02" })),
91
    ).toEqual({ type: "session", sessionId: "t_02" });
92
    expect(
93
      parseCodexEvent(
94
        JSON.stringify({ type: "thread.started", session_id: "t_03" }),
95
      ),
96
    ).toEqual({ type: "session", sessionId: "t_03" });
97
  });
98
99
  it("reads an assistant message as text", () => {
100
    expect(
101
      parseCodexEvent(
102
        JSON.stringify({
103
          type: "item.started",
104
          item: { role: "assistant", content: { text: "Done." } },
105
        }),
106
      ),
107
    ).toEqual({ type: "text", value: "Done." });
108
  });
109
110
  it("reads a completed assistant item output as text", () => {
111
    expect(
112
      parseCodexEvent(
113
        JSON.stringify({
114
          type: "item.completed",
115
          item: { role: "assistant", output: { text: "Done." } },
116
        }),
117
      ),
118
    ).toEqual({ type: "text", value: "Done." });
119
  });
120
121
  it("reads a function tool item", () => {
122
    const line = JSON.stringify({
123
      type: "item.started",
124
      item: {
125
        type: "function",
126
        id: "call_1",
127
        function: {
128
          name: "bash",
129
          arguments: { command: "ls -la" },
130
        },
131
      },
132
    });
133
    expect(parseCodexEvent(line)).toEqual({
134
      type: "tool",
135
      callId: "call_1",
136
      name: "bash",
137
      target: "ls -la",
138
    });
139
  });
140
141
  it("reads a file tool item", () => {
142
    const line = JSON.stringify({
143
      type: "item.completed",
144
      item: {
145
        type: "file",
146
        id: "f_1",
147
        file: "/tmp/x",
148
      },
149
    });
150
    expect(parseCodexEvent(line)).toEqual({
151
      type: "tool",
152
      callId: "f_1",
153
      name: "file",
154
      target: "/tmp/x",
155
    });
156
  });
157
158
  it("reads token usage from a turn.completed", () => {
159
    expect(
160
      parseCodexEvent(
161
        JSON.stringify({
162
          type: "turn.completed",
163
          turn_id: "turn_1",
164
          usage: { input_tokens: 150, output_tokens: 30 },
165
        }),
166
      ),
167
    ).toEqual({ type: "tokens", input: 150, output: 30 });
168
  });
169
170
  it("reads an error event", () => {
171
    expect(
172
      parseCodexEvent(
173
        JSON.stringify({
174
          type: "error",
175
          error: { message: "Rate limit" },
176
        }),
177
      ),
178
    ).toEqual({ type: "error", message: "Rate limit" });
179
  });
180
});
181
182
describe("running children on the Codex CLI", () => {
183
  it("names itself in the fleet, so a Codex child is not mistaken for the others", () => {
184
    const harness = new CodexHarness();
185
    expect(harness.agent).toBe("codex");
186
    expect(harness.model).toBe("not reported");
187
  });
188
189
  it("reports the child's answer as text", async () => {
190
    const events = await collect(
191
      new CodexHarness({
192
        command: fakeCodex({
193
          lines: [
194
            JSON.stringify({
195
              type: "thread.started",
196
              thread_id: "t_01",
197
              model: "o3",
198
            }),
199
            JSON.stringify({
200
              type: "item.started",
201
              item: { role: "assistant", content: { text: "PONG" } },
202
            }),
203
            JSON.stringify({
204
              type: "turn.completed",
205
              turn_id: "turn_1",
206
              usage: { input_tokens: 10, output_tokens: 1 },
207
            }),
208
          ],
209
        }),
210
      }),
211
    );
212
213
    expect(events.at(-2)).toEqual({ type: "text", value: "PONG" });
214
    expect(events.at(-1)).toEqual({ type: "tokens", input: 10, output: 1 });
215
  });
216
217
  it("reports what the child is doing while it does it", async () => {
218
    const events = await collect(
219
      new CodexHarness({
220
        command: fakeCodex({
221
          lines: [
222
            JSON.stringify({
223
              type: "thread.started",
224
              thread_id: "t_01",
225
            }),
226
            JSON.stringify({
227
              type: "item.started",
228
              item: {
229
                type: "function",
230
                id: "call_1",
231
                function: {
232
                  name: "bash",
233
                  arguments: { command: "ls -la" },
234
                },
235
              },
236
            }),
237
            JSON.stringify({
238
              type: "turn.completed",
239
              turn_id: "turn_1",
240
              usage: { input_tokens: 100, output_tokens: 20 },
241
            }),
242
          ],
243
        }),
244
      }),
245
    );
246
247
    expect(events).toContainEqual({
248
      type: "tool",
249
      callId: "call_1",
250
      name: "bash",
251
      target: "ls -la",
252
    });
253
    expect(events).toContainEqual({ type: "tokens", input: 100, output: 20 });
254
  });
255
256
  it("reports the session, so a retry can resume it", async () => {
257
    const events = await collect(
258
      new CodexHarness({
259
        command: fakeCodex({
260
          lines: [
261
            JSON.stringify({
262
              type: "thread.started",
263
              thread_id: "t_01",
264
            }),
265
            JSON.stringify({
266
              type: "turn.completed",
267
              turn_id: "turn_1",
268
              usage: { input_tokens: 5, output_tokens: 1 },
269
            }),
270
          ],
271
        }),
272
      }),
273
    );
274
275
    expect(events[0]).toEqual({ type: "session", sessionId: "t_01" });
276
  });
277
278
  it("writes the raw stdout and stderr to the transcript as they arrive", async () => {
279
    const transcriptPath = join(mkdtempSync(join(tmpdir(), "codex-t-")), "child.jsonl");
280
    const harness = new CodexHarness({
281
      command: fakeCodex({
282
        lines: [
283
          JSON.stringify({
284
            type: "item.started",
285
            item: { role: "assistant", content: { text: "ok" } },
286
          }),
287
        ],
288
        stderr: "noisy",
289
      }),
290
    });
291
292
    for await (const _event of harness.run(
293
      { prompt: "x", cwd: process.cwd(), transcriptPath },
294
      new AbortController().signal,
295
    )) {
296
      void _event;
297
    }
298
299
    const written = readFileSync(transcriptPath, "utf8");
300
    expect(written).toContain("ok");
301
    expect(written).toContain("noisy");
302
  });
303
304
  it("uses exec, json, the prompt, and the workspace-write sandbox by default", async () => {
305
    const log = join(mkdtempSync(join(tmpdir(), "codex-args-")), "log");
306
    await collect(
307
      new CodexHarness({
308
        command: fakeCodex({
309
          recordArgsTo: log,
310
          exitCode: 0,
311
          lines: [
312
            JSON.stringify({
313
              type: "turn.completed",
314
              turn_id: "turn_1",
315
              usage: { input_tokens: 1, output_tokens: 1 },
316
            }),
317
          ],
318
        }),
319
      }),
320
    );
321
322
    const said = readFileSync(log, "utf8");
323
    expect(said).toContain("exec");
324
    expect(said).toContain("--json");
325
    expect(said).toContain("do the thing");
326
    expect(said).toContain("--sandbox workspace-write");
327
  });
328
329
  it("passes the model through when the lane names one", async () => {
330
    const log = join(mkdtempSync(join(tmpdir(), "codex-args-")), "log");
331
    const harness = new CodexHarness({
332
      command: fakeCodex({
333
        recordArgsTo: log,
334
        exitCode: 0,
335
        lines: [
336
          JSON.stringify({
337
            type: "turn.completed",
338
            turn_id: "turn_1",
339
            usage: { input_tokens: 1, output_tokens: 1 },
340
          }),
341
        ],
342
      }),
343
      model: "o3",
344
    });
345
346
    await collect(harness);
347
348
    expect(harness.model).toBe("o3");
349
    expect(readFileSync(log, "utf8")).toContain("-m o3");
350
  });
351
352
  it("passes the cwd through as the child's working directory", async () => {
353
    const cwdLog = join(mkdtempSync(join(tmpdir(), "codex-cwd-")), "log");
354
    const otherCwd = mkdtempSync(join(tmpdir(), "codex-cwd-"));
355
    await collect(
356
      new CodexHarness({
357
        command: fakeCodex({
358
          recordCwdTo: cwdLog,
359
          exitCode: 0,
360
          lines: [
361
            JSON.stringify({
362
              type: "turn.completed",
363
              turn_id: "turn_1",
364
              usage: { input_tokens: 1, output_tokens: 1 },
365
            }),
366
          ],
367
        }),
368
      }),
369
      otherCwd,
370
    );
371
372
    expect(readFileSync(cwdLog, "utf8").trim()).toBe(realpathSync(otherCwd));
373
  });
374
375
  it("resumes with exec resume and the session id", async () => {
376
    const log = join(mkdtempSync(join(tmpdir(), "codex-resume-")), "log");
377
    const harness = new CodexHarness({
378
      command: fakeCodex({
379
        recordArgsTo: log,
380
        exitCode: 0,
381
        lines: [
382
          JSON.stringify({
383
            type: "turn.completed",
384
            turn_id: "turn_1",
385
            usage: { input_tokens: 1, output_tokens: 1 },
386
          }),
387
        ],
388
      }),
389
    });
390
391
    const events: DelegateEvent[] = [];
392
    const transcriptPath = join(mkdtempSync(join(tmpdir(), "codex-t-")), "child.jsonl");
393
    for await (const event of harness.run(
394
      {
395
        prompt: "do the thing",
396
        cwd: process.cwd(),
397
        transcriptPath,
398
        resumeSessionId: "t_resume",
399
      },
400
      new AbortController().signal,
401
    )) {
402
      events.push(event);
403
    }
404
405
    expect(events.length).toBeGreaterThan(0);
406
    const said = readFileSync(log, "utf8");
407
    expect(said).toContain("exec resume");
408
    expect(said).toContain("t_resume");
409
    expect(said).toContain("do the thing");
410
  });
411
412
  it("reports a Codex result error as a failed child", async () => {
413
    const harness = new CodexHarness({
414
      command: fakeCodex({
415
        lines: [
416
          JSON.stringify({
417
            type: "error",
418
            error: { message: "the model provider is unavailable" },
419
          }),
420
        ],
421
        exitCode: 1,
422
      }),
423
    });
424
425
    await expect(collect(harness)).rejects.toThrow(/model provider is unavailable/);
426
  });
427
428
  it("throws when the binary is not on PATH, rather than reporting an empty child", async () => {
429
    await expect(
430
      collect(new CodexHarness({ command: "codex-does-not-exist" })),
431
    ).rejects.toThrow("not on the path");
432
  });
433
434
  it("stops when the fleet is stopped", async () => {
435
    const harness = new CodexHarness({
436
      command: fakeCodex({
437
        hang: true,
438
        lines: [
439
          JSON.stringify({
440
            type: "thread.started",
441
            thread_id: "t_01",
442
          }),
443
        ],
444
      }),
445
    });
446
    const controller = new AbortController();
447
    const events: DelegateEvent[] = [];
448
    const transcriptPath = join(mkdtempSync(join(tmpdir(), "codex-t-")), "child.jsonl");
449
450
    const running = (async () => {
451
      for await (const event of harness.run(
452
        { prompt: "x", cwd: process.cwd(), transcriptPath },
453
        controller.signal,
454
      )) {
455
        events.push(event);
456
      }
457
    })();
458
459
    controller.abort();
460
    await running;
461
462
    expect(events.every((event) => event.type !== "text")).toBe(true);
463
  });
464
});

This page updates live while a promote is in flight · changelog