- squash Effect causes to surface real Codex process error messages - normalize Git process results to error on signal/null exit and keep details - treat timed-out Codex health probes/auth checks as unavailable or warning with clear messages - add tests for cause extraction, Git signal termination, and provider timeout paths
Unified · Split
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | +import { Cause } from "effect"; | |
| 2 | 3 | import { ManagedRuntime, ServiceMap } from "effect"; |
| 3 | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | 5 | import { ProviderSessionId } from "@t3tools/contracts"; |
| @@ -7,6 +8,7 @@ import { · 2 unmodified lines | ||
| 7 | 8 | CodexAppServerManager, |
| 8 | 9 | classifyCodexStderrLine, |
| 9 | 10 | isRecoverableThreadResumeError, |
| 11 | + messageFromCodexProcessCause, | |
| 10 | 12 | normalizeCodexModelSlug, |
| 11 | 13 | } from "./codexAppServerManager"; |
| 12 | 14 | |
| @@ -171,6 +173,12 @@ describe("isRecoverableThreadResumeError", () => { · 158 unmodified lines | ||
| 171 | 173 | }); |
| 172 | 174 | }); |
| 173 | 175 | |
| 176 | +describe("messageFromCodexProcessCause", () => { | |
| 177 | + it("extracts the underlying error message from an effect cause", () => { | |
| 178 | + expect(messageFromCodexProcessCause(Cause.fail(new Error("boom")))).toBe("boom"); | |
| 179 | + }); | |
| 180 | +}); | |
| 181 | + | |
| 174 | 182 | describe("startSession", () => { |
| 175 | 183 | it("emits session/startFailed when resolving cwd throws before process launch", async () => { |
| 176 | 184 | const manager = createManager(); |
| @@ -17,7 +17,7 @@ import { · 16 unmodified lines | ||
| 17 | 17 | type ProviderTurnStartResult, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | 19 | import type * as NodeServices from "@effect/platform-node/NodeServices"; |
| 20 | −import { Effect, Exit, ServiceMap, Scope, Stream } from "effect"; | |
| 20 | +import { Cause, Effect, Exit, ServiceMap, Scope, Stream } from "effect"; | |
| 21 | 21 | import type { ChildProcessSpawner } from "effect/unstable/process"; |
| 22 | 22 | import { makeRuntimeCommand, spawnManagedCommand } from "./processRunner"; |
| 23 | 23 | |
| @@ -153,6 +153,11 @@ export function isRecoverableThreadResumeError(error: unknown): boolean { · 129 unmodified lines | ||
| 153 | 153 | return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); |
| 154 | 154 | } |
| 155 | 155 | |
| 156 | +export function messageFromCodexProcessCause(cause: Cause.Cause<unknown>): string { | |
| 157 | + const squashed = Cause.squash(cause); | |
| 158 | + return squashed instanceof Error ? squashed.message : "codex app-server process errored."; | |
| 159 | +} | |
| 160 | + | |
| 156 | 161 | export interface CodexAppServerManagerEvents { |
| 157 | 162 | event: [event: ProviderEvent]; |
| 158 | 163 | } |
| @@ -566,8 +571,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 407 unmodified lines | ||
| 566 | 571 | return; |
| 567 | 572 | } |
| 568 | 573 | |
| 569 | − const message = | |
| 570 | − cause instanceof Error ? cause.message : "codex app-server process errored."; | |
| 574 | + const message = messageFromCodexProcessCause(cause); | |
| 571 | 575 | this.updateSession(context, { |
| 572 | 576 | status: "error", |
| 573 | 577 | activeTurnId: undefined, |
| @@ -3,7 +3,7 @@ import { it, assert } from "@effect/vitest"; · 2 unmodified lines | ||
| 3 | 3 | import { Effect, Layer, Schema } from "effect"; |
| 4 | 4 | |
| 5 | 5 | import { GitCommandError } from "../Errors.ts"; |
| 6 | −import { GitServiceLive } from "./GitService.ts"; | |
| 6 | +import { GitServiceLive, normalizeGitProcessResult } from "./GitService.ts"; | |
| 7 | 7 | import { GitService } from "../Services/GitService.ts"; |
| 8 | 8 | |
| 9 | 9 | const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); |
| @@ -56,4 +56,31 @@ layer("GitServiceLive", (it) => { · 46 unmodified lines | ||
| 56 | 56 | } |
| 57 | 57 | }), |
| 58 | 58 | ); |
| 59 | + | |
| 60 | + it("normalizeGitProcessResult fails when git exits without a code", () => { | |
| 61 | + try { | |
| 62 | + void normalizeGitProcessResult( | |
| 63 | + { | |
| 64 | + operation: "GitProcess.test.signal", | |
| 65 | + cwd: process.cwd(), | |
| 66 | + args: ["status"], | |
| 67 | + }, | |
| 68 | + {}, | |
| 69 | + { | |
| 70 | + code: null, | |
| 71 | + signal: "SIGTERM", | |
| 72 | + stdout: "", | |
| 73 | + stderr: "", | |
| 74 | + timedOut: false, | |
| 75 | + }, | |
| 76 | + ); | |
| 77 | + assert.fail("Expected normalizeGitProcessResult to throw."); | |
| 78 | + } catch (error) { | |
| 79 | + assert.equal(Schema.is(GitCommandError)(error), true); | |
| 80 | + if (!Schema.is(GitCommandError)(error)) { | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + assert.equal(error.detail, "git status terminated by signal SIGTERM."); | |
| 84 | + } | |
| 85 | + }); | |
| 59 | 86 | }); |
| @@ -39,6 +39,59 @@ function toGitCommandError( · 38 unmodified lines | ||
| 39 | 39 | }); |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | +export function normalizeGitProcessResult( | |
| 43 | + commandInput: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 44 | + input: Pick<ExecuteGitInput, "allowNonZeroExit">, | |
| 45 | + result: { | |
| 46 | + readonly stdout: string; | |
| 47 | + readonly stderr: string; | |
| 48 | + readonly code: number | null; | |
| 49 | + readonly signal: NodeJS.Signals | null; | |
| 50 | + readonly timedOut: boolean; | |
| 51 | + }, | |
| 52 | +): ExecuteGitResult { | |
| 53 | + if (result.timedOut) { | |
| 54 | + throw new GitCommandError({ | |
| 55 | + operation: commandInput.operation, | |
| 56 | + command: quoteGitCommand(commandInput.args), | |
| 57 | + cwd: commandInput.cwd, | |
| 58 | + detail: `${quoteGitCommand(commandInput.args)} timed out.`, | |
| 59 | + }); | |
| 60 | + } | |
| 61 | + | |
| 62 | + if (result.code === null) { | |
| 63 | + throw new GitCommandError({ | |
| 64 | + operation: commandInput.operation, | |
| 65 | + command: quoteGitCommand(commandInput.args), | |
| 66 | + cwd: commandInput.cwd, | |
| 67 | + detail: | |
| 68 | + result.signal !== null | |
| 69 | + ? `${quoteGitCommand(commandInput.args)} terminated by signal ${result.signal}.` | |
| 70 | + : `${quoteGitCommand(commandInput.args)} terminated before reporting an exit code.`, | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + | |
| 74 | + const exitCode = result.code; | |
| 75 | + if (!input.allowNonZeroExit && exitCode !== 0) { | |
| 76 | + const trimmedStderr = result.stderr.trim(); | |
| 77 | + throw new GitCommandError({ | |
| 78 | + operation: commandInput.operation, | |
| 79 | + command: quoteGitCommand(commandInput.args), | |
| 80 | + cwd: commandInput.cwd, | |
| 81 | + detail: | |
| 82 | + trimmedStderr.length > 0 | |
| 83 | + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | |
| 84 | + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | |
| 85 | + }); | |
| 86 | + } | |
| 87 | + | |
| 88 | + return { | |
| 89 | + code: exitCode, | |
| 90 | + stdout: result.stdout, | |
| 91 | + stderr: result.stderr, | |
| 92 | + } satisfies ExecuteGitResult; | |
| 93 | +} | |
| 94 | + | |
| 42 | 95 | const makeGitService = Effect.sync(() => { |
| 43 | 96 | const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { |
| 44 | 97 | const commandInput = { |
| @@ -59,34 +112,7 @@ const makeGitService = Effect.sync(() => { · 14 unmodified lines | ||
| 59 | 112 | outputMode: "error", |
| 60 | 113 | }); |
| 61 | 114 | |
| 62 | − if (result.timedOut) { | |
| 63 | − throw new GitCommandError({ | |
| 64 | − operation: commandInput.operation, | |
| 65 | − command: quoteGitCommand(commandInput.args), | |
| 66 | − cwd: commandInput.cwd, | |
| 67 | − detail: `${quoteGitCommand(commandInput.args)} timed out.`, | |
| 68 | − }); | |
| 69 | − } | |
| 70 | − | |
| 71 | − const exitCode = result.code ?? 0; | |
| 72 | − if (!input.allowNonZeroExit && exitCode !== 0) { | |
| 73 | − const trimmedStderr = result.stderr.trim(); | |
| 74 | − throw new GitCommandError({ | |
| 75 | − operation: commandInput.operation, | |
| 76 | − command: quoteGitCommand(commandInput.args), | |
| 77 | − cwd: commandInput.cwd, | |
| 78 | − detail: | |
| 79 | − trimmedStderr.length > 0 | |
| 80 | − ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | |
| 81 | − : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | |
| 82 | − }); | |
| 83 | − } | |
| 84 | − | |
| 85 | − return { | |
| 86 | − code: exitCode, | |
| 87 | − stdout: result.stdout, | |
| 88 | − stderr: result.stderr, | |
| 89 | − } satisfies ExecuteGitResult; | |
| 115 | + return normalizeGitProcessResult(commandInput, input, result); | |
| 90 | 116 | }, |
| 91 | 117 | catch: toGitCommandError(commandInput, "failed to run."), |
| 92 | 118 | }); |
| @@ -127,7 +127,7 @@ export const spawnManagedCommand = (command: ChildProcess.Command) => · 126 unmodified lines | ||
| 127 | 127 | } satisfies ManagedChildProcess; |
| 128 | 128 | }); |
| 129 | 129 | |
| 130 | −export function spawnProcess( | |
| 130 | +function spawnProcess( | |
| 131 | 131 | command: string, |
| 132 | 132 | args: readonly string[], |
| 133 | 133 | options: ProcessSpawnOptions = {}, |
| @@ -7,7 +7,12 @@ import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderH · 6 unmodified lines | ||
| 7 | 7 | // ── Test helpers ──────────────────────────────────────────────────── |
| 8 | 8 | |
| 9 | 9 | function mockRunner( |
| 10 | − handler: (args: ReadonlyArray<string>) => { stdout: string; stderr: string; code: number }, | |
| 10 | + handler: (args: ReadonlyArray<string>) => { | |
| 11 | + stdout: string; | |
| 12 | + stderr: string; | |
| 13 | + code: number | null; | |
| 14 | + timedOut?: boolean; | |
| 15 | + }, | |
| 11 | 16 | ) { |
| 12 | 17 | return (args: ReadonlyArray<string>) => Promise.resolve(handler(args)); |
| 13 | 18 | } |
| @@ -116,6 +121,32 @@ it.effect("returns warning when login status command is unsupported", () => · 102 unmodified lines | ||
| 116 | 121 | }), |
| 117 | 122 | ); |
| 118 | 123 | |
| 124 | +it.effect("returns unavailable when codex version probe times out", () => | |
| 125 | + Effect.gen(function* () { | |
| 126 | + const status = yield* checkCodexProviderStatus( | |
| 127 | + mockRunner((args) => { | |
| 128 | + const joined = args.join(" "); | |
| 129 | + if (joined === "--version") { | |
| 130 | + return { stdout: "", stderr: "", code: null, timedOut: true }; | |
| 131 | + } | |
| 132 | + throw new Error(`Unexpected args: ${joined}`); | |
| 133 | + }), | |
| 134 | + ); | |
| 135 | + assert.strictEqual(status.provider, "codex"); | |
| 136 | + assert.strictEqual(status.status, "error"); | |
| 137 | + assert.strictEqual(status.available, false); | |
| 138 | + assert.strictEqual(status.authStatus, "unknown"); | |
| 139 | + assert.strictEqual(status.message, "Codex CLI is installed but failed to run. Timed out while running command."); | |
| 140 | + }), | |
| 141 | +); | |
| 142 | + | |
| 143 | +it("parseAuthStatusFromOutput: timed out auth check is warning", () => { | |
| 144 | + const parsed = parseAuthStatusFromOutput({ stdout: "", stderr: "", code: null, timedOut: true }); | |
| 145 | + assert.strictEqual(parsed.status, "warning"); | |
| 146 | + assert.strictEqual(parsed.authStatus, "unknown"); | |
| 147 | + assert.strictEqual(parsed.message, "Timed out while checking Codex authentication status."); | |
| 148 | +}); | |
| 149 | + | |
| 119 | 150 | // ── Pure function tests ───────────────────────────────────────────── |
| 120 | 151 | |
| 121 | 152 | it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { |
| @@ -31,7 +31,7 @@ class ProviderHealthCommandError extends Data.TaggedError("ProviderHealthCommand · 30 unmodified lines | ||
| 31 | 31 | export interface CommandResult { |
| 32 | 32 | readonly stdout: string; |
| 33 | 33 | readonly stderr: string; |
| 34 | − readonly code: number; | |
| 34 | + readonly code: number | null; | |
| 35 | 35 | readonly timedOut?: boolean; |
| 36 | 36 | } |
| 37 | 37 | |
| @@ -109,6 +109,14 @@ export function parseAuthStatusFromOutput(result: CommandResult): { · 71 unmodified lines | ||
| 109 | 109 | readonly authStatus: ServerProviderAuthStatus; |
| 110 | 110 | readonly message?: string; |
| 111 | 111 | } { |
| 112 | + if (result.timedOut) { | |
| 113 | + return { | |
| 114 | + status: "warning", | |
| 115 | + authStatus: "unknown", | |
| 116 | + message: "Timed out while checking Codex authentication status.", | |
| 117 | + }; | |
| 118 | + } | |
| 119 | + | |
| 112 | 120 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); |
| 113 | 121 | |
| 114 | 122 | if ( |
| @@ -199,7 +207,7 @@ const defaultRunCodexCommand: RunProviderHealthCommand = async (args) => { · 84 unmodified lines | ||
| 199 | 207 | return { |
| 200 | 208 | stdout: result.stdout, |
| 201 | 209 | stderr: result.stderr, |
| 202 | − code: result.code ?? 0, | |
| 210 | + code: result.code, | |
| 203 | 211 | timedOut: result.timedOut, |
| 204 | 212 | } satisfies CommandResult; |
| 205 | 213 | }; |
| @@ -237,7 +245,7 @@ export const checkCodexProviderStatus = ( · 31 unmodified lines | ||
| 237 | 245 | } |
| 238 | 246 | |
| 239 | 247 | const version = versionProbe.success; |
| 240 | − if (version.code !== 0) { | |
| 248 | + if (version.timedOut || version.code !== 0) { | |
| 241 | 249 | const detail = detailFromResult(version); |
| 242 | 250 | return { |
| 243 | 251 | provider: CODEX_PROVIDER, |