authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:12:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:12:02-07:00
log300d4d566a1044dc401bcd7ebf43def08621229e
tree7def31379c28fe0e1ae0ce72e80f145046709a3c
parent53a477c2e91d206057adc5ac4c28cbdaddfbc07c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure missing provider command failures (#3384)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 102 insertions(+), 12 deletions(-)

apps/server/src/provider/providerSnapshot.test.ts+76-2
......@@ -1,8 +1,19 @@
1import { describe, expect, it } from "vite-plus/test";
1import { describe, expect, it } from "@effect/vitest";
22import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
34import { createModelCapabilities } from "@t3tools/shared/model";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
411
5import { providerModelsFromSettings } from "./providerSnapshot.ts";
12import {
13 isCommandMissingCause,
14 providerModelsFromSettings,
15 spawnAndCollect,
16} from "./providerSnapshot.ts";
617
718const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
819 optionDescriptors: [
......@@ -42,3 +53,66 @@ describe("providerModelsFromSettings", () => {
4253 ]);
4354 });
4455});
56
57describe("ProviderCommandNotFoundError", () => {
58 it("classifies normalized platform failures without parsing messages", () => {
59 expect(
60 isCommandMissingCause(
61 PlatformError.systemError({
62 _tag: "NotFound",
63 module: "ChildProcess",
64 method: "spawn",
65 description: "arbitrary host detail",
66 }),
67 ),
68 ).toBe(true);
69 expect(isCommandMissingCause(new Error("spawn provider ENOENT"))).toBe(false);
70 });
71
72 it.effect("retains safe failed-command diagnostics without process output", () => {
73 const stderr = "'codex' is not recognized: secret-token-value";
74 const spawner = ChildProcessSpawner.make(() =>
75 Effect.succeed(
76 ChildProcessSpawner.makeHandle({
77 pid: ChildProcessSpawner.ProcessId(1),
78 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)),
79 isRunning: Effect.succeed(false),
80 kill: () => Effect.void,
81 unref: Effect.succeed(Effect.void),
82 stdin: Sink.drain,
83 stdout: Stream.empty,
84 stderr: Stream.encodeText(Stream.make(stderr)),
85 all: Stream.empty,
86 getInputFd: () => Sink.drain,
87 getOutputFd: () => Stream.empty,
88 }),
89 ),
90 );
91 return Effect.gen(function* () {
92 const error = yield* spawnAndCollect(
93 "C:\\tools\\codex.cmd",
94 ChildProcess.make("codex", ["--version"]),
95 ).pipe(
96 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
97 Effect.provideService(HostProcessPlatform, "win32"),
98 Effect.flip,
99 );
100
101 if (error._tag !== "ProviderCommandNotFoundError") {
102 throw new Error(`Unexpected error: ${error._tag}`);
103 }
104
105 expect(error.binaryPath).toBe("C:\\tools\\codex.cmd");
106 expect(error.exitCode).toBe(9009);
107 expect(error.stdoutLength).toBe(0);
108 expect(error.stderrLength).toBe(stderr.length);
109 expect(error.message).toBe(
110 "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).",
111 );
112 expect(isCommandMissingCause(error)).toBe(true);
113 expect(error).not.toHaveProperty("stdout");
114 expect(error).not.toHaveProperty("stderr");
115 expect(error.message).not.toContain("secret-token-value");
116 });
117 });
118});
apps/server/src/provider/providerSnapshot.ts+26-10
......@@ -9,7 +9,8 @@ import type {
99 ServerProviderState,
1010} from "@t3tools/contracts";
1111import * as Effect from "effect/Effect";
12import * as Data from "effect/Data";
12import * as PlatformError from "effect/PlatformError";
13import * as Schema from "effect/Schema";
1314import * as Stream from "effect/Stream";
1415import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1516import { normalizeModelSlug } from "@t3tools/shared/model";
......@@ -27,11 +28,21 @@ export interface CommandResult {
2728 readonly code: number;
2829}
2930
30export class ProviderCommandExecutionError extends Data.TaggedError(
31 "ProviderCommandExecutionError",
32)<{
33 readonly message: string;
34}> {}
31export class ProviderCommandNotFoundError extends Schema.TaggedErrorClass<ProviderCommandNotFoundError>()(
32 "ProviderCommandNotFoundError",
33 {
34 binaryPath: Schema.String,
35 exitCode: Schema.Number,
36 stdoutLength: Schema.Number,
37 stderrLength: Schema.Number,
38 },
39) {
40 override get message(): string {
41 return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`;
42 }
43}
44
45const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError);
3546
3647export interface ProviderProbeResult {
3748 readonly installed: boolean;
......@@ -56,9 +67,9 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined {
5667 return trimmed.length > 0 ? trimmed : undefined;
5768}
5869
59export function isCommandMissingCause(error: { readonly message: string }): boolean {
60 const lower = error.message.toLowerCase();
61 return lower.includes("enoent") || lower.includes("notfound");
70export function isCommandMissingCause(error: unknown): boolean {
71 if (isProviderCommandNotFoundError(error)) return true;
72 return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound";
6273}
6374
6475export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) =>
......@@ -76,7 +87,12 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman
7687
7788 const result: CommandResult = { stdout, stderr, code: exitCode };
7889 if (yield* isWindowsCommandNotFound(exitCode, stderr)) {
79 return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` });
90 return yield* new ProviderCommandNotFoundError({
91 binaryPath,
92 exitCode,
93 stdoutLength: stdout.length,
94 stderrLength: stderr.length,
95 });
8096 }
8197 return result;
8298 }).pipe(Effect.scoped);