- 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
+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" \
+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"
+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
+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+```
+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 ## 节点变量
+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
+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` 没有新内容
+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
+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+});