baa-conductor

git clone 

commit
d574288
parent
80c6202
author
im_wower
date
2026-03-23 22:05:08 +0800 CST
Add codexd e2e smoke coverage
9 files changed,  +681, -30
M docs/api/README.md
+17, -3
 1@@ -79,7 +79,7 @@
 2 
 3 ### Codex 代理接口
 4 
 5-这些路由不是 `conductor-daemon` 内嵌 bridge,而是固定代理到独立 `codexd`:
 6+这些路由固定代理到独立 `codexd`:
 7 
 8 | 方法 | 路径 | 说明 |
 9 | --- | --- | --- |
10@@ -91,8 +91,8 @@
11 
12 当前正式口径只保留 session / turn / status:
13 
14-- `conductor-daemon` 不对外代理 `/v1/codex/runs*`
15-- 不把 `codex exec` 当作正式产品能力
16+- turn 的回读统一通过 `GET /v1/codex/sessions/:session_id` 里的 `lastTurn*` 和 `recentEvents`
17+- 正式业务面不包含 run/exec 路线
18 
19 ### 本机 Host Ops 接口
20 
21@@ -204,6 +204,20 @@ curl -X POST "${LOCAL_API_BASE}/v1/codex/sessions" \
22   -d '{"cwd":"/Users/george/code/baa-conductor","purpose":"duplex"}'
23 ```
24 
25+```bash
26+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
27+SESSION_ID="session-example"
28+curl "${LOCAL_API_BASE}/v1/codex/sessions/${SESSION_ID}"
29+```
30+
31+```bash
32+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
33+SESSION_ID="session-example"
34+curl -X POST "${LOCAL_API_BASE}/v1/codex/turn" \
35+  -H 'Content-Type: application/json' \
36+  -d "{\"sessionId\":\"${SESSION_ID}\",\"input\":\"Summarize pending work.\"}"
37+```
38+
39 ```bash
40 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
41 curl -X POST "${LOCAL_API_BASE}/v1/system/pause" \
M docs/api/business-interfaces.md
+16, -1
 1@@ -72,7 +72,8 @@
 2 
 3 - 这些 `/v1/codex/*` 路由固定代理到独立 `codexd`
 4 - `conductor-daemon` 不自己持有 Codex session 真相
 5-- 正式能力只保留 session / turn / status,不对外暴露 `/v1/codex/runs*`
 6+- turn 的回读统一通过 `GET /v1/codex/sessions/:session_id` 里的 `lastTurn*` 和 `recentEvents`
 7+- 正式能力只保留 session / turn / status
 8 
 9 ## 当前不在业务面讨论的写接口
10 
11@@ -120,6 +121,20 @@ curl -X POST "${BASE_URL}/v1/codex/sessions" \
12   -d '{"cwd":"/Users/george/code/baa-conductor","purpose":"duplex"}'
13 ```
14 
15+```bash
16+BASE_URL="http://100.71.210.78:4317"
17+SESSION_ID="session-example"
18+curl "${BASE_URL}/v1/codex/sessions/${SESSION_ID}"
19+```
20+
21+```bash
22+BASE_URL="http://100.71.210.78:4317"
23+SESSION_ID="session-example"
24+curl -X POST "${BASE_URL}/v1/codex/turn" \
25+  -H 'Content-Type: application/json' \
26+  -d "{\"sessionId\":\"${SESSION_ID}\",\"input\":\"Summarize pending work.\"}"
27+```
28+
29 ```bash
30 BASE_URL="http://100.71.210.78:4317"
31 TASK_ID="example-task-id"
M docs/runtime/README.md
+5, -1
 1@@ -38,7 +38,7 @@
 2 - `POST /v1/codexd/turn`
 3 - `WS /v1/codexd/events`
 4 
 5-不把 `/v1/codexd/runs*` 或 `codex exec` 写进当前 runtime / launchd / 运维口径。
 6+当前 runtime / launchd / 运维只验 `status / sessions / turn / events` 这条 `app-server` 会话链路。
 7 
 8 ## 最短路径
 9 
10@@ -46,6 +46,7 @@
11 2. `./scripts/runtime/status-launchd.sh`
12 3. `./scripts/runtime/restart-launchd.sh`
13 4. `./scripts/runtime/check-node.sh --node mini`
14+5. `./scripts/runtime/codexd-e2e-smoke.sh`
15 
16 ## 当前推荐入口
17 
18@@ -62,6 +63,9 @@
19   - 需要单独管理时,用 `--service codexd`
20 - 节点检查:
21   - `./scripts/runtime/check-node.sh --node mini`
22+- 会话链路 smoke:
23+  - `./scripts/runtime/codexd-e2e-smoke.sh`
24+  - 会起临时 `codexd` + `conductor`,覆盖 `codexd status`、`GET /v1/codex`、session create/read、turn create/read,以及 `logs/codexd/**`、`state/codexd/**` 落盘
25 
26 职责边界:
27 
M docs/runtime/codexd.md
+16, -14
 1@@ -2,14 +2,14 @@
 2 
 3 `codexd` 现在是 `mini` 上的正式 Codex 运行时服务。
 4 
 5-它不是 `conductor-daemon` 的内嵌 bridge,也不是手工调试时顺便开的附属进程;它是由 `launchd` 托管的独立常驻进程,用来承接 Codex child、会话、日志、恢复和本地服务面。
 6+它是由 `launchd` 托管的独立常驻进程,用来承接 Codex child、会话、日志、恢复和本地服务面。
 7 
 8 ## 当前正式运行面
 9 
10 - launchd label: `so.makefile.baa-codexd`
11 - 本地监听地址:`http://127.0.0.1:4319`
12 - 本地事件流:`ws://127.0.0.1:4319/v1/codexd/events`
13-- 正式模式:`app-server`
14+- 会话链路:`app-server`
15 - child 策略:`spawn`
16 - child 命令:`codex app-server`
17 - 日志目录:`logs/codexd/`
18@@ -69,9 +69,7 @@ BAA_CODEXD_STATE_DIR=/Users/george/code/baa-conductor/state/codexd
19 
20 正式口径只保留 `status / sessions / turn / events`。
21 
22-- `runs` 不属于当前正式 runtime / launchd / 运维运行面
23-- `codex exec` 也不作为正式无交互模式进入这一层
24-- 如果仓库里仍保留 run registry 或一次性执行相关内部代码,应视为非正式内部遗留,不属于当前对外能力描述
25+`codexd` 的正式对外描述不包含 run/exec 路线。
26 
27 当前骨架已经会维护:
28 
29@@ -93,19 +91,23 @@ BAA_CODEXD_STATE_DIR=/Users/george/code/baa-conductor/state/codexd
30 
31 下面这些不属于正式 launchd 运行面:
32 
33-- `/v1/codexd/runs*` 正式接口
34-- `codex exec` 正式模式
35+- `status / sessions / turn / events` 之外的额外运行面
36 - TUI 常驻模式
37 - 两个进程互相直接拉起
38 - 把 `codexd` 描述成“可选以后再说”的附属能力
39 
40-`codex exec` 可以继续存在于测试、临时工具或过渡代码里,但不进入 `mini` 正式 runtime 配置、launchd 模板、安装脚本或运维验收。
41+## 当前验收链路
42 
43-## 当前仍未补齐的部分
44+这条运行面当前至少要通过下面这组 smoke:
45 
46-当前 runtime / launchd / 检查口径已经正式纳入 `codexd`,但以下能力仍在后续演进范围:
47+- `codexd status`
48+- `GET /v1/codex`
49+- session create/read
50+- turn create,然后通过 session read 回读 `lastTurn*` 和 `recentEvents`
51+- `logs/codexd/**` 与 `state/codexd/**` 正常落盘
52 
53-- 更完整的 `conductor-daemon -> codexd` 代理接线
54-- 更丰富的会话恢复和断线重放
55-- 更强的 steering / interrupt / replay 语义
56-- 更细的 worker 与对话统一日志索引
57+仓库内对应命令:
58+
59+```bash
60+./scripts/runtime/codexd-e2e-smoke.sh
61+```
M docs/runtime/environment.md
+1, -2
 1@@ -64,8 +64,7 @@ BAA_CODEXD_STATE_DIR=/Users/george/code/baa-conductor/state/codexd
 2 
 3 - 正式运行面只支持 `app-server`
 4 - 正式 API 只保留 `/healthz`、`/v1/codexd/status`、`/v1/codexd/sessions`、`/v1/codexd/turn`、`/v1/codexd/events`
 5-- 不为正式运行面暴露 `/v1/codexd/runs*` 相关变量或开关
 6-- 不为 launchd 运行面增加 `codex exec` 正式开关
 7+- 正式运行面只保留这条 `app-server` 会话链路所需变量
 8 - `BAA_CODEXD_LOCAL_API_BASE` 必须保持 loopback host;当前默认是 `127.0.0.1:4319`
 9 
10 ## 节点变量
M docs/runtime/launchd.md
+3, -3
 1@@ -38,9 +38,9 @@
 2 
 3 - `codexd` 独立安装时不需要共享 token
 4 - `--control-api-base` 仍然保留,只是为了写入兼容变量 `BAA_CONTROL_API_BASE`
 5-- `codexd` 正式运行面只写入 `app-server` 相关默认值,不暴露 `codex exec` 正式开关
 6+- `codexd` 正式运行面只写入 `app-server` 会话链路所需默认值
 7 - `codexd` 正式服务面只保留 `/healthz`、`/v1/codexd/status`、`/v1/codexd/sessions`、`/v1/codexd/turn`、`/v1/codexd/events`
 8-- install / reload / status / check 脚本都不把 `/v1/codexd/runs*` 作为正式探针或验收项
 9+- install / reload / status / check 脚本都只验这条会话链路
10 
11 ## 日常管理
12 
13@@ -81,7 +81,7 @@
14 - `status-launchd.sh` 对 `codexd` 只展示 `/healthz` 和 `/v1/codexd/status`
15 - `reload-launchd.sh` 只等待 `codexd /healthz` 恢复
16 - `check-node.sh` 只要求 `codexd /healthz` 和 `/v1/codexd/status` 返回正常
17-- 不要求、也不建议把 `/v1/codexd/runs*` 当成 launchd 运行面验收
18+- 额外会话验收统一走 `./scripts/runtime/codexd-e2e-smoke.sh`
19 
20 ## 渲染安装副本
21 
M docs/runtime/node-verification.md
+21, -6
 1@@ -6,7 +6,7 @@
 2 - `codexd` `http://127.0.0.1:4319`
 3 - `status-api` `http://100.71.210.78:4318`
 4 
 5-其中 `codexd` 的正式产品面仍是 `status / sessions / turn / events`,但 on-node 运维探针只要求 `/healthz` 和 `/v1/codexd/status`;不把 `/v1/codexd/runs*` 当成必验项。
 6+其中 `codexd` 的正式产品面仍是 `status / sessions / turn / events`,但 on-node 运维探针只要求 `/healthz` 和 `/v1/codexd/status`。会话级端到端链路单独由仓库 smoke 覆盖。
 7 
 8 ## 1. 构建与静态检查
 9 
10@@ -31,7 +31,7 @@ npx --yes pnpm -r build
11 
12 - `--control-api-base` 仍是当前静态检查参数,但只用于校验兼容变量 `BAA_CONTROL_API_BASE`
13 - `check-launchd.sh` 现在会校验 `codexd` 的监听地址、事件流路径、日志目录、状态目录和 `app-server` child 配置
14-- 这些静态检查不要求 `/v1/codexd/runs*`
15+- 这些静态检查不包含 run/exec 路线
16 
17 ## 2. 运行态检查
18 
19@@ -60,9 +60,23 @@ npx --yes pnpm -r build
20 - `conductor` 是否监听 `4317` 并返回 `/healthz`、`/readyz`、`/rolez`
21 - `codexd` 是否监听 `4319` 并返回 `/healthz`、`/v1/codexd/status`
22 - `status-api` 是否监听 `4318` 并返回 `/healthz`、`/v1/status`
23-- 不要求探测 `/v1/codexd/runs*`
24+- 不要求探测 run/exec 路线
25 
26-## 3. 手工探针
27+## 3. 会话链路 smoke
28+
29+```bash
30+./scripts/runtime/codexd-e2e-smoke.sh
31+```
32+
33+这条 smoke 会起一组临时 `codexd` + `conductor` 进程,并验证:
34+
35+- `codexd status`
36+- `GET /v1/codex`
37+- session create/read
38+- turn create/read
39+- `logs/codexd/**` 和 `state/codexd/**` 落盘
40+
41+## 4. 手工探针
42 
43 主路径:
44 
45@@ -83,13 +97,14 @@ curl -fsSL http://127.0.0.1:4319/v1/codexd/status
46 curl -fsSL http://100.71.210.78:4318/v1/status
47 ```
48 
49-不需要再额外探测 `http://127.0.0.1:4319/v1/codexd/runs*`。
50+会话链路回归不要直接手工拼 `run/exec` 探针,统一跑 `./scripts/runtime/codexd-e2e-smoke.sh`。
51 
52-## 4. 常见失败点
53+## 5. 常见失败点
54 
55 - `conductor /rolez` 不是 `leader`
56 - `codexd` 没有监听 `127.0.0.1:4319`
57 - `codexd /v1/codexd/status` 没有返回 `app-server` 运行信息
58+- `conductor /v1/codex` 没有正确代理到 `codexd`
59 - `conductor.makefile.so` 没有正确回源到 `100.71.210.78:4317`
60 - `launchctl print` 失败
61 - `logs/launchd/*.log` 没有新内容
A scripts/runtime/codexd-e2e-smoke.sh
+10, -0
 1@@ -0,0 +1,10 @@
 2+#!/usr/bin/env bash
 3+set -euo pipefail
 4+
 5+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
 6+REPO_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
 7+
 8+cd "${REPO_DIR}"
 9+
10+npx --yes pnpm -r build
11+node --test tests/codexd/codexd-e2e-smoke.test.mjs
A tests/codexd/codexd-e2e-smoke.test.mjs
+592, -0
  1@@ -0,0 +1,592 @@
  2+import assert from "node:assert/strict";
  3+import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
  4+import { tmpdir } from "node:os";
  5+import { join } from "node:path";
  6+import test from "node:test";
  7+
  8+import {
  9+  CodexdLocalService,
 10+  resolveCodexdConfig,
 11+  runCodexdCli
 12+} from "../../apps/codexd/dist/index.js";
 13+import { ConductorRuntime } from "../../apps/conductor-daemon/dist/index.js";
 14+
 15+class FakeEventStream {
 16+  constructor() {
 17+    this.listeners = new Set();
 18+  }
 19+
 20+  emit(event) {
 21+    for (const listener of this.listeners) {
 22+      listener(event);
 23+    }
 24+  }
 25+
 26+  subscribe(listener) {
 27+    this.listeners.add(listener);
 28+
 29+    return {
 30+      unsubscribe: () => {
 31+        this.listeners.delete(listener);
 32+      }
 33+    };
 34+  }
 35+}
 36+
 37+class FakeStream {
 38+  constructor() {
 39+    this.listeners = [];
 40+  }
 41+
 42+  on(event, listener) {
 43+    if (event === "data") {
 44+      this.listeners.push(listener);
 45+    }
 46+
 47+    return this;
 48+  }
 49+
 50+  emit(chunk) {
 51+    for (const listener of this.listeners) {
 52+      listener(chunk);
 53+    }
 54+  }
 55+}
 56+
 57+class FakeChild {
 58+  constructor() {
 59+    this.pid = 43210;
 60+    this.stdin = {
 61+      end() {},
 62+      write() {
 63+        return true;
 64+      }
 65+    };
 66+    this.stdout = new FakeStream();
 67+    this.stderr = new FakeStream();
 68+    this.listeners = new Map();
 69+    this.onceListeners = new Map();
 70+  }
 71+
 72+  on(event, listener) {
 73+    this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
 74+    return this;
 75+  }
 76+
 77+  once(event, listener) {
 78+    this.onceListeners.set(event, [...(this.onceListeners.get(event) ?? []), listener]);
 79+    return this;
 80+  }
 81+
 82+  kill(signal = "SIGTERM") {
 83+    this.emit("exit", 0, signal);
 84+    return true;
 85+  }
 86+
 87+  emit(event, ...args) {
 88+    for (const listener of this.listeners.get(event) ?? []) {
 89+      listener(...args);
 90+    }
 91+
 92+    for (const listener of this.onceListeners.get(event) ?? []) {
 93+      listener(...args);
 94+    }
 95+
 96+    this.onceListeners.delete(event);
 97+  }
 98+}
 99+
100+class FakeAppServerAdapter {
101+  constructor(defaultCwd) {
102+    this.defaultCwd = defaultCwd;
103+    this.events = new FakeEventStream();
104+    this.nextThreadId = 1;
105+    this.nextTurnId = 1;
106+    this.sessions = new Map();
107+  }
108+
109+  async close() {}
110+
111+  async initialize() {
112+    return {
113+      platformFamily: "unix",
114+      platformOs: "macos",
115+      userAgent: "codex-cli fake-app-server"
116+    };
117+  }
118+
119+  async threadResume(params) {
120+    const session = this.sessions.get(params.threadId);
121+
122+    if (session == null) {
123+      throw new Error(`unknown thread ${params.threadId}`);
124+    }
125+
126+    return session;
127+  }
128+
129+  async threadStart(params = {}) {
130+    const threadId = `thread-${this.nextThreadId}`;
131+    this.nextThreadId += 1;
132+    const session = {
133+      thread: {
134+        cliVersion: "test",
135+        createdAt: Date.now(),
136+        cwd: params.cwd ?? this.defaultCwd,
137+        ephemeral: params.ephemeral ?? true,
138+        id: threadId,
139+        modelProvider: params.modelProvider ?? "openai",
140+        name: null,
141+        preview: "fake codexd smoke session",
142+        source: {
143+          custom: "codexd-e2e-smoke"
144+        },
145+        status: {
146+          type: "idle"
147+        },
148+        turns: [],
149+        updatedAt: Date.now()
150+      },
151+      approvalPolicy: params.approvalPolicy ?? "never",
152+      cwd: params.cwd ?? this.defaultCwd,
153+      model: params.model ?? "gpt-5.4",
154+      modelProvider: params.modelProvider ?? "openai",
155+      reasoningEffort: "medium",
156+      sandbox: {
157+        type: "dangerFullAccess"
158+      },
159+      serviceTier: params.serviceTier ?? null
160+    };
161+
162+    this.sessions.set(threadId, session);
163+    this.events.emit({
164+      notificationMethod: "thread/started",
165+      thread: session.thread,
166+      type: "thread.started"
167+    });
168+    return session;
169+  }
170+
171+  async turnInterrupt() {}
172+
173+  async turnStart(params) {
174+    const session = this.sessions.get(params.threadId);
175+
176+    if (session == null) {
177+      throw new Error(`unknown thread ${params.threadId}`);
178+    }
179+
180+    const turnId = `turn-${this.nextTurnId}`;
181+    this.nextTurnId += 1;
182+    const turn = {
183+      error: null,
184+      id: turnId,
185+      status: "inProgress"
186+    };
187+
188+    session.thread.turns = [...(session.thread.turns ?? []), turn];
189+
190+    queueMicrotask(() => {
191+      this.events.emit({
192+        notificationMethod: "turn/started",
193+        threadId: params.threadId,
194+        turn,
195+        type: "turn.started"
196+      });
197+      this.events.emit({
198+        delta: "hello from fake adapter",
199+        itemId: "item-1",
200+        notificationMethod: "item/agentMessage/delta",
201+        threadId: params.threadId,
202+        turnId,
203+        type: "turn.message.delta"
204+      });
205+
206+      const completedTurn = {
207+        ...turn,
208+        status: "completed"
209+      };
210+
211+      session.thread.turns = session.thread.turns.map((entry) =>
212+        entry.id === completedTurn.id ? completedTurn : entry
213+      );
214+      this.events.emit({
215+        notificationMethod: "turn/completed",
216+        threadId: params.threadId,
217+        turn: completedTurn,
218+        type: "turn.completed"
219+      });
220+    });
221+
222+    return {
223+      turn
224+    };
225+  }
226+
227+  async turnSteer(params) {
228+    return {
229+      turnId: params.expectedTurnId
230+    };
231+  }
232+}
233+
234+function createTextCollector() {
235+  let text = "";
236+
237+  return {
238+    read() {
239+      return text;
240+    },
241+    writer: {
242+      write(chunk) {
243+        text += String(chunk);
244+      }
245+    }
246+  };
247+}
248+
249+async function fetchJson(url, init) {
250+  const response = await fetch(url, init);
251+  const text = await response.text();
252+
253+  return {
254+    payload: text === "" ? null : JSON.parse(text),
255+    response,
256+    text
257+  };
258+}
259+
260+function readJsonFile(path) {
261+  return JSON.parse(readFileSync(path, "utf8"));
262+}
263+
264+async function sleep(ms) {
265+  await new Promise((resolve) => {
266+    setTimeout(resolve, ms);
267+  });
268+}
269+
270+async function waitForValue(readValue, description, timeoutMs = 5_000) {
271+  const deadline = Date.now() + timeoutMs;
272+  let lastError = null;
273+
274+  while (Date.now() < deadline) {
275+    try {
276+      const value = await readValue();
277+
278+      if (value != null) {
279+        return value;
280+      }
281+    } catch (error) {
282+      lastError = error;
283+    }
284+
285+    await sleep(25);
286+  }
287+
288+  if (lastError instanceof Error) {
289+    throw lastError;
290+  }
291+
292+  throw new Error(`Timed out waiting for ${description}.`);
293+}
294+
295+async function readCodexdCliStatus(config, env) {
296+  const stdout = createTextCollector();
297+  const stderr = createTextCollector();
298+  const argv = [
299+    "node",
300+    "codexd",
301+    "status",
302+    "--json",
303+    "--repo-root",
304+    config.paths.repoRoot,
305+    "--logs-dir",
306+    config.paths.logsRootDir,
307+    "--state-dir",
308+    config.paths.stateRootDir,
309+    "--local-api-base",
310+    config.service.localApiBase,
311+    "--event-stream-path",
312+    config.service.eventStreamPath,
313+    "--server-endpoint",
314+    config.server.endpoint,
315+    "--server-strategy",
316+    config.server.childStrategy,
317+    "--server-command",
318+    config.server.childCommand,
319+    "--server-cwd",
320+    config.server.childCwd
321+  ];
322+
323+  for (const arg of config.server.childArgs) {
324+    argv.push("--server-arg", arg);
325+  }
326+
327+  const exitCode = await runCodexdCli({
328+    argv,
329+    env,
330+    processLike: {
331+      argv,
332+      env,
333+      pid: 424242
334+    },
335+    stderr: stderr.writer,
336+    stdout: stdout.writer
337+  });
338+
339+  assert.equal(exitCode, 0, stderr.read());
340+  return JSON.parse(stdout.read());
341+}
342+
343+test("codexd e2e smoke covers status, conductor proxy, session and turn flow, and persistence", async () => {
344+  const workspace = mkdtempSync(join(tmpdir(), "baa-codexd-e2e-smoke-"));
345+  const repoRoot = join(workspace, "repo");
346+  const logsRoot = join(workspace, "logs");
347+  const stateRoot = join(workspace, "state");
348+  const conductorStateDir = join(workspace, "conductor-state");
349+  const runsDir = join(workspace, "runs");
350+  mkdirSync(repoRoot, {
351+    recursive: true
352+  });
353+  mkdirSync(logsRoot, {
354+    recursive: true
355+  });
356+  mkdirSync(stateRoot, {
357+    recursive: true
358+  });
359+  mkdirSync(conductorStateDir, {
360+    recursive: true
361+  });
362+  mkdirSync(runsDir, {
363+    recursive: true
364+  });
365+
366+  const fakeChild = new FakeChild();
367+  const fakeAdapter = new FakeAppServerAdapter(repoRoot);
368+  const codexdConfig = resolveCodexdConfig({
369+    localApiBase: "http://127.0.0.1:0",
370+    logsDir: logsRoot,
371+    repoRoot,
372+    stateDir: stateRoot,
373+    version: "e2e-smoke"
374+  });
375+  const codexd = new CodexdLocalService(codexdConfig, {
376+    appServerClientFactory: {
377+      async create(context) {
378+        assert.equal(context.config.server.mode, "app-server");
379+        return fakeAdapter;
380+      }
381+    },
382+    env: {
383+      HOME: workspace
384+    },
385+    spawner: {
386+      spawn(command, args, options) {
387+        assert.equal(command, "codex");
388+        assert.deepEqual(args, ["app-server"]);
389+        assert.equal(options.cwd, repoRoot);
390+
391+        queueMicrotask(() => {
392+          fakeChild.emit("spawn");
393+          fakeChild.stdout.emit("codexd fake child ready\n");
394+          fakeChild.stderr.emit("codexd fake child stderr\n");
395+        });
396+
397+        return fakeChild;
398+      }
399+    }
400+  });
401+  let conductor = null;
402+  let codexdStarted = false;
403+  let conductorStarted = false;
404+
405+  try {
406+    const codexdStatus = await codexd.start();
407+    codexdStarted = true;
408+    const codexdBaseUrl = codexdStatus.service.resolvedBaseUrl;
409+
410+    assert.ok(codexdBaseUrl);
411+    assert.equal(codexdStatus.snapshot.daemon.started, true);
412+    assert.equal(codexdStatus.snapshot.daemon.child.status, "running");
413+
414+    conductor = new ConductorRuntime(
415+      {
416+        codexdLocalApiBase: codexdBaseUrl,
417+        controlApiBase: "https://conductor.makefile.so",
418+        host: "mini",
419+        localApiBase: "http://127.0.0.1:0",
420+        nodeId: "mini-main",
421+        paths: {
422+          runsDir,
423+          stateDir: conductorStateDir
424+        },
425+        role: "primary",
426+        sharedToken: "replace-me"
427+      },
428+      {
429+        autoStartLoops: false,
430+        now: () => 100
431+      }
432+    );
433+
434+    const conductorSnapshot = await conductor.start();
435+    conductorStarted = true;
436+    const conductorBaseUrl = conductorSnapshot.controlApi.localApiBase;
437+
438+    assert.ok(conductorBaseUrl);
439+
440+    const directCodexdStatus = await fetchJson(`${codexdBaseUrl}/v1/codexd/status`);
441+    assert.equal(directCodexdStatus.response.status, 200);
442+    assert.equal(directCodexdStatus.payload.ok, true);
443+    assert.equal(directCodexdStatus.payload.data.service.resolvedBaseUrl, codexdBaseUrl);
444+    assert.equal(directCodexdStatus.payload.data.snapshot.daemon.child.status, "running");
445+
446+    const initialProxyStatus = await fetchJson(`${conductorBaseUrl}/v1/codex`);
447+    assert.equal(initialProxyStatus.response.status, 200);
448+    assert.equal(initialProxyStatus.payload.ok, true);
449+    assert.equal(initialProxyStatus.payload.data.backend, "independent_codexd");
450+    assert.equal(initialProxyStatus.payload.data.proxy.target_base_url, codexdBaseUrl);
451+    assert.equal(initialProxyStatus.payload.data.sessions.count, 0);
452+    assert.doesNotMatch(JSON.stringify(initialProxyStatus.payload.data.routes), /\/v1\/codex\/runs/u);
453+
454+    const sessionCreateResponse = await fetchJson(`${conductorBaseUrl}/v1/codex/sessions`, {
455+      body: JSON.stringify({
456+        cwd: repoRoot,
457+        model: "gpt-5.4",
458+        purpose: "duplex"
459+      }),
460+      headers: {
461+        "content-type": "application/json"
462+      },
463+      method: "POST"
464+    });
465+    assert.equal(sessionCreateResponse.response.status, 201);
466+    assert.equal(sessionCreateResponse.payload.ok, true);
467+    const session = sessionCreateResponse.payload.data.session;
468+    const sessionId = session.sessionId;
469+
470+    assert.match(sessionId, /^session-/u);
471+    assert.equal(session.purpose, "duplex");
472+    assert.equal(session.status, "active");
473+    assert.equal(session.cwd, repoRoot);
474+
475+    const sessionsListResponse = await fetchJson(`${conductorBaseUrl}/v1/codex/sessions`);
476+    assert.equal(sessionsListResponse.response.status, 200);
477+    assert.equal(sessionsListResponse.payload.data.sessions.length, 1);
478+    assert.equal(sessionsListResponse.payload.data.sessions[0].sessionId, sessionId);
479+
480+    const sessionReadResponse = await fetchJson(`${conductorBaseUrl}/v1/codex/sessions/${sessionId}`);
481+    assert.equal(sessionReadResponse.response.status, 200);
482+    assert.equal(sessionReadResponse.payload.ok, true);
483+    assert.equal(sessionReadResponse.payload.data.session.sessionId, sessionId);
484+    assert.equal(sessionReadResponse.payload.data.session.lastTurnId, null);
485+
486+    const turnCreateResponse = await fetchJson(`${conductorBaseUrl}/v1/codex/turn`, {
487+      body: JSON.stringify({
488+        input: "Summarize pending work.",
489+        sessionId
490+      }),
491+      headers: {
492+        "content-type": "application/json"
493+      },
494+      method: "POST"
495+    });
496+    assert.equal(turnCreateResponse.response.status, 202);
497+    assert.equal(turnCreateResponse.payload.ok, true);
498+    assert.equal(turnCreateResponse.payload.data.accepted, true);
499+    const turnId = turnCreateResponse.payload.data.turnId;
500+
501+    assert.equal(turnId, "turn-1");
502+
503+    const completedSessionRead = await waitForValue(async () => {
504+      const response = await fetchJson(`${conductorBaseUrl}/v1/codex/sessions/${sessionId}`);
505+
506+      if (response.response.status !== 200) {
507+        throw new Error(`Unexpected session read status ${response.response.status}.`);
508+      }
509+
510+      const sessionPayload = response.payload.data.session;
511+      const eventTypes = response.payload.data.recentEvents.map((event) => event.type);
512+
513+      if (
514+        sessionPayload.lastTurnId === turnId
515+        && sessionPayload.lastTurnStatus === "completed"
516+        && eventTypes.includes("turn.accepted")
517+        && eventTypes.includes("app-server.turn.completed")
518+      ) {
519+        return response.payload;
520+      }
521+
522+      return null;
523+    }, "turn completion in conductor session read");
524+
525+    const proxyStatusAfterTurn = await fetchJson(`${conductorBaseUrl}/v1/codex`);
526+    assert.equal(proxyStatusAfterTurn.response.status, 200);
527+    assert.equal(proxyStatusAfterTurn.payload.data.sessions.count, 1);
528+    assert.equal(proxyStatusAfterTurn.payload.data.sessions.active_count, 1);
529+    assert.ok(proxyStatusAfterTurn.payload.data.recent_events.count >= 1);
530+
531+    const cliStatus = await readCodexdCliStatus(codexdConfig, {
532+      BAA_NODE_ID: codexdConfig.nodeId,
533+      HOME: workspace
534+    });
535+    assert.equal(cliStatus.daemon.started, true);
536+    assert.equal(cliStatus.daemon.child.status, "running");
537+    assert.equal(cliStatus.sessionRegistry.sessions.length, 1);
538+    assert.equal(cliStatus.sessionRegistry.sessions[0].sessionId, sessionId);
539+    assert.equal(cliStatus.sessionRegistry.sessions[0].lastTurnId, turnId);
540+    assert.equal(cliStatus.sessionRegistry.sessions[0].lastTurnStatus, "completed");
541+    assert.equal(cliStatus.runRegistry.runs.length, 0);
542+
543+    const daemonState = readJsonFile(codexdConfig.paths.daemonStatePath);
544+    assert.equal(daemonState.started, true);
545+    assert.equal(daemonState.child.status, "running");
546+    assert.equal(daemonState.child.pid, 43210);
547+
548+    const sessionRegistry = readJsonFile(codexdConfig.paths.sessionRegistryPath);
549+    assert.equal(sessionRegistry.sessions.length, 1);
550+    assert.equal(sessionRegistry.sessions[0].sessionId, sessionId);
551+    assert.equal(sessionRegistry.sessions[0].lastTurnId, turnId);
552+    assert.equal(sessionRegistry.sessions[0].lastTurnStatus, "completed");
553+
554+    const recentEvents = readJsonFile(codexdConfig.paths.recentEventsPath);
555+    assert.ok(recentEvents.events.some((event) => event.type === "session.created"));
556+    assert.ok(recentEvents.events.some((event) => event.type === "turn.accepted"));
557+    assert.ok(recentEvents.events.some((event) => event.type === "app-server.turn.completed"));
558+
559+    const identity = readJsonFile(codexdConfig.paths.identityPath);
560+    assert.equal(identity.nodeId, "mini-main");
561+    assert.equal(identity.repoRoot, repoRoot);
562+
563+    assert.match(
564+      readFileSync(codexdConfig.paths.structuredEventLogPath, "utf8"),
565+      /app-server\.turn\.completed/u
566+    );
567+    assert.match(
568+      readFileSync(codexdConfig.paths.stdoutLogPath, "utf8"),
569+      /codexd fake child ready/u
570+    );
571+    assert.match(
572+      readFileSync(codexdConfig.paths.stderrLogPath, "utf8"),
573+      /codexd fake child stderr/u
574+    );
575+
576+    assert.equal(completedSessionRead.data.session.sessionId, sessionId);
577+    assert.equal(completedSessionRead.data.session.lastTurnId, turnId);
578+    assert.equal(completedSessionRead.data.session.lastTurnStatus, "completed");
579+  } finally {
580+    if (conductorStarted && conductor != null) {
581+      await conductor.stop();
582+    }
583+
584+    if (codexdStarted) {
585+      await codexd.stop();
586+    }
587+
588+    rmSync(workspace, {
589+      force: true,
590+      recursive: true
591+    });
592+  }
593+});