diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 4ecc0ad28971b..96771b3e46699 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -5,9 +5,16 @@ import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; import { GrokSettings } from "@t3tools/provider-grok/settings"; import { @@ -221,6 +228,68 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); + it.effect.each([ + { + label: "answers after 6 s", + versionDelay: "6 seconds", + version: "1.0.52", + message: "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", + probesAfterVersion: true, + }, + { + label: "never answers", + versionDelay: "Infinity", + version: null, + message: "Grok CLI is installed but timed out while running `grok --version`.", + probesAfterVersion: false, + }, + ] as const)("gives `grok --version` 10 s when it $label", (row) => + Effect.gen(function* () { + // A fake CLI runs on wall time, so a stub answers `--version` on the test clock. + const spawned: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + spawned.push(args.join(" ")); + if (!args.includes("--version")) { + return Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + description: "Only `grok --version` is stubbed.", + }), + ); + } + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_201), + exitCode: Effect.delay(Effect.succeed(ChildProcessSpawner.ExitCode(0)), row.versionDelay), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.fromEffect(Effect.delay(Effect.succeed("grok 1.0.52\n"), row.versionDelay)), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + return Effect.succeed(handle); + }); + const fiber = yield* checkGrokProviderStatus(decodeGrokSettings({ enabled: true }), {}).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* TestClock.adjust("10 seconds"); + const snapshot = yield* Fiber.join(fiber); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe(row.version); + expect(snapshot.message).toBe(row.message); + expect(spawned.length > 1).toBe(row.probesAfterVersion); + }), + ); + // A stand-in for the Grok CLI: `--version` and `models` print canned text, // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. // A list of `models` outputs is served in call order, repeating the last one. diff --git a/packages/provider-fx/src/server/status.test.ts b/packages/provider-fx/src/server/status.test.ts index a235340a49e21..3f9ae01ae969a 100644 --- a/packages/provider-fx/src/server/status.test.ts +++ b/packages/provider-fx/src/server/status.test.ts @@ -1,11 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; import { FxSettings } from "../settings.ts"; import { checkFxProviderStatus } from "./status.ts"; @@ -13,15 +16,21 @@ import { checkFxProviderStatus } from "./status.ts"; const encoder = new TextEncoder(); const settings = Schema.decodeSync(FxSettings)({ enabled: true }); -function processHandle(stdout: string, code: number) { +/** Holds stdout and the exit back until `answered` completes, like a CLI slow to answer. */ +function processHandle(stdout: string, code: number, answered: Effect.Effect = Effect.void) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(900_000_101), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), + exitCode: Effect.as(answered, ChildProcessSpawner.ExitCode(code)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: stdout.length === 0 ? Stream.empty : Stream.succeed(encoder.encode(stdout)), + stdout: Stream.unwrap( + Effect.as( + answered, + stdout.length === 0 ? Stream.empty : Stream.succeed(encoder.encode(stdout)), + ), + ), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -30,7 +39,7 @@ function processHandle(stdout: string, code: number) { } /** Answers `fx` invocations by their arguments and records which ran. */ -const check = (statusJson: string) => +const check = (statusJson: string, versionDelay: Duration.Input = 0) => Effect.gen(function* () { const outputs: Record = { "--version": "fx 0.0.13\n", @@ -50,7 +59,13 @@ const check = (statusJson: string) => const args = ChildProcess.isStandardCommand(command) ? command.args.join(" ") : ""; invoked.push(args); const stdout = outputs[args]; - return Effect.succeed(processHandle(stdout ?? "", stdout === undefined ? 2 : 0)); + return Effect.succeed( + processHandle( + stdout ?? "", + stdout === undefined ? 2 : 0, + args === "--version" ? Effect.sleep(versionDelay) : Effect.void, + ), + ); }); const draft = yield* checkFxProviderStatus(settings, {}).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -59,6 +74,38 @@ const check = (statusJson: string) => }).pipe(Effect.provide(NodeServices.layer)); describe("checkFxProviderStatus", () => { + it.effect.each([ + { + label: "answers after 6 s", + versionDelay: "6 seconds", + status: "ready", + version: "0.0.13", + message: undefined, + invoked: ["--version", "status --json", "models --json"], + }, + { + label: "never answers", + versionDelay: "Infinity", + status: "error", + version: null, + message: "Failed to run `fx --version`.", + invoked: ["--version"], + }, + ] as const)("gives `fx --version` 10 s when it $label", (row) => + Effect.gen(function* () { + const fiber = yield* check( + JSON.stringify({ kind: "status", model: "anthropic/claude-opus-5.5", auth: "fx login" }), + row.versionDelay, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 seconds"); + const { draft, invoked } = yield* Fiber.join(fiber); + assert.equal(draft.status, row.status); + assert.equal(draft.version, row.version); + assert.equal(draft.message, row.message); + assert.deepEqual(invoked, [...row.invoked]); + }), + ); + // `fx status --json` from fx 0.0.13, trimmed to the fields T3 reads plus a few it ignores. it.effect("reports sign-in from `fx status` without opening an ACP session", () => Effect.gen(function* () { diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index ec8ad5bce9f8e..c71e909197df2 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -19,7 +19,6 @@ import { import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, - DEFAULT_TIMEOUT_MS, isCommandMissingCause, nonEmptyTrimmed, parseGenericCliVersion, @@ -135,6 +134,8 @@ const FxModels = Schema.fromJsonString( ); const decodeFxModels = Schema.decodeUnknownOption(FxModels); +const VERSION_PROBE_TIMEOUT_MS = 10_000; + function fxAuth(auth: string | undefined): ServerProviderAuth { const method = nonEmptyTrimmed(auth); if (method === undefined) return { status: "unknown" }; @@ -163,7 +164,7 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function } const versionResult = yield* runFxCli(settings, ["--version"], environment).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); if (Result.isFailure(versionResult) || Option.isNone(versionResult.success)) { diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index c634c5b80c74e..cd6e4f3b4fe48 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -58,7 +58,7 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); -const VERSION_PROBE_TIMEOUT_MS = 4_000; +const VERSION_PROBE_TIMEOUT_MS = 10_000; // Grok writes the initialize result only after startup work (settings, // skill discovery, file watching). From a home directory that work measured // 9-14s on CLI 1.0.52, so an 8s cap records a healthy CLI as a failed probe. diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index b8559d1ab7d8a..55caff62eac86 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -8,6 +8,8 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./status.ts"; @@ -21,19 +23,22 @@ function processHandle(input: { readonly stdout?: string; readonly stderr?: string; readonly exitCode?: number; + /** Holds stdout and the exit back until it completes, like a CLI slow to answer. */ + readonly answered?: Effect.Effect; }) { + const answered = input.answered ?? Effect.void; const bytes = (value: string | undefined) => value === undefined || value.length === 0 ? Stream.empty : Stream.succeed(encoder.encode(value)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(900_000_001), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + exitCode: Effect.as(answered, ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: bytes(input.stdout), + stdout: Stream.unwrap(Effect.as(answered, bytes(input.stdout))), stderr: bytes(input.stderr), all: Stream.empty, getInputFd: () => Sink.drain, @@ -148,6 +153,48 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect.each([ + { + label: "answers after 6 s", + versionDelay: "6 seconds", + status: "ready", + version: "0.84.3", + message: "could not refresh its models and commands", + probesAfterVersion: true, + }, + { + label: "never answers", + versionDelay: "Infinity", + status: "error", + version: null, + message: "Pi CLI is installed but timed out while running `pi --version`.", + probesAfterVersion: false, + }, + ] as const)("gives `pi --version` 10 s when it $label", (row) => + Effect.gen(function* () { + const spawned: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + spawned.push(args.join(" ")); + return Effect.succeed( + args.includes("--version") + ? processHandle({ stdout: "pi 0.84.3\n", answered: Effect.sleep(row.versionDelay) }) + : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), + ); + }); + const fiber = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* TestClock.adjust("10 seconds"); + const snapshot = yield* Fiber.join(fiber); + assert.equal(snapshot.status, row.status); + assert.equal(snapshot.version, row.version); + assert.include(snapshot.message ?? "", row.message); + assert.equal(spawned.length > 1, row.probesAfterVersion); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("reports a known unauthenticated inventory when Pi returns no usable models", () => Effect.gen(function* () { const spawner = yield* piDiscoverySpawner({ models: [] }); diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index a180ab9c41a3b..dfafa75ccd579 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -29,6 +29,7 @@ import { import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { buildServerProvider, + DEFAULT_TIMEOUT_MS, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -56,7 +57,7 @@ const PI_PRESENTATION = { reportsContextWindow: true, } as const; -const VERSION_PROBE_TIMEOUT_MS = 4_000; +const VERSION_PROBE_TIMEOUT_MS = 10_000; const PI_RPC_DISCOVERY_TIMEOUT_MS = 30_000; /** * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. @@ -278,7 +279,7 @@ const describeUnavailablePiModel = Effect.fn("PiProviderStatus.describeUnavailab }), ), ), - Effect.timeout(VERSION_PROBE_TIMEOUT_MS), + Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.option, ); const missingCredentials = Option.exists(