import { describe, expect, test } from "bun:test" import { EventEmitter } from "node:events" import { PassThrough } from "node:stream" import { CodexAppServerManager } from "./codex-app-server" class FakeCodexProcess extends EventEmitter { readonly stdin = new PassThrough() readonly stdout = new PassThrough() readonly stderr = new PassThrough() readonly messages: unknown[] = [] killed = false constructor( private readonly onMessage?: (message: any, process: FakeCodexProcess) => void ) { super() let buffer = "" this.stdin.on("data", (chunk) => { buffer += chunk.toString() const lines = buffer.split("\n") buffer = lines.pop() ?? "" for (const line of lines) { if (!line.trim()) continue const message = JSON.parse(line) this.messages.push(message) this.onMessage?.(message, this) } }) } kill() { this.killed = true this.emit("close", 0) } writeServerMessage(message: unknown) { this.stdout.write(`${JSON.stringify(message)}\n`) } writeStderr(message: string) { this.stderr.write(`${message}\n`) } closeWithCode(code: number) { this.emit("close", code) } } async function collectStream(stream: AsyncIterable) { const items: any[] = [] for await (const item of stream) { items.push(item) } return items } describe("CodexAppServerManager", () => { test("initializes app-server and starts a fresh thread", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) expect(process.messages).toHaveLength(3) expect((process.messages[0] as any).method).toBe("initialize") expect((process.messages[1] as any).method).toBe("initialized") expect((process.messages[2] as any).method).toBe("thread/start") }) test("falls back to thread/start when thread/resume is recoverably missing", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/resume") { child.writeServerMessage({ id: message.id, error: { message: "thread/resume failed: thread not found" }, }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-2" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) const started = await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: "missing-thread", }) expect(started).toEqual({ sessionToken: "thread-2", resumeFellBack: true }) expect(process.messages.map((message: any) => message.method)).toEqual([ "initialize", "initialized", "thread/resume", "thread/start", ]) }) test("forks a thread when a pending fork session token is provided", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/fork") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-fork-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) const started = await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, pendingForkSessionToken: "thread-source", }) expect(started).toEqual({ sessionToken: "thread-fork-1", resumeFellBack: false }) expect(process.messages.map((message: any) => message.method)).toEqual([ "initialize", "initialized", "thread/fork", ]) }) test("maps fast mode and reasoning into app-server params", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "completed", error: null } }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", serviceTier: "fast", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", effort: "xhigh", serviceTier: "fast", content: "Run pwd", planMode: false, onToolRequest: async () => ({}), }) await collectStream(turn.stream) const threadStart = process.messages.find((message: any) => message.method === "thread/start") as | { method: "thread/start"; params: { serviceTier?: string } } | undefined const turnStart = process.messages.find((message: any) => message.method === "turn/start") as | { method: "turn/start"; params: { effort?: string; serviceTier?: string; collaborationMode?: { settings?: { reasoning_effort?: string | null } } } } | undefined expect(threadStart?.params.serviceTier).toBe("fast") expect(turnStart?.params.effort).toBe("xhigh") expect(turnStart?.params.serviceTier).toBe("fast") expect(turnStart?.params.collaborationMode?.settings?.reasoning_effort).toBe("xhigh") }) test("attaches a structured skill item plus system-message failsafe when invoking a skill", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "completed", error: null } }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "/deploy-helper ship to prod", skill: { name: "deploy-helper", path: "/tmp/project/.agents/skills/deploy-helper/SKILL.md" }, planMode: false, onToolRequest: async () => ({}), }) await collectStream(turn.stream) const turnStart = process.messages.find((message: any) => message.method === "turn/start") as any expect(turnStart.params.input).toHaveLength(2) expect(turnStart.params.input[0].type).toBe("text") expect(turnStart.params.input[0].text.startsWith("/deploy-helper ship to prod")).toBe(true) expect(turnStart.params.input[0].text).toContain( "the user would like to use the skill available at /tmp/project/.agents/skills/deploy-helper/SKILL.md" ) expect(turnStart.params.input[1]).toEqual({ type: "skill", name: "deploy-helper", path: "/tmp/project/.agents/skills/deploy-helper/SKILL.md", }) }) test("listSkills maps skills/list and filters disabled skills", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "skills/list") { child.writeServerMessage({ id: message.id, result: { data: [ { cwd: "/tmp/project", skills: [ { name: "deploy-helper", description: "Deploys things", path: "/tmp/project/.agents/skills/deploy-helper/SKILL.md", scope: "repo", enabled: true, }, { name: "disabled-skill", description: "Off", path: "/tmp/project/.agents/skills/disabled-skill/SKILL.md", scope: "repo", enabled: false, }, ], }, ], }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null }) const skills = await manager.listSkills({ chatId: "chat-1", cwd: "/tmp/project" }) expect(skills?.map((skill) => skill.name)).toEqual(["deploy-helper"]) const request = process.messages.find((message: any) => message.method === "skills/list") as any expect(request.params.cwds).toEqual(["/tmp/project"]) }) test("listSkills degrades to null when the server lacks skills/list", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "skills/list") { child.writeServerMessage({ id: message.id, error: { code: -32601, message: "method not found" } }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null }) expect(await manager.listSkills({ chatId: "chat-1", cwd: "/tmp/project" })).toBeNull() expect(await manager.listSkills({ chatId: "chat-unknown", cwd: "/tmp/project" })).toBeNull() }) test("forwards every supported GPT-5.6 model and reasoning combination", async () => { const combinations = [ ...(["low", "medium", "high", "xhigh", "max", "ultra"] as const) .map((effort) => ({ model: "gpt-5.6-sol", effort })), ...(["low", "medium", "high", "xhigh", "max", "ultra"] as const) .map((effort) => ({ model: "gpt-5.6-terra", effort })), ...(["low", "medium", "high", "xhigh", "max"] as const) .map((effort) => ({ model: "gpt-5.6-luna", effort })), ] expect(combinations).toHaveLength(17) for (const [index, combination] of combinations.entries()) { const threadId = `thread-matrix-${index}` const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: threadId }, model: combination.model, reasoningEffort: combination.effort }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: `turn-matrix-${index}`, status: "completed", error: null } }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId, turn: { id: `turn-matrix-${index}`, status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never }) const chatId = `chat-matrix-${index}` await manager.startSession({ chatId, cwd: "/tmp/project", model: combination.model, sessionToken: null, }) const turn = await manager.startTurn({ chatId, model: combination.model, effort: combination.effort, content: "matrix test", planMode: false, onToolRequest: async () => ({}), }) await collectStream(turn.stream) const threadStart = process.messages.find((message: any) => message.method === "thread/start") as any const turnStart = process.messages.find((message: any) => message.method === "turn/start") as any expect(threadStart.params.model).toBe(combination.model) expect(turnStart.params.model).toBe(combination.model) expect(turnStart.params.effort).toBe(combination.effort) expect(turnStart.params.collaborationMode.settings).toMatchObject({ model: combination.model, reasoning_effort: combination.effort, }) manager.stopSession(chatId) } }) test("maps thread token usage updates into context window transcript entries", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-usage" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-usage", status: "completed", error: null } }, }) child.writeServerMessage({ method: "thread/tokenUsage/updated", params: { threadId: "thread-usage", turnId: "turn-usage", tokenUsage: { total: { inputTokens: 11_833, cachedInputTokens: 3456, outputTokens: 6, reasoningOutputTokens: 0, totalTokens: 11_839, }, last: { inputTokens: 120, cachedInputTokens: 0, outputTokens: 6, reasoningOutputTokens: 0, totalTokens: 126, }, modelContextWindow: 258_400, }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-usage", turn: { id: "turn-usage", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "Hello", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const usageEvent = events.find((event) => event.type === "transcript" && event.entry.kind === "context_window_updated") expect(usageEvent).toBeDefined() if (!usageEvent || usageEvent.type !== "transcript" || usageEvent.entry.kind !== "context_window_updated") { throw new Error("missing usage event") } expect(usageEvent.entry.usage).toMatchObject({ usedTokens: 126, totalProcessedTokens: 11_839, maxTokens: 258_400, inputTokens: 120, cachedInputTokens: 0, outputTokens: 6, reasoningOutputTokens: 0, lastUsedTokens: 126, compactsAutomatically: true, }) }) test("generateStructured returns the final assistant JSON and stops the transient session", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-structured" }, model: "gpt-5.5", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-structured", status: "completed", error: null } }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-structured", turnId: "turn-structured", item: { type: "agentMessage", id: "msg-structured", text: "{\"title\":\"Codex title\"}", phase: "final_answer", }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-structured", turn: { id: "turn-structured", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) const result = await manager.generateStructured({ cwd: "/tmp/project", prompt: "Return JSON", }) expect(result).toBe("{\"title\":\"Codex title\"}") expect(process.killed).toBe(true) expect((process.messages.find((message: any) => message.method === "thread/start") as any)?.params.model).toBe("gpt-5.5") expect((process.messages.find((message: any) => message.method === "turn/start") as any)?.params.model).toBe("gpt-5.5") }) test("maps command execution and agent output into the shared transcript stream", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "commandExecution", id: "call-1", command: "/bin/zsh -lc pwd", status: "inProgress", }, }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "commandExecution", id: "call-1", command: "/bin/zsh -lc pwd", status: "completed", aggregatedOutput: "/tmp/project\n", exitCode: 0, }, }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "agentMessage", id: "msg-1", text: "/tmp/project", phase: "final_answer", }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "Run pwd", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const transcriptKinds = events .filter((event) => event.type === "transcript") .map((event) => event.entry.kind) expect(events[0]).toEqual({ type: "session_token", sessionToken: "thread-1" }) expect(transcriptKinds).toEqual(["system_init", "tool_call", "tool_result", "assistant_text", "result"]) }) test("emits only a compact boundary when Codex reports thread compaction", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "thread/compacted", params: { threadId: "thread-1", turnId: "turn-1", }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "/compact", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const transcriptKinds = events .filter((event) => event.type === "transcript") .map((event) => event.entry.kind) expect(transcriptKinds).toEqual(["system_init", "compact_boundary", "result"]) expect(transcriptKinds).not.toContain("context_cleared") }) test("maps fileChange updates into edit_file tool calls", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "call-1", changes: [ { path: "/tmp/project/test.md", kind: { type: "update", move_path: null, }, diff: "@@ -1,2 +1,2 @@\n-old line\n+new line", }, ], status: "inProgress", }, }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "call-1", changes: [ { path: "/tmp/project/test.md", kind: { type: "update", move_path: null, }, diff: "@@ -1,2 +1,2 @@\n-old line\n+new line", }, ], status: "completed", }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "edit a file", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call") expect(toolCall?.entry.kind).toBe("tool_call") if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call") expect(toolCall.entry.tool.toolKind).toBe("edit_file") expect(toolCall.entry.tool.toolName).toBe("Edit") expect(toolCall.entry.tool.input).toEqual({ filePath: "/tmp/project/test.md", oldString: "old line", newString: "new line", }) }) test("maps fileChange adds into write_file tool calls", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "call-1", changes: [ { path: "/tmp/project/test.md", kind: { type: "add", move_path: null, }, diff: "@@ -0,0 +1,2 @@\n+hello\n+world", }, ], status: "inProgress", }, }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "call-1", changes: [ { path: "/tmp/project/test.md", kind: { type: "add", move_path: null, }, diff: "@@ -0,0 +1,2 @@\n+hello\n+world", }, ], status: "completed", }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "write a file", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call") expect(toolCall?.entry.kind).toBe("tool_call") if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call") expect(toolCall.entry.tool.toolKind).toBe("write_file") expect(toolCall.entry.tool.toolName).toBe("Write") expect(toolCall.entry.tool.input).toEqual({ filePath: "/tmp/project/test.md", content: "hello\nworld", }) }) test("maps plain-text fileChange adds into write_file tool calls", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "call-1", changes: [ { path: "/tmp/project/test.md", kind: { type: "add", move_path: null, }, diff: "hello\nworld\n", }, ], status: "completed", }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "write a file", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call") expect(toolCall?.entry.kind).toBe("tool_call") if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call") expect(toolCall.entry.tool.toolKind).toBe("write_file") expect(toolCall.entry.tool.input).toEqual({ filePath: "/tmp/project/test.md", content: "hello\nworld\n", }) }) test("maps plain-text fileChange deletes into delete_file tool calls", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "call-1", changes: [ { path: "/tmp/project/test.md", kind: { type: "delete", move_path: null, }, diff: "hello\nworld\n", }, ], status: "completed", }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "delete a file", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call") expect(toolCall?.entry.kind).toBe("tool_call") if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call") expect(toolCall.entry.tool.toolKind).toBe("delete_file") expect(toolCall.entry.tool.toolName).toBe("Delete") expect(toolCall.entry.tool.input).toEqual({ filePath: "/tmp/project/test.md", content: "hello\nworld\n", }) }) test("splits multi-change fileChange items into multiple tool calls and results", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "call-1", changes: [ { path: "/tmp/project/one.md", kind: { type: "add", move_path: null, }, diff: "@@ -0,0 +1,2 @@\n+hello\n+world", }, { path: "/tmp/project/two.md", kind: { type: "update", move_path: null, }, diff: "@@ -1,2 +1,2 @@\n-old line\n+new line", }, ], status: "completed", }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "change multiple files", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCalls = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call") const toolResults = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_result") expect(toolCalls).toHaveLength(2) expect(toolResults).toHaveLength(2) expect(toolCalls[0]?.entry.kind).toBe("tool_call") expect(toolCalls[1]?.entry.kind).toBe("tool_call") if (toolCalls[0]?.entry.kind !== "tool_call" || toolCalls[1]?.entry.kind !== "tool_call") { throw new Error("missing tool calls") } expect(toolCalls[0].entry.tool.toolKind).toBe("write_file") expect(toolCalls[0].entry.tool.toolId).toBe("call-1:change:0") expect(toolCalls[0].entry.tool.input).toEqual({ filePath: "/tmp/project/one.md", content: "hello\nworld", }) expect(toolCalls[1].entry.tool.toolKind).toBe("edit_file") expect(toolCalls[1].entry.tool.toolId).toBe("call-1:change:1") expect(toolCalls[1].entry.tool.input).toEqual({ filePath: "/tmp/project/two.md", oldString: "old line", newString: "new line", }) expect(toolResults[0]?.entry.kind).toBe("tool_result") expect(toolResults[1]?.entry.kind).toBe("tool_result") if (toolResults[0]?.entry.kind !== "tool_result" || toolResults[1]?.entry.kind !== "tool_result") { throw new Error("missing tool results") } expect(toolResults[0].entry.toolId).toBe("call-1:change:0") expect(toolResults[1].entry.toolId).toBe("call-1:change:1") }) test("maps plan updates into TodoWrite and synthesizes ExitPlanMode on successful plan turns", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "turn/plan/updated", params: { threadId: "thread-1", turnId: "turn-1", explanation: "Plan the work", plan: [ { step: "Inspect repo", status: "completed" }, { step: "Implement changes", status: "inProgress" }, ], }, }) child.writeServerMessage({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "plan", id: "plan-1", text: "", }, }, }) child.writeServerMessage({ method: "item/plan/delta", params: { threadId: "thread-1", turnId: "turn-1", itemId: "plan-1", delta: "## Plan\n\n- [x] Inspect repo\n- [ ] Implement changes", }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "make a plan", planMode: true, onToolRequest: async () => ({ confirmed: true }), }) const events = await collectStream(turn.stream) const toolCalls = events .filter((event) => event.type === "transcript" && event.entry.kind === "tool_call") .map((event) => event.entry.tool) expect(toolCalls[0]?.toolKind).toBe("todo_write") expect(toolCalls[1]?.toolKind).toBe("exit_plan_mode") if (!toolCalls[1] || toolCalls[1].toolKind !== "exit_plan_mode") { throw new Error("missing ExitPlanMode tool") } expect(toolCalls[1].input.summary).toBe("Plan the work") expect(toolCalls[1].input.plan).toContain("## Plan") }) test("maps collab agent tool calls into subagent_task", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "collabAgentToolCall", id: "agent-1", tool: "spawnAgent", status: "completed", senderThreadId: "thread-1", receiverThreadIds: ["thread-2"], prompt: "Inspect tests", agentsStates: { "thread-2": { status: "running", message: "Inspecting" }, }, }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "spawn an agent", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call") expect(toolCall?.entry.kind).toBe("tool_call") if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call") expect(toolCall.entry.tool.toolKind).toBe("subagent_task") expect(toolCall.entry.tool.input).toEqual({ subagentType: "spawnAgent" }) }) test("uses the completed webSearch query when the started item is empty", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "webSearch", id: "ws-1", query: "", }, }, }) child.writeServerMessage({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "webSearch", id: "ws-1", query: "jake mor", action: { type: "search", query: "jake mor", queries: ["jake mor"], }, }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "search", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCalls = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call") expect(toolCalls).toHaveLength(1) const toolCall = toolCalls[0] if (toolCall.entry.kind !== "tool_call") throw new Error("missing tool call") expect(toolCall.entry.tool.toolKind).toBe("web_search") expect(toolCall.entry.tool.input).toEqual({ query: "jake mor" }) }) test("responds to unsupported dynamic tool requests with a generic tool error", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ id: "dyn-1", method: "item/tool/call", params: { threadId: "thread-1", turnId: "turn-1", callId: "call-1", tool: "custom_tool", arguments: { value: 1 }, }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "call tool", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call") const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result") const response = process.messages.find((message: any) => message.id === "dyn-1") expect(toolCall?.entry.kind).toBe("tool_call") if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call") expect(toolCall.entry.tool.toolKind).toBe("unknown_tool") expect(toolCall.entry.tool.toolName).toBe("custom_tool") expect(toolResult?.entry.kind).toBe("tool_result") expect(response).toEqual({ id: "dyn-1", result: { contentItems: [{ type: "inputText", text: "Unsupported dynamic tool call: custom_tool" }], success: false, }, }) }) test("answers requestUserInput requests with the official JSON-RPC result payload", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ id: "req-1", method: "item/tool/requestUserInput", params: { threadId: "thread-1", turnId: "turn-1", itemId: "ask-1", questions: [ { id: "runtime", header: "Runtime", question: "Which runtime?", isOther: false, isSecret: false, options: null, }, ], }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "ask me", planMode: false, onToolRequest: async () => ({ questions: [{ id: "runtime", question: "Which runtime?", }], answers: { runtime: "bun", }, }), }) const events = await collectStream(turn.stream) const askEntry = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call") expect(askEntry?.entry.tool.toolKind).toBe("ask_user_question") const response = process.messages.find((message: any) => message.id === "req-1") expect(response).toEqual({ id: "req-1", result: { answers: { runtime: { answers: ["bun"], }, }, }, }) }) test("falls back to question text when requestUserInput answers are keyed by prompt text", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ id: "req-1", method: "item/tool/requestUserInput", params: { threadId: "thread-1", turnId: "turn-1", itemId: "ask-1", questions: [ { id: "favorite_color", header: "Color", question: "What is your favorite color right now?", isOther: true, isSecret: false, options: [ { label: "Red", description: null }, { label: "Blue", description: null }, ], }, ], }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "ask me", planMode: false, onToolRequest: async () => ({ questions: [{ id: "favorite_color", question: "What is your favorite color right now?", }], answers: { "What is your favorite color right now?": "Red", }, }), }) await collectStream(turn.stream) const response = process.messages.find((message: any) => message.id === "req-1") expect(response).toEqual({ id: "req-1", result: { answers: { favorite_color: { answers: ["Red"], }, }, }, }) }) test("infers multi-select Codex questions from prompt text and returns multiple answers", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ id: "req-1", method: "item/tool/requestUserInput", params: { threadId: "thread-1", turnId: "turn-1", itemId: "ask-1", questions: [ { id: "runtimes", header: "Runtime", question: "Select all runtimes that apply", isOther: true, isSecret: false, options: [ { label: "bun", description: null }, { label: "node", description: null }, ], }, ], }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "ask me", planMode: false, onToolRequest: async ({ tool }) => { expect(tool.toolKind).toBe("ask_user_question") if (tool.toolKind !== "ask_user_question") { return {} } expect(tool.input.questions[0]?.multiSelect).toBe(true) return { questions: [{ id: "runtimes", question: "Select all runtimes that apply", multiSelect: true, }], answers: { runtimes: ["bun", "node"], }, } }, }) await collectStream(turn.stream) const response = process.messages.find((message: any) => message.id === "req-1") expect(response).toEqual({ id: "req-1", result: { answers: { runtimes: { answers: ["bun", "node"], }, }, }, }) }) test("sends approval decisions back to the app-server", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeServerMessage({ id: "approval-1", method: "item/commandExecution/requestApproval", params: { threadId: "thread-1", turnId: "turn-1", itemId: "call-1", command: "rm -rf .", cwd: "/tmp/project", }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null }, }, }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "approve something", planMode: false, onToolRequest: async () => ({}), onApprovalRequest: async () => "accept", }) await collectStream(turn.stream) const response = process.messages.find((message: any) => message.id === "approval-1") expect(response).toEqual({ id: "approval-1", result: { decision: "accept", }, }) }) test("interrupt sends turn/interrupt for the active turn", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) } else if (message.method === "turn/interrupt") { child.writeServerMessage({ id: message.id, result: {} }) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "wait", planMode: false, onToolRequest: async () => ({}), }) await turn.interrupt() const interruptRequest = process.messages.find((message: any) => message.method === "turn/interrupt") as | { id: string; method: "turn/interrupt"; params: { threadId: string; turnId: string } } | undefined expect(interruptRequest).toBeDefined() if (!interruptRequest) throw new Error("missing interrupt request") expect(interruptRequest).toEqual({ id: interruptRequest.id, method: "turn/interrupt", params: { threadId: "thread-1", turnId: "turn-1", }, }) }) test("interrupt clears a pending exit-plan wait so a new turn can start immediately", async () => { let resolveToolRequest!: (value: unknown) => void const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { if (message.params.input[0]?.text === "make a plan") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-plan", status: "completed", error: null } }, }) child.writeServerMessage({ method: "turn/plan/updated", params: { threadId: "thread-1", turnId: "turn-plan", explanation: "Plan the work", plan: [{ step: "Inspect repo", status: "completed" }], }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-plan", status: "completed", error: null }, }, }) } else { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-next", status: "completed", error: null } }, }) child.writeServerMessage({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-next", status: "completed", error: null }, }, }) } } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "make a plan", planMode: true, onToolRequest: async () => await new Promise((resolve) => { resolveToolRequest = resolve }), }) const iterator = turn.stream[Symbol.asyncIterator]() await iterator.next() await iterator.next() await iterator.next() await turn.interrupt() const nextTurn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "continue", planMode: false, onToolRequest: async () => ({}), }) await collectStream(nextTurn.stream) resolveToolRequest({}) }) test("emits an error result when the app-server exits mid-turn", async () => { const process = new FakeCodexProcess((message, child) => { if (message.method === "initialize") { child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } }) } else if (message.method === "thread/start") { child.writeServerMessage({ id: message.id, result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" }, }) } else if (message.method === "turn/start") { child.writeServerMessage({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", error: null } }, }) child.writeStderr("fatal: app-server crashed") child.closeWithCode(1) } }) const manager = new CodexAppServerManager({ spawnProcess: () => process as never, }) await manager.startSession({ chatId: "chat-1", cwd: "/tmp/project", model: "gpt-5.4", sessionToken: null, }) const turn = await manager.startTurn({ chatId: "chat-1", model: "gpt-5.4", content: "crash", planMode: false, onToolRequest: async () => ({}), }) const events = await collectStream(turn.stream) const resultEvent = events.find((event) => event.type === "transcript" && event.entry.kind === "result") expect(resultEvent?.entry.subtype).toBe("error") expect(resultEvent?.entry.result).toContain("fatal: app-server crashed") }) })