diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index fdc8b4c4a71..abe138fdfb9 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,8 +1,19 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { providerModelsFromSettings } from "./providerSnapshot.ts"; +import { + isCommandMissingCause, + providerModelsFromSettings, + spawnAndCollect, +} from "./providerSnapshot.ts"; const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ @@ -42,3 +53,66 @@ describe("providerModelsFromSettings", () => { ]); }); }); + +describe("ProviderCommandNotFoundError", () => { + it("classifies normalized platform failures without parsing messages", () => { + expect( + isCommandMissingCause( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "arbitrary host detail", + }), + ), + ).toBe(true); + expect(isCommandMissingCause(new Error("spawn provider ENOENT"))).toBe(false); + }); + + it.effect("retains safe failed-command diagnostics without process output", () => { + const stderr = "'codex' is not recognized: secret-token-value"; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + return Effect.gen(function* () { + const error = yield* spawnAndCollect( + "C:\\tools\\codex.cmd", + ChildProcess.make("codex", ["--version"]), + ).pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.flip, + ); + + if (error._tag !== "ProviderCommandNotFoundError") { + throw new Error(`Unexpected error: ${error._tag}`); + } + + expect(error.binaryPath).toBe("C:\\tools\\codex.cmd"); + expect(error.exitCode).toBe(9009); + expect(error.stdoutLength).toBe(0); + expect(error.stderrLength).toBe(stderr.length); + expect(error.message).toBe( + "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).", + ); + expect(isCommandMissingCause(error)).toBe(true); + expect(error).not.toHaveProperty("stdout"); + expect(error).not.toHaveProperty("stderr"); + expect(error.message).not.toContain("secret-token-value"); + }); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 2ecb3220773..dfe31ffdc44 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -9,7 +9,8 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Data from "effect/Data"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; @@ -27,11 +28,21 @@ export interface CommandResult { readonly code: number; } -export class ProviderCommandExecutionError extends Data.TaggedError( - "ProviderCommandExecutionError", -)<{ - readonly message: string; -}> {} +export class ProviderCommandNotFoundError extends Schema.TaggedErrorClass()( + "ProviderCommandNotFoundError", + { + binaryPath: Schema.String, + exitCode: Schema.Number, + stdoutLength: Schema.Number, + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`; + } +} + +const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError); export interface ProviderProbeResult { readonly installed: boolean; @@ -56,9 +67,9 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -export function isCommandMissingCause(error: { readonly message: string }): boolean { - const lower = error.message.toLowerCase(); - return lower.includes("enoent") || lower.includes("notfound"); +export function isCommandMissingCause(error: unknown): boolean { + if (isProviderCommandNotFoundError(error)) return true; + return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; } export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => @@ -76,7 +87,12 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman const result: CommandResult = { stdout, stderr, code: exitCode }; if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); + return yield* new ProviderCommandNotFoundError({ + binaryPath, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); } return result; }).pipe(Effect.scoped);