From d4bcfd3622355d65eff35e7c087d5e5e13743200 Mon Sep 17 00:00:00 2001 From: agadzik Date: Fri, 9 Oct 2026 15:04:57 -0400 Subject: [PATCH 01/11] feat(provider-fx): add Vercel fx as a provider fx is Vercel's CLI coding agent. This ports it into packages/provider-fx as a thin ACP flavor over makeAcpAdapterV2, off by default, with Supervised and Auto only. Permission mode is always written from advertised ids, skills are discovered from fx's folders, and text generation stays unsupported. --- apps/mobile/package.json | 1 + apps/mobile/src/lib/providerClients.ts | 2 + apps/server/package.json | 1 + .../Adapters/FxAdapterV2.test.ts | 707 ++++++++++++++++++ .../builtInProviderAdapterDrivers.ts | 3 + .../src/provider/ProviderRegistry.test.ts | 14 +- apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/provider/model-manifest.json | 11 +- .../src/provider/providerStatusCache.ts | 1 + apps/web/package.json | 1 + .../components/settings/providerDriverMeta.ts | 2 + .../src/components/settings/settingsSearch.ts | 4 +- package.json | 2 +- packages/contracts/src/model.ts | 3 + packages/contracts/src/settings.test.ts | 2 +- packages/contracts/src/settings.ts | 2 +- packages/provider-fx/package.json | 43 ++ packages/provider-fx/src/client.ts | 25 + packages/provider-fx/src/server.ts | 7 + .../provider-fx/src/server/acpSupport.test.ts | 344 +++++++++ packages/provider-fx/src/server/acpSupport.ts | 350 +++++++++ packages/provider-fx/src/server/adapter.ts | 206 +++++ .../provider-fx/src/server/driver.test.ts | 145 ++++ packages/provider-fx/src/server/driver.ts | 230 ++++++ .../provider-fx/src/server/fx-models.json | 12 + .../src/server/fx-session-config-options.json | 61 ++ .../src/server/fx-status-signed-in.json | 6 + .../src/server/fx-status-signed-out.json | 7 + .../provider-fx/src/server/skills.test.ts | 277 +++++++ packages/provider-fx/src/server/skills.ts | 186 +++++ .../provider-fx/src/server/status.test.ts | 276 +++++++ packages/provider-fx/src/server/status.ts | 433 +++++++++++ .../provider-fx/src/server/textGeneration.ts | 20 + packages/provider-fx/src/settings.ts | 37 + packages/provider-fx/src/testing.ts | 6 + packages/provider-fx/tsconfig.json | 7 + pnpm-lock.yaml | 49 ++ 37 files changed, 3475 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts create mode 100644 packages/provider-fx/package.json create mode 100644 packages/provider-fx/src/client.ts create mode 100644 packages/provider-fx/src/server.ts create mode 100644 packages/provider-fx/src/server/acpSupport.test.ts create mode 100644 packages/provider-fx/src/server/acpSupport.ts create mode 100644 packages/provider-fx/src/server/adapter.ts create mode 100644 packages/provider-fx/src/server/driver.test.ts create mode 100644 packages/provider-fx/src/server/driver.ts create mode 100644 packages/provider-fx/src/server/fx-models.json create mode 100644 packages/provider-fx/src/server/fx-session-config-options.json create mode 100644 packages/provider-fx/src/server/fx-status-signed-in.json create mode 100644 packages/provider-fx/src/server/fx-status-signed-out.json create mode 100644 packages/provider-fx/src/server/skills.test.ts create mode 100644 packages/provider-fx/src/server/skills.ts create mode 100644 packages/provider-fx/src/server/status.test.ts create mode 100644 packages/provider-fx/src/server/status.ts create mode 100644 packages/provider-fx/src/server/textGeneration.ts create mode 100644 packages/provider-fx/src/settings.ts create mode 100644 packages/provider-fx/src/testing.ts create mode 100644 packages/provider-fx/tsconfig.json diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fccf31e61630..ab383586f85a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -73,6 +73,7 @@ "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-grok": "workspace:*", + "@t3tools/provider-fx": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index 96755bd85410..ded7b794538c 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -3,6 +3,7 @@ import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { fxClient } from "@t3tools/provider-fx/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -11,6 +12,7 @@ import { piClient } from "@t3tools/provider-pi/client"; const providerClients = makeProviderClientRegistry([ cursorClient, grokClient, + fxClient, museClient, openCodeClient, piClient, diff --git a/apps/server/package.json b/apps/server/package.json index 1147b5e06233..702fec7c12f0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-grok": "workspace:*", + "@t3tools/provider-fx": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts new file mode 100644 index 000000000000..00927631ae18 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts @@ -0,0 +1,707 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + ProviderInstanceId, + ProviderSessionId, + type RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; +import { FxSettings } from "@t3tools/provider-fx/settings"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/process"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import { + FX_DRIVER_KIND, + FxProviderCapabilitiesV2, + makeFxAdapterV2, +} from "@t3tools/provider-fx/testing"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import { + decodeAcpReplayTranscript, + makeAcpReplayCompletenessAssertion, + makeAcpReplayRuntime, +} from "./AcpAdapterV2.testkit.ts"; + +const decodeFxSettings = Schema.decodeSync(FxSettings); +const recordedConfigOptions = JSON.parse( + NodeFS.readFileSync( + NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../../../../../packages/provider-fx/src/server/fx-session-config-options.json", + ), + "utf8", + ), +) as ReadonlyArray; + +const testLayer = layerTestProviderHost().pipe( + Layer.provide(NodeServices.layer), + Layer.merge(IdAllocator.layer), + Layer.merge(NodeServices.layer), +); + +type Frame = Record; +const outbound = (method: string, params: unknown = ""): Frame => ({ + type: "expect_outbound", + frame: { kind: "request", method, params }, +}); +const answer = (method: string, result: unknown): Frame => ({ + type: "emit_inbound", + frame: { kind: "response", method, result }, +}); + +function withCurrent( + configId: string, + value: string, + base: ReadonlyArray = recordedConfigOptions, +): ReadonlyArray { + return base.map((option) => + option.id === configId && option.type === "select" + ? { ...option, currentValue: value } + : option, + ); +} + +const post1189ModeValues = ["auto", "ask", "full-access"] as const; + +function withModeAdvertisement( + currentValue: string, + values: ReadonlyArray = post1189ModeValues, + base: ReadonlyArray = recordedConfigOptions, +): ReadonlyArray { + return base.map((option) => + option.id === "mode" && option.type === "select" + ? { + ...option, + currentValue, + options: values.map((value) => ({ value, name: value })), + } + : option, + ); +} + +const initializeResult = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: false, embeddedContext: true }, + mcpCapabilities: { http: true, sse: true, acp: true }, + }, + agentInfo: { name: "fx", title: "fx", version: "0.0.13" }, + authMethods: [], +}; + +describe("FxAdapterV2", () => { + it("does not switch runtime mode or steer in session", () => { + expect(FxProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession).toBe(false); + expect(FxProviderCapabilitiesV2.turns.supportsActiveSteering).toBe(false); + }); + + const openReplay = Effect.fn("openFxReplay")(function* (input: { + readonly scenario: string; + readonly entries: ReadonlyArray; + readonly initialNativeThreadId?: string; + readonly runtimeMode?: RuntimeMode; + readonly options?: ReadonlyArray<{ readonly id: string; readonly value: string }>; + }) { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const replayDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fx-adapter-" }); + const statusPath = path.join(replayDir, "status.json"); + const transcript = yield* decodeAcpReplayTranscript( + { + provider: FX_DRIVER_KIND, + protocol: "acp.ndjson-jsonrpc", + version: "1", + scenario: input.scenario, + entries: input.entries as never, + }, + FX_DRIVER_KIND, + ); + const instanceId = ProviderInstanceId.make("fx"); + const adapter = yield* makeFxAdapterV2({ + instanceId, + settings: decodeFxSettings({ enabled: true }), + environment: {}, + selfInvocation: yield* resolveSelfInvocation(), + currentFxDefaultModel: Effect.succeed("anthropic/claude-opus-5.5"), + makeRuntime: makeAcpReplayRuntime({ + transcript, + statusPath, + scriptPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-replay-agent.ts", import.meta.url), + ), + childProcessSpawner, + fileSystem, + }), + }); + yield* adapter + .openSession({ + threadId: ThreadId.make("thread-fx"), + providerSessionId: ProviderSessionId.make("provider-session-fx"), + modelSelection: { + instanceId, + model: "anthropic/claude-sonnet-5.5", + options: input.options ?? [{ id: "effort", value: "high" }], + }, + runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: input.runtimeMode ?? "auto", + interactionMode: "default", + cwd: replayDir, + }), + ...(input.initialNativeThreadId === undefined + ? {} + : { initialNativeThreadId: input.initialNativeThreadId }), + }) + .pipe(Effect.scoped); + yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); + }); + + it.effect("writes model, effort, and mode after session/new", () => + openReplay({ + scenario: "fx-session-new-config", + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { sessionId: "fx-session", configOptions: recordedConfigOptions }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent("model", "anthropic/claude-sonnet-5.5"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "high", + }), + answer("session/set_config_option", { + configOptions: withCurrent("effort", "high"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "code", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "code"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes effort medium when the selection has no effort", () => + openReplay({ + scenario: "fx-session-new-default-effort", + options: [], + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { sessionId: "fx-session", configOptions: recordedConfigOptions }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent("model", "anthropic/claude-sonnet-5.5"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "medium", + }), + answer("session/set_config_option", { + configOptions: withCurrent( + "effort", + "medium", + withCurrent("model", "anthropic/claude-sonnet-5.5"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "code", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "code"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes nothing when live effort is already medium", () => + openReplay({ + scenario: "fx-session-new-live-medium-effort", + options: [], + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { + sessionId: "fx-session", + configOptions: withCurrent("effort", "medium"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent( + "model", + "anthropic/claude-sonnet-5.5", + withCurrent("effort", "medium"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "code", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "code", withCurrent("effort", "medium")), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes effort medium for a stale stored default", () => + openReplay({ + scenario: "fx-session-new-stale-default-effort", + options: [{ id: "effort", value: "default" }], + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { sessionId: "fx-session", configOptions: recordedConfigOptions }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent("model", "anthropic/claude-sonnet-5.5"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "medium", + }), + answer("session/set_config_option", { + configOptions: withCurrent( + "effort", + "medium", + withCurrent("model", "anthropic/claude-sonnet-5.5"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "code", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "code"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes effort medium on resume when the selection has no effort", () => + openReplay({ + scenario: "fx-session-load-default-effort", + initialNativeThreadId: "fx-session", + options: [], + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/load"), + answer("session/load", { + sessionId: "fx-session", + configOptions: withCurrent("effort", "high"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent( + "model", + "anthropic/claude-sonnet-5.5", + withCurrent("effort", "high"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "medium", + }), + answer("session/set_config_option", { + configOptions: withCurrent( + "effort", + "medium", + withCurrent("model", "anthropic/claude-sonnet-5.5", withCurrent("effort", "high")), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "code", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "code"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("loads a resumed session with session/load", () => + openReplay({ + scenario: "fx-session-load", + initialNativeThreadId: "fx-session", + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/load"), + answer("session/load", { sessionId: "fx-session", configOptions: recordedConfigOptions }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent("model", "anthropic/claude-sonnet-5.5"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "high", + }), + answer("session/set_config_option", { + configOptions: withCurrent("effort", "high"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "code", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "code"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes mode ask when a supervised thread reports ask", () => + openReplay({ + scenario: "fx-session-new-supervised-mode", + runtimeMode: "approval-required", + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { sessionId: "fx-session", configOptions: recordedConfigOptions }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent("model", "anthropic/claude-sonnet-5.5"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "high", + }), + answer("session/set_config_option", { + configOptions: withCurrent("effort", "high"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "ask", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "ask"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes mode code when an auto thread reports ask", () => + openReplay({ + scenario: "fx-session-new-auto-reports-ask", + options: [], + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { + sessionId: "fx-session", + configOptions: withCurrent("effort", "medium"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent( + "model", + "anthropic/claude-sonnet-5.5", + withCurrent("effort", "medium"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "code", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "code", withCurrent("effort", "medium")), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes mode ask on resume when a supervised thread reports ask", () => + openReplay({ + scenario: "fx-session-load-supervised-mode", + initialNativeThreadId: "fx-session", + runtimeMode: "approval-required", + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/load"), + answer("session/load", { sessionId: "fx-session", configOptions: recordedConfigOptions }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withCurrent("model", "anthropic/claude-sonnet-5.5"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "high", + }), + answer("session/set_config_option", { + configOptions: withCurrent("effort", "high"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "ask", + }), + answer("session/set_config_option", { + configOptions: withCurrent("mode", "ask"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes mode auto when later fx reports auto on an auto thread", () => + openReplay({ + scenario: "fx-session-new-auto-reports-auto", + options: [], + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { + sessionId: "fx-session", + configOptions: withModeAdvertisement( + "auto", + post1189ModeValues, + withCurrent("effort", "medium"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "auto", + post1189ModeValues, + withCurrent("model", "anthropic/claude-sonnet-5.5", withCurrent("effort", "medium")), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "auto", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "auto", + post1189ModeValues, + withCurrent("effort", "medium"), + ), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes mode ask when later fx reports auto on a supervised thread", () => + openReplay({ + scenario: "fx-session-new-supervised-reports-auto", + runtimeMode: "approval-required", + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { + sessionId: "fx-session", + configOptions: withModeAdvertisement("auto"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "auto", + post1189ModeValues, + withCurrent("model", "anthropic/claude-sonnet-5.5"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "high", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "auto", + post1189ModeValues, + withCurrent("effort", "high"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "ask", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement("ask"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes mode ask when later fx reports full-access on a supervised thread", () => + openReplay({ + scenario: "fx-session-new-supervised-reports-full-access", + runtimeMode: "approval-required", + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { + sessionId: "fx-session", + configOptions: withModeAdvertisement("full-access"), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "full-access", + post1189ModeValues, + withCurrent("model", "anthropic/claude-sonnet-5.5"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "effort", + value: "high", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "full-access", + post1189ModeValues, + withCurrent("effort", "high"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "ask", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement("ask"), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("writes mode auto when later fx reports full-access on an auto thread", () => + openReplay({ + scenario: "fx-session-new-auto-reports-full-access", + options: [], + entries: [ + outbound("initialize"), + answer("initialize", initializeResult), + outbound("session/new"), + answer("session/new", { + sessionId: "fx-session", + configOptions: withModeAdvertisement( + "full-access", + post1189ModeValues, + withCurrent("effort", "medium"), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "model", + value: "anthropic/claude-sonnet-5.5", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "full-access", + post1189ModeValues, + withCurrent("model", "anthropic/claude-sonnet-5.5", withCurrent("effort", "medium")), + ), + }), + outbound("session/set_config_option", { + sessionId: "fx-session", + configId: "mode", + value: "auto", + }), + answer("session/set_config_option", { + configOptions: withModeAdvertisement( + "auto", + post1189ModeValues, + withCurrent("effort", "medium"), + ), + }), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 77cd6f3933d5..6e52ac5f3010 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -14,6 +14,7 @@ import { type CursorAdapterV2DriverEnv, } from "@t3tools/provider-cursor/server"; import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "@t3tools/provider-grok/server"; +import { FxAdapterV2Driver, type FxAdapterV2DriverEnv } from "@t3tools/provider-fx/server"; import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, @@ -27,6 +28,7 @@ export type BuiltInProviderAdapterDriversV2Env = | CodexAdapterV2DriverEnv | CursorAdapterV2DriverEnv | GrokAdapterV2DriverEnv + | FxAdapterV2DriverEnv | OpenCodeAdapterV2DriverEnv | PiAdapterV2DriverEnv; @@ -38,6 +40,7 @@ const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< CursorAdapterV2Driver, OpenCodeAdapterV2Driver, GrokAdapterV2Driver, + FxAdapterV2Driver, PiAdapterV2Driver, AcpRegistryAdapterV2Driver, ]; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index d0c1f348a736..729cf32218f0 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -2723,7 +2723,7 @@ it.layer( // driver's probe *before* it touches the spawner, so the // test environment stays isolated from the dev // machine's PATH. - ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), + ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "fx", "opencode"), // Matches the shape the user had in `.t3/dev/settings.json` // when the bug was reported: a custom enabled Codex instance // pointing at a binary the server has to actually spawn. @@ -2827,7 +2827,7 @@ it.layer( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { providerInstances: { - ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), + ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "fx", "opencode"), [ProviderInstanceId.make("codex")]: { driver: ProviderDriverKind.make("codex"), enabled: true, @@ -2920,7 +2920,7 @@ it.layer( ); yield* serverSettings.updateSettings({ providerInstances: { - ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), + ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "fx", "opencode"), [ProviderInstanceId.make("codex")]: { driver: ProviderDriverKind.make("codex"), enabled: true, @@ -2954,7 +2954,7 @@ it.layer( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { providerInstances: { - ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), + ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "fx", "opencode"), ghost_main: { driver: "ghostDriver", displayName: "A fork-only driver we don't ship", @@ -3020,7 +3020,7 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providerInstances: disabledDefaultSlots("codex", "grok"), + providerInstances: disabledDefaultSlots("codex", "grok", "fx"), }), ), ); @@ -3097,6 +3097,7 @@ it.layer( "claudeAgent", "codex", "cursor", + "fx", "grok", "muse", "opencode", @@ -3108,6 +3109,9 @@ it.layer( const museProvider = providers.find((provider) => provider.driver === "muse"); assert.strictEqual(museProvider?.enabled, false); assert.strictEqual(museProvider?.status, "disabled"); + const fxProvider = providers.find((provider) => provider.driver === "fx"); + assert.strictEqual(fxProvider?.enabled, false); + assert.strictEqual(fxProvider?.status, "disabled"); assert.strictEqual(cursorSpawned, false); }).pipe(Effect.provide(runtimeServices)); }), diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 532bd602d145..ceeeb13ebce8 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -29,6 +29,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; +import { FxDriver, type FxDriverEnv } from "@t3tools/provider-fx/server"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; @@ -49,6 +50,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | FxDriverEnv | OpenCodeDriverEnv | PiDriverEnv | MuseDriverEnv; @@ -63,6 +65,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray=1.4.2", "status": "supported" }, { "range": "<1.4.2", "status": "unknown" } ] + }, + { + "driver": "fx", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=0.0.13", + "ranges": [ + { "range": ">=0.0.13", "status": "supported" }, + { "range": "<0.0.13", "status": "unsupported" } + ] } ], "currentModels": { diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index ea9e7ff9ce7a..df2e71969fe6 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -40,6 +40,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "grok", "opencode", "antigravity", + "fx", ]; const driverRank = (driver: string): number => { diff --git a/apps/web/package.json b/apps/web/package.json index 1d2d93ed36a9..b99ea246885a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-grok": "workspace:*", + "@t3tools/provider-fx": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index b64f5d714bad..b374d10e1ad0 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -8,6 +8,7 @@ import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { fxClient } from "@t3tools/provider-fx/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -26,6 +27,7 @@ export const providerClients = makeProviderClientRegistry([ }, cursorClient, grokClient, + fxClient, openCodeClient, { driverKind: ProviderDriverKind.make("antigravity"), diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c23eeb8fdf06..0bde16fcf98f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -443,7 +443,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "provider-update-checks", title: "Provider update checks", to: "/settings/general", - searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], + searchTerms: ["installed cli versions newer available codex claude cursor grok fx opencode"], scope: "environment-defaults", }, { @@ -630,7 +630,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", + "agents cli codex claude cursor grok fx opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", ], }, { diff --git a/package.json b/package.json index e21d895fd656..c2edda284993 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/provider-fx --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 3a863a1670c3..f1a97affa33e 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -147,6 +147,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const FX_DRIVER_KIND = ProviderDriverKind.make("fx"); const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); @@ -178,6 +179,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [FX_DRIVER_KIND]: "fx", [MUSE_DRIVER_KIND]: "Muse Code", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b5d39323a23d..7f52aad5a482 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -816,7 +816,7 @@ describe("provider enabled defaults", () => { resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make(driver), config: {} }); expect(enabledByDefault("codex")).toBe(true); expect(enabledByDefault("claudeAgent")).toBe(true); - for (const driver of ["cursor", "grok", "muse", "pi", "opencode", "antigravity"]) { + for (const driver of ["cursor", "grok", "fx", "muse", "pi", "opencode", "antigravity"]) { expect(enabledByDefault(driver)).toBe(false); } }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0038ede86c2d..ff3f477dee7d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1323,7 +1323,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * `enabled` decoding default of each driver's settings schema. */ const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) => + ["cursor", "grok", "fx", "muse", "pi", "opencode", "antigravity"].map((driver) => ProviderDriverKind.make(driver), ), ); diff --git a/packages/provider-fx/package.json b/packages/provider-fx/package.json new file mode 100644 index 000000000000..283b3678ded0 --- /dev/null +++ b/packages/provider-fx/package.json @@ -0,0 +1,43 @@ +{ + "name": "@t3tools/provider-fx", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-acp": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:", + "yaml": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-fx/src/client.ts b/packages/provider-fx/src/client.ts new file mode 100644 index 000000000000..2357dd390884 --- /dev/null +++ b/packages/provider-fx/src/client.ts @@ -0,0 +1,25 @@ +/** + * fx's client definition: settings form, label, and glyph. + * Browser- and React Native-safe. + * + * @module provider-fx/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { FxSettings } from "./settings.ts"; + +export const fxClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("fx"), + label: "fx", + settingsSchema: FxSettings, + icon: { + viewBox: "166.241 0 155.861 156", + fill: { light: "#0F0F0F", dark: "#F5F5F5" }, + paths: [ + { + d: "M237.89 0C243.18 0 249.38 1.42 253.03 3.07L255.09 4.01L250.08 18.63L247.68 17.75C244.9 16.72 241.94 15.8 238.49 15.8C234.98 15.8 232.79 16.56 231.08 18.32C229.23 20.23 227.63 23.64 226.23 29.76L226.23 29.76L226.23 29.76L225.14 34.85H241.67L260.43 34.95L260.69 34.95L260.84 35.17L278.85 61.63L296.74 34.95H320.87L291.68 76.74L322.1 119.75H299.33L299.18 119.55L241.14 40.48L239.35 49.4H222.07L205.69 127.21C203.93 135.71 201.19 142.84 196.78 147.87C192.27 153.01 186.2 155.75 178.34 155.75C174.18 155.75 170.75 155.11 167.91 154.11L166.24 153.52V137.18L166.9 137.4L169.53 138.28C172.18 139.16 174.41 139.8 177.14 139.8C178.53 139.8 179.7 139.53 180.73 138.98C181.76 138.43 182.68 137.6 183.52 136.44C185.3 133.99 186.72 130.13 187.9 124.67L203.76 49.4H189.87L191.76 39.44L192.04 39.35L206.82 34.47L208.15 28.64C210.52 18.21 213.77 10.94 218.71 6.32C223.74 1.61 230.13 0 237.89 0ZM273.99 99.08L260.07 120.25H234.54L261 82.02L273.99 99.08Z", + }, + ], + }, +}); diff --git a/packages/provider-fx/src/server.ts b/packages/provider-fx/src/server.ts new file mode 100644 index 000000000000..39def8970ea6 --- /dev/null +++ b/packages/provider-fx/src/server.ts @@ -0,0 +1,7 @@ +/** + * fx's server entry: the driver and adapter driver the server registers. + * + * @module provider-fx/server + */ +export { FxDriver, type FxDriverEnv } from "./server/driver.ts"; +export { FxAdapterV2Driver, type FxAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts new file mode 100644 index 000000000000..4f3f5446e137 --- /dev/null +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -0,0 +1,344 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { describe, expect, it } from "@effect/vitest"; +import type { RuntimeMode } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { collectSessionConfigOptionValues } from "@t3tools/provider-acp/server/runtimeModel"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import { + applyFxEffortSelection, + applyFxModelSelection, + buildFxAcpSpawnInput, + FX_DEFAULT_MODEL_SLUG, + FX_EFFORT_CONFIG_ID, + FX_EFFORT_OPTION_DESCRIPTOR, + FX_FALLBACK_RUNTIME_MODE, + FX_MODEL_CONFIG_ID, + FX_SUPPORTED_RUNTIME_MODES, + applyFxSessionMode, + fxPermissionDisposition, + fxSemanticModeOf, + fxSessionModeForPolicy, + resolveFxSessionModeId, +} from "./acpSupport.ts"; + +const recordedConfigOptions = JSON.parse( + NodeFS.readFileSync( + NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "fx-session-config-options.json", + ), + "utf8", + ), +) as ReadonlyArray; + +function runtimePolicy( + runtimeMode: RuntimeMode, + override: Partial = {}, +) { + return ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "default", + cwd: "/workspace", + ...override, + }); +} + +function permissionRequest( + kind: EffectAcpSchema.ToolKind, +): EffectAcpSchema.RequestPermissionRequest { + return { + sessionId: "session-1", + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + toolCall: { + toolCallId: "tool-1", + title: "Test tool", + kind, + }, + }; +} + +function recordedEffortSelect(): Extract { + const effort = recordedConfigOptions.find( + (option) => option.id === FX_EFFORT_CONFIG_ID && option.type === "select", + ); + if (effort === undefined || effort.type !== "select") { + throw new Error("recorded fx session fixture is missing the effort select"); + } + return effort; +} + +function withRecordedEffortSelect( + effort: Extract, +): ReadonlyArray { + return recordedConfigOptions.map((option) => + option.id === FX_EFFORT_CONFIG_ID ? effort : option, + ); +} + +function makeRecordingRuntime( + configOptions: ReadonlyArray = recordedConfigOptions, +) { + const writes: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + return { + writes, + runtime: { + getConfigOptions: Effect.succeed(configOptions), + setConfigOption: (configId: string, value: string | boolean) => { + writes.push({ configId, value }); + return Effect.succeed({ configOptions }); + }, + }, + }; +} + +function makeModeRuntime(input: { + readonly currentModeId: string; + readonly modeIds: ReadonlyArray; +}) { + const calls: Array = []; + const runtime: Parameters[0]["runtime"] = { + getModeState: Effect.succeed({ + currentModeId: input.currentModeId, + availableModes: input.modeIds.map((id) => ({ id, name: id })), + }), + getConfigOptions: Effect.succeed([]), + setMode: (modeId) => + Effect.sync(() => { + calls.push(`setMode:${modeId}`); + return {}; + }), + request: (method) => + Effect.sync(() => { + calls.push(`request:${method}`); + return {}; + }), + }; + return { calls, runtime }; +} + +describe("fx session mode", () => { + it("lists the fallback mode first", () => { + expect(FX_SUPPORTED_RUNTIME_MODES[0]).toBe(FX_FALLBACK_RUNTIME_MODE); + }); + + it("maps each offered T3 mode onto fx ask or auto", () => { + expect(fxSessionModeForPolicy(runtimePolicy("approval-required"))).toBe("ask"); + expect(fxSessionModeForPolicy(runtimePolicy("auto"))).toBe("auto"); + }); + + it("resolves auto to code on fx 0.0.13 and to auto on later fx", () => { + expect(resolveFxSessionModeId("auto", ["code", "ask"])).toBe("code"); + expect(resolveFxSessionModeId("ask", ["code", "ask"])).toBe("ask"); + expect(resolveFxSessionModeId("auto", ["auto", "ask", "full-access"])).toBe("auto"); + expect(resolveFxSessionModeId("ask", ["auto", "ask", "full-access"])).toBe("ask"); + expect(resolveFxSessionModeId("auto", ["ask", "full-access"])).toBeUndefined(); + }); + + it("maps only ask, auto, and fx 0.0.13's code onto fx semantics", () => { + expect(fxSemanticModeOf("ask")).toBe("ask"); + expect(fxSemanticModeOf("auto")).toBe("auto"); + expect(fxSemanticModeOf("code")).toBe("auto"); + expect(fxSemanticModeOf("plan")).toBeUndefined(); + expect(fxSemanticModeOf("architect")).toBeUndefined(); + expect(fxSemanticModeOf("full-access")).toBeUndefined(); + }); + + it.effect("rejects a plan mode without writing a mode", () => + Effect.gen(function* () { + const { calls, runtime } = makeModeRuntime({ + currentModeId: "ask", + modeIds: ["code", "ask"], + }); + + const error = yield* Effect.flip( + applyFxSessionMode({ runtime, sessionId: "session-1", mode: "plan" }), + ); + + expect(error._tag).toBe("FxSessionModeUnavailableError"); + expect(error.message).toBe("fx cannot run in mode 'plan'; it advertises code, ask."); + expect(calls).toEqual([]); + }), + ); + + it.effect("restores fx 0.0.13's code mode when the adapter passes it back", () => + Effect.gen(function* () { + const { calls, runtime } = makeModeRuntime({ + currentModeId: "ask", + modeIds: ["code", "ask"], + }); + + yield* applyFxSessionMode({ runtime, sessionId: "session-1", mode: "code" }); + + expect(calls).toEqual(["setMode:code"]); + }), + ); + + it("runs full-access as ask, matching approval-required", () => { + expect(fxSessionModeForPolicy(runtimePolicy("full-access"))).toBe("ask"); + const edit = permissionRequest("edit"); + const read = permissionRequest("read"); + expect(fxPermissionDisposition(runtimePolicy("full-access"), edit)).toBe("ask"); + expect(fxPermissionDisposition(runtimePolicy("approval-required"), edit)).toBe("ask"); + expect(fxPermissionDisposition(runtimePolicy("full-access"), read)).toBe("allow"); + expect(fxPermissionDisposition(runtimePolicy("approval-required"), read)).toBe("allow"); + }); + + it("leaves Auto prompts to the user unless an explicit policy forced ask", () => { + expect(fxPermissionDisposition(runtimePolicy("auto"), permissionRequest("read"))).toBe("ask"); + expect(fxPermissionDisposition(runtimePolicy("auto"), permissionRequest("edit"))).toBe("ask"); + const readOnly = runtimePolicy("auto", { + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }); + expect(fxSessionModeForPolicy(readOnly)).toBe("ask"); + expect(fxPermissionDisposition(readOnly, permissionRequest("execute"))).toBe("deny"); + }); +}); + +describe("FX_EFFORT_OPTION_DESCRIPTOR", () => { + it("lists six effort levels with medium as default", () => { + expect(FX_EFFORT_OPTION_DESCRIPTOR.type).toBe("select"); + if (FX_EFFORT_OPTION_DESCRIPTOR.type !== "select") return; + expect(FX_EFFORT_OPTION_DESCRIPTOR.options.map((option) => option.id)).toEqual([ + "auto", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(FX_EFFORT_OPTION_DESCRIPTOR.options.find((option) => option.isDefault)?.id).toBe( + "medium", + ); + }); +}); + +describe("buildFxAcpSpawnInput", () => { + it("spawns fx acp without a model flag", () => { + expect( + buildFxAcpSpawnInput({ binaryPath: "/usr/local/bin/fx" }, "/tmp/project", { + HOME: "/tmp/home", + }), + ).toEqual({ + command: "/usr/local/bin/fx", + args: ["acp"], + cwd: "/tmp/project", + env: { HOME: "/tmp/home" }, + }); + }); +}); + +describe("applyFxModelSelection", () => { + it.effect("writes the model config id, not provider", () => + Effect.gen(function* () { + const { runtime, writes } = makeRecordingRuntime(); + const result = yield* applyFxModelSelection({ + runtime, + requestedModel: "anthropic/claude-sonnet-5.5", + fxDefaultModel: "anthropic/claude-opus-5.5", + }); + expect(result).toBe("anthropic/claude-sonnet-5.5"); + expect(writes).toEqual([ + { configId: FX_MODEL_CONFIG_ID, value: "anthropic/claude-sonnet-5.5" }, + ]); + }), + ); + + it.effect("skips the write when the live model already matches", () => + Effect.gen(function* () { + const { runtime, writes } = makeRecordingRuntime(); + const result = yield* applyFxModelSelection({ + runtime, + requestedModel: "anthropic/claude-opus-5.5", + fxDefaultModel: "anthropic/claude-opus-5.5", + }); + expect(result).toBe("anthropic/claude-opus-5.5"); + expect(writes).toEqual([]); + }), + ); + + it.effect("resolves Default to fx's reported model", () => + Effect.gen(function* () { + const { runtime, writes } = makeRecordingRuntime(); + const result = yield* applyFxModelSelection({ + runtime, + requestedModel: FX_DEFAULT_MODEL_SLUG, + fxDefaultModel: "anthropic/claude-sonnet-5.5", + }); + expect(result).toBe("anthropic/claude-sonnet-5.5"); + expect(writes).toEqual([{ configId: "model", value: "anthropic/claude-sonnet-5.5" }]); + }), + ); + + it.effect("writes nothing when Default has no probed model", () => + Effect.gen(function* () { + const { runtime, writes } = makeRecordingRuntime(); + const result = yield* applyFxModelSelection({ + runtime, + requestedModel: FX_DEFAULT_MODEL_SLUG, + fxDefaultModel: undefined, + }); + expect(result).toBe("anthropic/claude-opus-5.5"); + expect(writes).toEqual([]); + }), + ); + + it.effect("keeps the current model for an unadvertised id, including provider values", () => + Effect.gen(function* () { + const { runtime, writes } = makeRecordingRuntime(); + const result = yield* applyFxModelSelection({ + runtime, + requestedModel: "codex", + fxDefaultModel: "anthropic/claude-opus-5.5", + }); + expect(result).toBe("anthropic/claude-opus-5.5"); + expect(writes).toEqual([]); + }), + ); +}); + +describe("applyFxEffortSelection", () => { + it.effect("writes medium when the selection has no effort", () => + Effect.gen(function* () { + const { runtime, writes } = makeRecordingRuntime(); + yield* applyFxEffortSelection({ runtime, requestedEffort: undefined }); + expect(writes).toEqual([{ configId: FX_EFFORT_CONFIG_ID, value: "medium" }]); + }), + ); + + it.effect("writes nothing when live effort is already medium", () => + Effect.gen(function* () { + const { runtime, writes } = makeRecordingRuntime( + withRecordedEffortSelect({ ...recordedEffortSelect(), currentValue: "medium" }), + ); + yield* applyFxEffortSelection({ runtime, requestedEffort: undefined }); + expect(writes).toEqual([]); + }), + ); + + it.effect("writes nothing when the live session does not advertise medium", () => + Effect.gen(function* () { + const effort = recordedEffortSelect(); + const { runtime, writes } = makeRecordingRuntime( + withRecordedEffortSelect({ + ...effort, + options: collectSessionConfigOptionValues(effort) + .filter((id) => id !== "medium") + .map((id) => ({ value: id, name: id })), + }), + ); + yield* applyFxEffortSelection({ runtime, requestedEffort: undefined }); + expect(writes).toEqual([]); + }), + ); +}); diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts new file mode 100644 index 000000000000..f91db1400139 --- /dev/null +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -0,0 +1,350 @@ +import type * as EffectAcpSchema from "effect-acp/compat"; +import { + type ProviderOptionDescriptor, + ProviderDriverKind, + type RuntimeMode, +} from "@t3tools/contracts"; +import type { FxSettings } from "../settings.ts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { collectSessionConfigOptionValues } from "@t3tools/provider-acp/server/runtimeModel"; +import { + acpPermissionDisposition, + type AcpPermissionDisposition, +} from "@t3tools/provider-acp/server/clientPolicy"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +export const FX_DRIVER_KIND = ProviderDriverKind.make("fx"); +export const FX_DEFAULT_MODEL_SLUG = "default"; +export const FX_MODEL_CONFIG_ID = "model"; +const FX_MODE_CONFIG_ID = "mode"; +export const FX_EFFORT_CONFIG_ID = "effort"; +const FX_DEFAULT_EFFORT = "medium"; +const FX_EFFORT_LEVELS = ["auto", "low", "medium", "high", "xhigh", "max"] as const; +export type FxEffortLevel = (typeof FX_EFFORT_LEVELS)[number]; + +function isFxEffortLevel(value: unknown): value is FxEffortLevel { + return typeof value === "string" && (FX_EFFORT_LEVELS as ReadonlyArray).includes(value); +} + +export type FxSessionMode = "ask" | "auto"; + +export const FX_FALLBACK_RUNTIME_MODE = "approval-required" as const satisfies RuntimeMode; + +export const FX_SUPPORTED_RUNTIME_MODES = [ + FX_FALLBACK_RUNTIME_MODE, + "auto", +] as const satisfies ReadonlyArray; +export type FxRuntimeMode = (typeof FX_SUPPORTED_RUNTIME_MODES)[number]; + +const FX_SESSION_MODE_BY_RUNTIME_MODE = { + "approval-required": "ask", + auto: "auto", +} as const satisfies Record; + +export class FxSessionModeUnavailableError extends Schema.TaggedError()( + "FxSessionModeUnavailableError", + { + requested: Schema.String, + advertised: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const advertised = this.advertised.length > 0 ? this.advertised.join(", ") : "none"; + return `fx cannot run in mode '${this.requested}'; it advertises ${advertised}.`; + } +} + +export const FX_EFFORT_OPTION_DESCRIPTOR: ProviderOptionDescriptor = { + id: FX_EFFORT_CONFIG_ID, + label: "Effort", + type: "select", + options: [ + { id: "auto", label: "Auto" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + ], +}; + +export interface FxAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "spawn" | "cancelBehavior" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly fxSettings: Pick; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildFxAcpSpawnInput( + settings: Pick, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { command: settings.binaryPath || "fx", args: ["acp"], cwd, env: { ...environment } }; +} + +function fxRuntimeModeOf(policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy): FxRuntimeMode { + const mode = policy.runtimeMode; + for (const supported of FX_SUPPORTED_RUNTIME_MODES) { + if (mode === supported) return supported; + } + return FX_FALLBACK_RUNTIME_MODE; +} + +export function fxSessionModeForPolicy( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, +): FxSessionMode { + if (policy.approvalPolicy !== undefined || policy.sandboxPolicy !== undefined) { + return "ask"; + } + return FX_SESSION_MODE_BY_RUNTIME_MODE[fxRuntimeModeOf(policy)]; +} + +function advertisedFxModeIds(input: { + readonly availableModes?: ReadonlyArray<{ readonly id: string }>; + readonly configOptions: ReadonlyArray; +}): ReadonlyArray { + const fromModes = (input.availableModes ?? []) + .map((mode) => mode.id) + .filter((id) => id.length > 0); + if (fromModes.length > 0) { + return fromModes; + } + const option = input.configOptions.find( + (entry) => + entry.type === "select" && (entry.id === FX_MODE_CONFIG_ID || entry.category === "mode"), + ); + return option === undefined ? [] : collectSessionConfigOptionValues(option); +} + +/** + * Maps a mode id the adapter asks for onto T3's fx semantics. The adapter + * passes `ask` or `auto` from the runtime policy, and can pass fx 0.0.13's + * native `code` back when it restores a saved mode. Anything else, such as a + * plan mode, has no fx equivalent. + */ +export function fxSemanticModeOf(modeId: string): FxSessionMode | undefined { + switch (modeId) { + case "ask": + return "ask"; + case "auto": + case "code": + return "auto"; + default: + return undefined; + } +} + +export function resolveFxSessionModeId( + semantic: FxSessionMode, + advertised: ReadonlyArray, +): string | undefined { + if (semantic === "ask") { + return advertised.includes("ask") ? "ask" : undefined; + } + if (advertised.includes("auto")) { + return "auto"; + } + if (advertised.includes("code")) { + return "code"; + } + return undefined; +} + +/** + * fx 0.0.13 reports `ask` on new sessions while enforcing its global default + * until a client writes the mode. Later fx renamed `code` to `auto` and reports + * the saved permission mode honestly. + */ +export const applyFxSessionMode = (input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getModeState" | "getConfigOptions" | "setMode" | "request" + >; + readonly sessionId: string; + readonly mode: string; +}): Effect.Effect => + Effect.gen(function* () { + const current = yield* input.runtime.getModeState; + const configOptions = yield* input.runtime.getConfigOptions; + const advertised = advertisedFxModeIds({ + ...(current === undefined ? {} : { availableModes: current.availableModes }), + configOptions, + }); + const semantic = fxSemanticModeOf(input.mode); + const target = + semantic === undefined ? undefined : resolveFxSessionModeId(semantic, advertised); + if (target === undefined) { + return yield* new FxSessionModeUnavailableError({ + requested: input.mode, + advertised: [...advertised], + }); + } + if (current?.currentModeId !== target) { + yield* input.runtime.setMode(target); + return; + } + yield* input.runtime.request("session/set_config_option", { + sessionId: input.sessionId, + configId: FX_MODE_CONFIG_ID, + value: target, + }); + }); + +export function withFxSessionModeAlwaysWritten( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], +): AcpSessionRuntime.AcpSessionRuntime["Service"] { + const session = { id: undefined as string | undefined }; + const captureSession = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.tap((result) => + Effect.sync(() => { + session.id = result.sessionId; + }), + ), + ); + + return { + ...runtime, + start: () => captureSession(runtime.start()), + loadSession: (sessionId, options) => captureSession(runtime.loadSession(sessionId, options)), + resumeSession: (sessionId, options) => + captureSession(runtime.resumeSession(sessionId, options)), + forkSession: (sessionId, options) => captureSession(runtime.forkSession(sessionId, options)), + setMode: (modeId) => { + if (session.id === undefined) { + return runtime.setMode(modeId); + } + return applyFxSessionMode({ + runtime, + sessionId: session.id, + mode: modeId, + }).pipe( + Effect.mapError((error): EffectAcpErrors.AcpError => + error._tag === "FxSessionModeUnavailableError" + ? new EffectAcpErrors.AcpTransportError({ + method: "session/set_config_option", + detail: error.message, + cause: error, + }) + : error, + ), + Effect.as({}), + ); + }, + }; +} + +export function fxPermissionDisposition( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + request: EffectAcpSchema.RequestPermissionRequest, +): AcpPermissionDisposition { + const mode = fxRuntimeModeOf(policy); + const fxAlreadyApprovedRoutineActions = + mode === "auto" && fxSessionModeForPolicy(policy) === "auto"; + if (fxAlreadyApprovedRoutineActions) { + return "ask"; + } + return acpPermissionDisposition({ ...policy, runtimeMode: mode }, request); +} + +/** + * Writes config id `model` by id, never the first `category: "model"` option (that is `provider`). + */ +export const applyFxModelSelection = (input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setConfigOption" + >; + readonly requestedModel: string; + readonly fxDefaultModel: string | undefined; +}): Effect.Effect => + Effect.gen(function* () { + const options = yield* input.runtime.getConfigOptions; + const option = options.find( + (entry) => entry.id === FX_MODEL_CONFIG_ID && entry.type === "select", + ); + const current = option?.type === "select" ? option.currentValue : undefined; + const target = + input.requestedModel === "" || input.requestedModel === FX_DEFAULT_MODEL_SLUG + ? input.fxDefaultModel + : input.requestedModel; + if (target === undefined || target === current) { + return current; + } + const advertised = option === undefined ? [] : collectSessionConfigOptionValues(option); + if (!advertised.includes(target)) { + yield* Effect.logWarning( + "fx model is not advertised by the live session; keeping the current model", + { requested: target, current }, + ); + return current; + } + yield* input.runtime.setConfigOption(FX_MODEL_CONFIG_ID, target); + return target; + }); + +export const applyFxEffortSelection = (input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setConfigOption" + >; + readonly requestedEffort: unknown; +}): Effect.Effect => + Effect.gen(function* () { + if (isFxEffortLevel(input.requestedEffort)) { + return undefined; + } + const options = yield* input.runtime.getConfigOptions; + const option = options.find( + (entry) => entry.id === FX_EFFORT_CONFIG_ID && entry.type === "select", + ); + const current = option?.type === "select" ? option.currentValue : undefined; + if (current === FX_DEFAULT_EFFORT) { + return current; + } + const advertised = option === undefined ? [] : collectSessionConfigOptionValues(option); + if (!advertised.includes(FX_DEFAULT_EFFORT)) { + return current; + } + yield* input.runtime.setConfigOption(FX_EFFORT_CONFIG_ID, FX_DEFAULT_EFFORT); + return FX_DEFAULT_EFFORT; + }); + +/** fx docs: wait for the prompt response even after cancel. */ +export const makeFxAcpRuntime = ( + input: FxAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const { childProcessSpawner, fxSettings, environment, cwd, ...runtimeOptions } = input; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...runtimeOptions, + cwd, + spawn: buildFxAcpSpawnInput(fxSettings, cwd, environment), + cancelBehavior: "wait-for-prompt", + ownDetachedProcessGroup: true, + }).pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts new file mode 100644 index 000000000000..52f50861fa5e --- /dev/null +++ b/packages/provider-fx/src/server/adapter.ts @@ -0,0 +1,206 @@ +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { type OrchestrationV2ProviderCapabilities } from "@t3tools/contracts"; +import { FxSettings } from "../settings.ts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/process"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; +import { + applyFxEffortSelection, + applyFxModelSelection, + FX_DRIVER_KIND, + FX_EFFORT_CONFIG_ID, + fxPermissionDisposition, + fxSessionModeForPolicy, + makeFxAcpRuntime, + withFxSessionModeAlwaysWritten, +} from "./acpSupport.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2Flavor, + type AcpAdapterV2RuntimeInput, +} from "@t3tools/provider-acp/server/adapter"; + +export { FX_DRIVER_KIND } from "./acpSupport.ts"; + +const DEFAULT_FX_SETTINGS = Schema.decodeSync(FxSettings)({}); + +export const FxProviderCapabilitiesV2 = { + ...AcpProviderCapabilitiesV2, + sessions: { + ...AcpProviderCapabilitiesV2.sessions, + supportsModelSwitchInSession: true, + supportsRuntimeModeSwitchInSession: false, + }, + threads: { + ...AcpProviderCapabilitiesV2.threads, + canReadThreadSnapshot: true, + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + }, + tools: { ...AcpProviderCapabilitiesV2.tools, supportsMcpTools: true }, + checkpointing: { + ...AcpProviderCapabilitiesV2.checkpointing, + providerCanReadConversationSnapshot: true, + }, + turns: { + ...AcpProviderCapabilitiesV2.turns, + supportsActiveSteering: false, + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface FxAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: FxSettings; + readonly environment: NodeJS.ProcessEnv; + readonly selfInvocation: SelfInvocation; + readonly currentFxDefaultModel: Effect.Effect; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + readonly continuationRequests?: Parameters[0]["continuationRequests"]; + readonly testHooks?: Parameters[0]["testHooks"]; + readonly makeRuntime?: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope + >; + readonly assertComplete?: Effect.Effect; +} + +function makeFxAcpAdapterFlavor( + options: FxAdapterV2Options & { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + }, +): AcpAdapterV2Flavor { + return { + driver: FX_DRIVER_KIND, + runtimeHarness: "fx", + capabilities: FxProviderCapabilitiesV2, + makeRuntime: (input: AcpAdapterV2RuntimeInput) => + ( + options.makeRuntime ?? + (({ runtimePolicy: _runtimePolicy, ...runtimeInput }: AcpAdapterV2RuntimeInput) => + makeFxAcpRuntime({ + ...runtimeInput, + fxSettings: options.settings, + environment: options.environment, + childProcessSpawner: options.childProcessSpawner, + })) + )(input).pipe(Effect.map(withFxSessionModeAlwaysWritten)), + applyModelSelection: ({ runtime, modelSelection }) => + Effect.gen(function* () { + const fxDefaultModel = yield* options.currentFxDefaultModel; + const applied = yield* applyFxModelSelection({ + runtime, + requestedModel: modelSelection.model, + fxDefaultModel, + }); + yield* applyFxEffortSelection({ + runtime, + requestedEffort: modelSelection.options?.find( + (option) => option.id === FX_EFFORT_CONFIG_ID, + )?.value, + }); + return applied; + }), + sessionModeForPolicy: fxSessionModeForPolicy, + permissionDisposition: fxPermissionDisposition, + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), + }; +} + +export type FxAdapterV2 = ProviderAdapter.ProviderAdapterV2Shape & { + readonly currentFxDefaultModel: Effect.Effect; +}; + +export const makeFxAdapterV2 = Effect.fn("makeFxAdapterV2")(function* ( + options: FxAdapterV2Options, +) { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const adapter = yield* makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: makeFxAcpAdapterFlavor({ ...options, childProcessSpawner }), + selfInvocation: options.selfInvocation, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + ...(options.continuationRequests === undefined + ? {} + : { continuationRequests: options.continuationRequests }), + ...(options.testHooks === undefined ? {} : { testHooks: options.testHooks }), + }); + return { + ...adapter, + currentFxDefaultModel: options.currentFxDefaultModel, + } satisfies FxAdapterV2; +}); + +export type FxAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | IdAllocator.IdAllocatorV2 + | Path.Path + | ProviderEventLoggers.ProviderEventLoggers + | ProviderHost.ProviderHost; + +export const FxAdapterV2Driver: ProviderAdapterDriver = { + driverKind: FX_DRIVER_KIND, + configSchema: FxSettings, + defaultConfig: (): FxSettings => DEFAULT_FX_SETTINGS, + create: Effect.fn("FxAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const selfInvocation = yield* resolveSelfInvocation(); + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return yield* makeFxAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + selfInvocation, + currentFxDefaultModel: Effect.succeed(undefined), + continuationRequests, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: FX_DRIVER_KIND, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: FX_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create the fx ACP adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/packages/provider-fx/src/server/driver.test.ts b/packages/provider-fx/src/server/driver.test.ts new file mode 100644 index 000000000000..c25b1419f952 --- /dev/null +++ b/packages/provider-fx/src/server/driver.test.ts @@ -0,0 +1,145 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/http"; + +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { type FxAdapterV2 } from "./adapter.ts"; +import { FxDriver } from "./driver.ts"; + +const fixturesDir = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const SIGNED_IN_STATUS = NodeFS.readFileSync( + NodePath.join(fixturesDir, "fx-status-signed-in.json"), + "utf8", +); +const MODELS_JSON = NodeFS.readFileSync(NodePath.join(fixturesDir, "fx-models.json"), "utf8"); + +const testLayer = layerTestProviderHost({ runBackgroundWork: false }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(IdAllocator.layer), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("fx driver tests must not make an HTTP request")), + ), + ), +); + +it.layer(testLayer)("FxDriver", (it) => { + it.effect("disables text generation and feeds the probed default model to the adapter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-driver-" }); + const binaryPath = writeFakeCli({ + directory: dir, + name: "fx", + source: [ + "const args = process.argv.slice(2);", + 'if (args[0] === "--version") {', + ' process.stdout.write("fx 0.0.13\\n");', + " process.exit(0);", + "}", + 'if (args[0] === "status" && args[1] === "--json") {', + ` process.stdout.write(${JSON.stringify(SIGNED_IN_STATUS)});`, + " process.exit(0);", + "}", + 'if (args[0] === "models" && args[1] === "--json") {', + ` process.stdout.write(${JSON.stringify(MODELS_JSON)});`, + " process.exit(0);", + "}", + "process.exit(3);", + "", + ].join("\n"), + }); + + const instance = yield* FxDriver.create({ + instanceId: ProviderInstanceId.make("fx-probe"), + displayName: "fx test", + enabled: true, + environment: [], + config: { ...FxDriver.defaultConfig(), enabled: true, binaryPath }, + }); + + const snapshot = yield* instance.snapshot.refresh; + expect(snapshot.supportsTextGeneration).toBe(false); + expect(yield* (instance.orchestrationAdapter as FxAdapterV2).currentFxDefaultModel).toBe( + "anthropic/claude-opus-5.5", + ); + }).pipe(Effect.scoped), + ); + + it.effect("lists discovered skills on the workspace snapshot", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-driver-skills-" }); + const home = path.join(dir, "home"); + const workspace = path.join(home, "project"); + const skillDir = path.join(workspace, ".fx", "skills", "review"); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString( + path.join(skillDir, "SKILL.md"), + ["---", "name: review", "description: Review the diff.", "---"].join("\n"), + ); + const binaryPath = writeFakeCli({ + directory: dir, + name: "fx", + source: [ + "const args = process.argv.slice(2);", + 'if (args[0] === "--version") {', + ' process.stdout.write("fx 0.0.13\\n");', + " process.exit(0);", + "}", + 'if (args[0] === "status" && args[1] === "--json") {', + ` process.stdout.write(${JSON.stringify(SIGNED_IN_STATUS)});`, + " process.exit(0);", + "}", + 'if (args[0] === "models" && args[1] === "--json") {', + ` process.stdout.write(${JSON.stringify(MODELS_JSON)});`, + " process.exit(0);", + "}", + "process.exit(3);", + "", + ].join("\n"), + }); + + const instance = yield* FxDriver.create({ + instanceId: ProviderInstanceId.make("fx-skills"), + displayName: "fx test", + enabled: true, + environment: [{ name: "HOME", value: home, sensitive: false }], + config: { ...FxDriver.defaultConfig(), enabled: true, binaryPath }, + }); + + expect(instance.snapshotForCwd).toBeDefined(); + const snapshot = yield* instance.snapshotForCwd!(workspace); + expect(snapshot.skills).toEqual([ + { + name: "review", + path: path.join(skillDir, "SKILL.md"), + scope: "project", + enabled: true, + description: "Review the diff.", + }, + ]); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/provider-fx/src/server/driver.ts b/packages/provider-fx/src/server/driver.ts new file mode 100644 index 000000000000..2c94cb64f343 --- /dev/null +++ b/packages/provider-fx/src/server/driver.ts @@ -0,0 +1,230 @@ +import { FxSettings } from "../settings.ts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { makeUnsupportedFxTextGeneration } from "./textGeneration.ts"; +import { type FxAdapterV2DriverEnv, makeFxAdapterV2 } from "./adapter.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { + buildInitialFxProviderSnapshot, + checkFxProviderStatus, + enrichFxSnapshot, +} from "./status.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { discoverFxSkills } from "./skills.ts"; +import { + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; +import { FX_DRIVER_KIND } from "./acpSupport.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; + +const decodeFxSettings = Schema.decodeSync(FxSettings); + +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (context) => + Effect.succeed( + context + ? makeProviderMaintenanceCapabilities({ + provider: FX_DRIVER_KIND, + packageName: null, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["upgrade"], + updateLockKey: "fx", + platform: context.platform, + env: context.env, + }) + : makeManualOnlyProviderMaintenanceCapabilities({ + provider: FX_DRIVER_KIND, + packageName: null, + }), + ), +}; + +export type FxDriverEnv = + | FxAdapterV2DriverEnv + | ProviderHost.ProviderHost + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers.ProviderEventLoggers; + +export const FxDriver: ProviderDriver = { + driverKind: FX_DRIVER_KIND, + metadata: { + displayName: "fx", + supportsMultipleInstances: true, + }, + configSchema: FxSettings, + defaultConfig: (): FxSettings => decodeFxSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* ProviderHost.ProviderHost; + const hostEnvironment = yield* HostProcessEnvironment; + const selfInvocation = yield* resolveSelfInvocation(); + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: FX_DRIVER_KIND, + instanceId, + }); + const stampIdentity = (draft: ServerProviderDraft) => ({ + ...withInstanceIdentity({ + instanceId, + driverKind: FX_DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + })(draft), + supportsTextGeneration: false, + }); + const effectiveConfig = { ...config, enabled } satisfies FxSettings; + const currentFxDefaultModelRef = yield* Ref.make(undefined); + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const orchestrationAdapter = yield* makeFxAdapterV2({ + instanceId, + settings: effectiveConfig, + environment: mergeProviderInstanceEnvironment(environment, hostEnvironment), + selfInvocation, + currentFxDefaultModel: Ref.get(currentFxDefaultModelRef), + continuationRequests, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: FX_DRIVER_KIND, + threadId, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: FX_DRIVER_KIND, + instanceId, + detail: "Failed to build the fx orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = makeUnsupportedFxTextGeneration(); + + const provideSkillDiscovery = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const checkProvider = checkFxProviderStatus(effectiveConfig, processEnv).pipe( + Effect.tap((probe) => Ref.set(currentFxDefaultModelRef, probe.defaultModel)), + Effect.bindTo("probe"), + Effect.bind("skills", () => + effectiveConfig.enabled + ? provideSkillDiscovery(discoverFxSkills({ environment: processEnv })) + : Effect.succeed([]), + ), + Effect.map(({ probe, skills }) => stampIdentity({ ...probe.draft, skills })), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialFxProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichFxSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + ), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: FX_DRIVER_KIND, + instanceId, + detail: "Failed to build the fx provider snapshot.", + cause, + }), + ), + ); + + const snapshotForCwd = (workspaceCwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + provideSkillDiscovery( + discoverFxSkills({ cwd: workspaceCwd, environment: processEnv }), + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))); + + return { + instanceId, + driverKind: FX_DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-fx/src/server/fx-models.json b/packages/provider-fx/src/server/fx-models.json new file mode 100644 index 000000000000..f9c36916009a --- /dev/null +++ b/packages/provider-fx/src/server/fx-models.json @@ -0,0 +1,12 @@ +{ + "kind": "models", + "count": 4, + "shown_count": 4, + "more_count": 0, + "ids": [ + "anthropic/claude-sonnet-5.5", + "anthropic/claude-opus-5.5", + "openai/gpt-5.6-sol", + "spacexai/grok-4.7" + ] +} diff --git a/packages/provider-fx/src/server/fx-session-config-options.json b/packages/provider-fx/src/server/fx-session-config-options.json new file mode 100644 index 000000000000..c0a9d7b6e0d4 --- /dev/null +++ b/packages/provider-fx/src/server/fx-session-config-options.json @@ -0,0 +1,61 @@ +[ + { + "id": "provider", + "name": "Provider", + "category": "model", + "type": "select", + "currentValue": "gateway", + "options": [ + { "value": "gateway", "name": "Vercel AI Gateway" }, + { "value": "codex", "name": "Codex subscription" }, + { "value": "grok", "name": "Grok subscription" } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "anthropic/claude-opus-5.5", + "options": [ + { "value": "anthropic/claude-sonnet-5.5", "name": "anthropic/claude-sonnet-5.5" }, + { "value": "anthropic/claude-opus-5.5", "name": "anthropic/claude-opus-5.5" }, + { "value": "anthropic/claude-opus-5.5-fast", "name": "anthropic/claude-opus-5.5-fast" }, + { "value": "openai/gpt-5.6-sol", "name": "openai/gpt-5.6-sol" } + ] + }, + { + "id": "mode", + "name": "Session Mode", + "category": "mode", + "type": "select", + "currentValue": "ask", + "options": [ + { + "value": "code", + "name": "Code", + "description": "Write and modify code with full tool access" + }, + { + "value": "ask", + "name": "Ask", + "description": "Request permission before making any changes" + } + ] + }, + { + "id": "effort", + "name": "Reasoning Effort", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { "value": "auto", "name": "default" }, + { "value": "low", "name": "low" }, + { "value": "medium", "name": "medium" }, + { "value": "high", "name": "high" }, + { "value": "xhigh", "name": "xhigh" }, + { "value": "max", "name": "max" } + ] + } +] diff --git a/packages/provider-fx/src/server/fx-status-signed-in.json b/packages/provider-fx/src/server/fx-status-signed-in.json new file mode 100644 index 000000000000..ced8766509a0 --- /dev/null +++ b/packages/provider-fx/src/server/fx-status-signed-in.json @@ -0,0 +1,6 @@ +{ + "kind": "status", + "model": "anthropic/claude-opus-5.5", + "auth": "fx login", + "auth_refreshable": true +} diff --git a/packages/provider-fx/src/server/fx-status-signed-out.json b/packages/provider-fx/src/server/fx-status-signed-out.json new file mode 100644 index 000000000000..136f0d8750a5 --- /dev/null +++ b/packages/provider-fx/src/server/fx-status-signed-out.json @@ -0,0 +1,7 @@ +{ + "kind": "status", + "model": "spacexai/grok-4.7", + "auth": "missing", + "auth_refreshable": false, + "auth_help": "fx needs access to Vercel AI Gateway. Run fx login to sign in, fx setup to use an API key, or set AI_GATEWAY_API_KEY." +} diff --git a/packages/provider-fx/src/server/skills.test.ts b/packages/provider-fx/src/server/skills.test.ts new file mode 100644 index 000000000000..e90a47da20cb --- /dev/null +++ b/packages/provider-fx/src/server/skills.test.ts @@ -0,0 +1,277 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverFxSkills } from "./skills.ts"; + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + contents: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); +}); + +it.layer(NodeServices.layer)("discoverFxSkills", (it) => { + it.effect("finds workspace and user skills", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + const workspace = path.join(home, "project"); + yield* writeSkill( + path.join(workspace, ".fx", "skills"), + "review", + ["---", "name: review", "description: Review the diff.", "---", "", "# Review"].join("\n"), + ); + yield* writeSkill( + path.join(home, ".fx", "skills"), + "deploy", + ["---", "name: deploy", "description: Deploy the app.", "---"].join("\n"), + ); + + const skills = yield* discoverFxSkills({ + cwd: workspace, + environment: { HOME: home }, + }); + + expect(skills).toEqual([ + { + name: "review", + path: path.join(workspace, ".fx", "skills", "review", "SKILL.md"), + scope: "project", + enabled: true, + description: "Review the diff.", + }, + { + name: "deploy", + path: path.join(home, ".fx", "skills", "deploy", "SKILL.md"), + scope: "user", + enabled: true, + description: "Deploy the app.", + }, + ]); + }).pipe(Effect.scoped), + ); + + it.effect("stops the upward walk before HOME", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + const workspace = path.join(home, "project"); + yield* writeSkill( + path.join(home, "skills"), + "home-workspace-skill", + ["---", "name: home-workspace-skill", "description: Should not load.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(home, ".fx", "skills"), + "user-skill", + ["---", "name: user-skill", "description: User skill.", "---"].join("\n"), + ); + + const skills = yield* discoverFxSkills({ + cwd: workspace, + environment: { HOME: home }, + }); + + expect(skills.map((skill) => skill.name)).toEqual(["user-skill"]); + expect(skills[0]?.scope).toBe("user"); + }).pipe(Effect.scoped), + ); + + it.effect("uses the header name over the directory name", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + yield* writeSkill( + path.join(home, ".fx", "skills"), + "directory-name", + ["---", "name: header-name", "description: Named in YAML.", "---"].join("\n"), + ); + + const skills = yield* discoverFxSkills({ environment: { HOME: home } }); + + expect(skills).toEqual([ + { + name: "header-name", + path: path.join(home, ".fx", "skills", "directory-name", "SKILL.md"), + scope: "user", + enabled: true, + description: "Named in YAML.", + }, + ]); + }).pipe(Effect.scoped), + ); + + it.effect("uses the directory name when SKILL.md has no header", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + yield* writeSkill(path.join(home, ".fx", "skills"), "plain-skill", "# No frontmatter\n"); + + const skills = yield* discoverFxSkills({ environment: { HOME: home } }); + + expect(skills).toEqual([ + { + name: "plain-skill", + path: path.join(home, ".fx", "skills", "plain-skill", "SKILL.md"), + scope: "user", + enabled: true, + }, + ]); + }).pipe(Effect.scoped), + ); + + it.effect("reads a skill whose directory name has surrounding spaces", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + yield* writeSkill(path.join(home, ".fx", "skills"), "spaced-skill ", "# No frontmatter\n"); + + const skills = yield* discoverFxSkills({ environment: { HOME: home } }); + + expect(skills).toEqual([ + { + name: "spaced-skill", + path: path.join(home, ".fx", "skills", "spaced-skill ", "SKILL.md"), + scope: "user", + enabled: true, + }, + ]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps the nearer skill when names collide", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + const workspace = path.join(home, "project"); + yield* writeSkill( + path.join(workspace, ".fx", "skills"), + "shared", + ["---", "name: shared", "description: Workspace copy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(home, ".fx", "skills"), + "shared", + ["---", "name: shared", "description: User copy.", "---"].join("\n"), + ); + + const skills = yield* discoverFxSkills({ + cwd: workspace, + environment: { HOME: home }, + }); + + expect(skills).toEqual([ + { + name: "shared", + path: path.join(workspace, ".fx", "skills", "shared", "SKILL.md"), + scope: "project", + enabled: true, + description: "Workspace copy.", + }, + ]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores a missing root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + const workspace = path.join(home, "project"); + yield* fs.makeDirectory(workspace, { recursive: true }); + + const skills = yield* discoverFxSkills({ + cwd: workspace, + environment: { HOME: home }, + }); + + expect(skills).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores stray files in a skills root and still lists real skills", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + const skillsRoot = path.join(home, ".fx", "skills"); + yield* fs.makeDirectory(skillsRoot, { recursive: true }); + yield* fs.writeFileString(path.join(skillsRoot, ".DS_Store"), "not a skill"); + yield* fs.writeFileString(path.join(skillsRoot, "README.md"), "# skills"); + yield* writeSkill( + skillsRoot, + "review", + ["---", "name: review", "description: Review the diff.", "---"].join("\n"), + ); + + const skills = yield* discoverFxSkills({ environment: { HOME: home } }); + + expect(skills.map((skill) => skill.name)).toEqual(["review"]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores a workspace skills path that is a file and still lists user skills", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + const workspace = path.join(home, "project"); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString(path.join(workspace, "skills"), "not a directory"); + yield* writeSkill( + path.join(home, ".fx", "skills"), + "deploy", + ["---", "name: deploy", "description: Deploy the app.", "---"].join("\n"), + ); + + const skills = yield* discoverFxSkills({ + cwd: workspace, + environment: { HOME: home }, + }); + + expect(skills.map((skill) => skill.name)).toEqual(["deploy"]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores a skill directory without SKILL.md", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-skills-" }); + const home = path.join(tempDir, "home"); + yield* fs.makeDirectory(path.join(home, ".fx", "skills", "empty"), { recursive: true }); + yield* writeSkill( + path.join(home, ".fx", "skills"), + "review", + ["---", "name: review", "description: Review the diff.", "---"].join("\n"), + ); + + const skills = yield* discoverFxSkills({ environment: { HOME: home } }); + + expect(skills.map((skill) => skill.name)).toEqual(["review"]); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/provider-fx/src/server/skills.ts b/packages/provider-fx/src/server/skills.ts new file mode 100644 index 000000000000..1149efe43c16 --- /dev/null +++ b/packages/provider-fx/src/server/skills.ts @@ -0,0 +1,186 @@ +/** + * Discover fx skills for the `$` picker by scanning SKILL.md directories. + * + * fx has no skills CLI. Roots are workspace nearest-first, then user roots. + * When two skills share a name, the first root in that order wins. fx's docs + * do not define precedence; this is that choice. One bad root or entry is + * skipped so it cannot empty the catalog. + * + * @module provider-fx/server/skills + */ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import { parse as parseYamlDocument } from "yaml"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const MAX_SKILL_BYTES = 1_000_000; + +const WORKSPACE_SKILL_RELATIVE_ROOTS = [ + ".fx/skills", + "skills", + ".opencode/skills", + ".codex/skills", + ".claude/skills", + ".agents/skills", + ".claw/skills", +] as const; + +const USER_SKILL_RELATIVE_ROOTS = [ + ".fx/skills", + ".config/opencode/skills", + ".codex/skills", + ".claude/skills", + ".agents/skills", + ".claw/skills", +] as const; + +function parseFxSkillFrontmatter(contents: string): { + readonly name?: string; + readonly description?: string; +} { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return {}; + } + try { + const parsed = parseYamlDocument(match[1] ?? ""); + if (typeof parsed !== "object" || parsed === null) { + return {}; + } + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; + } catch { + return {}; + } +} + +function resolveFxUserHome(environment: NodeJS.ProcessEnv): string { + const home = environment.HOME?.trim(); + return home && home.length > 0 ? home : NodeOS.homedir(); +} + +function workspaceSkillDirectories( + path: Path.Path, + workspaceCwd: string, + home: string, +): ReadonlyArray { + const directories: Array = []; + const homePath = path.resolve(home); + let current = path.resolve(workspaceCwd); + while (current !== homePath) { + for (const relative of WORKSPACE_SKILL_RELATIVE_ROOTS) { + directories.push(path.join(current, relative)); + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return directories; +} + +function userSkillDirectories(path: Path.Path, home: string): ReadonlyArray { + return USER_SKILL_RELATIVE_ROOTS.map((relative) => path.join(home, relative)); +} + +const skipPath = ( + path: string, + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(undefined) + : Effect.logWarning("fx skill scan skipped a path", { path }).pipe(Effect.as(undefined)), + }), + ); + +const discoverSkillsInRoot = Effect.fn("discoverFxSkillsInRoot")(function* (input: { + readonly directory: string; + readonly scope: "project" | "user"; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootInfo = yield* skipPath(input.directory, fileSystem.stat(input.directory)); + if (rootInfo?.type !== "Directory") { + return []; + } + const entries = yield* skipPath(input.directory, fileSystem.readDirectory(input.directory)); + if (entries === undefined) { + return []; + } + const skills: Array = []; + for (const entry of entries) { + const directoryName = entry.trim(); + if (!directoryName) { + continue; + } + const candidate = path.join(input.directory, entry); + const candidateInfo = yield* skipPath(candidate, fileSystem.stat(candidate)); + if (candidateInfo?.type !== "Directory") { + continue; + } + const skillPath = path.join(candidate, "SKILL.md"); + const skillInfo = yield* skipPath(skillPath, fileSystem.stat(skillPath)); + if (skillInfo?.type !== "File" || skillInfo.size > MAX_SKILL_BYTES) { + continue; + } + const contents = yield* skipPath(skillPath, fileSystem.readFileString(skillPath)); + if (contents === undefined) { + continue; + } + const frontmatter = parseFxSkillFrontmatter(contents); + const name = frontmatter.name ?? directoryName; + skills.push({ + name, + path: skillPath, + scope: input.scope, + enabled: true, + ...(frontmatter.description ? { description: frontmatter.description } : {}), + }); + } + return skills; +}); + +export const discoverFxSkills = Effect.fn("discoverFxSkills")(function* (input: { + readonly cwd?: string; + readonly environment?: NodeJS.ProcessEnv; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const environment = input.environment ?? process.env; + const home = path.resolve(resolveFxUserHome(environment)); + const roots: Array<{ directory: string; scope: "project" | "user" }> = [ + ...(input.cwd === undefined + ? [] + : workspaceSkillDirectories(path, input.cwd, home).map((directory) => ({ + directory, + scope: "project" as const, + }))), + ...userSkillDirectories(path, home).map((directory) => ({ + directory, + scope: "user" as const, + })), + ]; + const skillsByName = new Map(); + for (const root of roots) { + const skills = yield* discoverSkillsInRoot(root); + for (const skill of skills) { + if (!skillsByName.has(skill.name)) { + skillsByName.set(skill.name, skill); + } + } + } + return [...skillsByName.values()]; +}); diff --git a/packages/provider-fx/src/server/status.test.ts b/packages/provider-fx/src/server/status.test.ts new file mode 100644 index 000000000000..48bdc37f86c6 --- /dev/null +++ b/packages/provider-fx/src/server/status.test.ts @@ -0,0 +1,276 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { FxSettings } from "../settings.ts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; + +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; +import { + buildFxModels, + checkFxProviderStatus, + fxLoginToProviderAuth, + parseFxModelsJson, + parseFxStatusJson, +} from "./status.ts"; + +const decodeFxSettings = Schema.decodeSync(FxSettings); +const fixturesDir = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const SIGNED_IN_STATUS = NodeFS.readFileSync( + NodePath.join(fixturesDir, "fx-status-signed-in.json"), + "utf8", +); +const SIGNED_OUT_STATUS = NodeFS.readFileSync( + NodePath.join(fixturesDir, "fx-status-signed-out.json"), + "utf8", +); +const MODELS_JSON = NodeFS.readFileSync(NodePath.join(fixturesDir, "fx-models.json"), "utf8"); + +describe("parseFxStatusJson", () => { + it("maps a signed-in capture to SignedIn and the default model", () => { + expect(parseFxStatusJson(SIGNED_IN_STATUS)).toEqual({ + login: { _tag: "SignedIn", method: "fx login" }, + defaultModel: "anthropic/claude-opus-5.5", + }); + }); + + it("maps a signed-out capture to SignedOut with auth_help", () => { + expect(parseFxStatusJson(SIGNED_OUT_STATUS)).toEqual({ + login: { + _tag: "SignedOut", + help: "fx needs access to Vercel AI Gateway. Run fx login to sign in, fx setup to use an API key, or set AI_GATEWAY_API_KEY.", + }, + defaultModel: "spacexai/grok-4.7", + }); + }); + + it("falls back when signed-out status omits auth_help", () => { + expect(parseFxStatusJson(JSON.stringify({ auth: "missing" }))).toEqual({ + login: { _tag: "SignedOut", help: "Run `fx login` to sign in." }, + defaultModel: undefined, + }); + }); + + it("maps undecodable output to Unknown", () => { + expect(parseFxStatusJson("not json")).toEqual({ + login: { _tag: "Unknown" }, + defaultModel: undefined, + }); + expect(parseFxStatusJson("{}")).toEqual({ + login: { _tag: "Unknown" }, + defaultModel: undefined, + }); + }); +}); + +describe("fxLoginToProviderAuth", () => { + it("maps each login tag onto provider auth", () => { + expect(fxLoginToProviderAuth({ _tag: "SignedIn", method: "fx login" })).toEqual({ + status: "authenticated", + label: "fx login", + }); + expect(fxLoginToProviderAuth({ _tag: "SignedOut", help: "run fx login" })).toEqual({ + status: "unauthenticated", + }); + expect(fxLoginToProviderAuth({ _tag: "Unknown" })).toEqual({ status: "unknown" }); + }); +}); + +describe("parseFxModelsJson", () => { + it("reads catalog ids from a capture", () => { + expect(parseFxModelsJson(MODELS_JSON)).toEqual([ + "anthropic/claude-sonnet-5.5", + "anthropic/claude-opus-5.5", + "openai/gpt-5.6-sol", + "spacexai/grok-4.7", + ]); + }); + + it("returns an empty list on decode failure", () => { + expect(parseFxModelsJson("not json")).toEqual([]); + expect(parseFxModelsJson("{}")).toEqual([]); + }); +}); + +describe("buildFxModels", () => { + it("puts Default first, then catalog ids, then custom models", () => { + const models = buildFxModels({ + ids: ["anthropic/claude-opus-5.5", "openai/gpt-5.6-sol"], + defaultModel: "anthropic/claude-opus-5.5", + customModels: ["my-fx-model"], + }); + expect( + models.map((model) => [model.slug, model.name, model.isCustom, model.isDefault ?? false]), + ).toEqual([ + ["default", "Default (anthropic/claude-opus-5.5)", false, true], + ["anthropic/claude-opus-5.5", "anthropic/claude-opus-5.5", false, false], + ["openai/gpt-5.6-sol", "openai/gpt-5.6-sol", false, false], + ["my-fx-model", "my-fx-model", true, false], + ]); + expect(models[0]?.capabilities?.optionDescriptors?.map((option) => option.id)).toEqual([ + "effort", + ]); + }); +}); + +it.layer(NodeServices.layer)("checkFxProviderStatus", (it) => { + const writeFakeFxCli = (input: { + readonly argvLogPath?: string; + readonly versionExit?: number; + readonly statusJson?: string; + readonly modelsJson?: string; + readonly modelsExit?: number; + }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-fx-probe-" }); + return writeFakeCli({ + directory: dir, + name: "fx", + source: [ + 'import { appendFileSync } from "node:fs";', + "const args = process.argv.slice(2);", + ...(input.argvLogPath === undefined + ? [] + : [`appendFileSync(${JSON.stringify(input.argvLogPath)}, args.join(" ") + "\\n");`]), + 'if (args[0] === "--version") {', + ' process.stdout.write("fx 0.0.13\\n");', + ` process.exit(${input.versionExit ?? 0});`, + "}", + 'if (args[0] === "status" && args[1] === "--json") {', + ` process.stdout.write(${JSON.stringify(input.statusJson ?? SIGNED_IN_STATUS)});`, + " process.exit(0);", + "}", + 'if (args[0] === "models" && args[1] === "--json") {', + ` process.stdout.write(${JSON.stringify(input.modelsJson ?? MODELS_JSON)});`, + ` process.exit(${input.modelsExit ?? 0});`, + "}", + "process.exit(3);", + "", + ].join("\n"), + }); + }); + + it.effect("reports a non-zero version exit as installed but failed after one spawn", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-fx-version-fail-" }); + const argvLogPath = NodePath.join(dir, "argv.log"); + const probe = yield* checkFxProviderStatus( + decodeFxSettings({ + enabled: true, + binaryPath: writeFakeCli({ + directory: dir, + name: "fx", + source: [ + 'import { appendFileSync } from "node:fs";', + `appendFileSync(${JSON.stringify(argvLogPath)}, process.argv.slice(2).join(" ") + "\\n");`, + "process.exit(1);", + "", + ].join("\n"), + }), + }), + ); + expect(probe.draft.installed).toBe(true); + expect(probe.draft.status).toBe("error"); + expect(probe.draft.message).toBe("fx is installed but failed to run."); + expect(probe.defaultModel).toBeUndefined(); + expect(NodeFS.readFileSync(argvLogPath, "utf8")).toBe("--version\n"); + }).pipe(Effect.scoped), + ); + + it.effect("reports a missing binary as not installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-fx-missing-" }); + const probe = yield* checkFxProviderStatus( + decodeFxSettings({ + enabled: true, + binaryPath: NodePath.join(dir, "no-such-fx"), + }), + ); + expect(probe.draft.installed).toBe(false); + expect(probe.draft.status).toBe("error"); + expect(probe.draft.message).toBe("fx is not installed or not on PATH."); + expect(probe.defaultModel).toBeUndefined(); + }).pipe(Effect.scoped), + ); + + it.effect("maps signed-in status onto authenticated ready", () => + Effect.gen(function* () { + const probe = yield* Effect.scoped( + Effect.gen(function* () { + const fxPath = yield* writeFakeFxCli({}); + return yield* checkFxProviderStatus( + decodeFxSettings({ enabled: true, binaryPath: fxPath }), + ); + }), + ); + expect(probe.defaultModel).toBe("anthropic/claude-opus-5.5"); + expect(probe.draft.status).toBe("ready"); + expect(probe.draft.version).toBe("0.0.13"); + expect(probe.draft.auth).toEqual({ status: "authenticated", label: "fx login" }); + expect(probe.draft.models.map((model) => model.slug)).toEqual([ + "default", + "anthropic/claude-sonnet-5.5", + "anthropic/claude-opus-5.5", + "openai/gpt-5.6-sol", + "spacexai/grok-4.7", + ]); + expect(probe.draft.models[0]?.name).toBe("Default (anthropic/claude-opus-5.5)"); + }), + ); + + it.effect("maps signed-out status onto error with auth_help", () => + Effect.gen(function* () { + const probe = yield* Effect.scoped( + Effect.gen(function* () { + const fxPath = yield* writeFakeFxCli({ statusJson: SIGNED_OUT_STATUS }); + return yield* checkFxProviderStatus( + decodeFxSettings({ enabled: true, binaryPath: fxPath }), + ); + }), + ); + expect(probe.draft.status).toBe("error"); + expect(probe.draft.auth.status).toBe("unauthenticated"); + expect(probe.draft.message).toBe( + "fx needs access to Vercel AI Gateway. Run fx login to sign in, fx setup to use an API key, or set AI_GATEWAY_API_KEY.", + ); + }), + ); + + it.effect("maps undecodable status onto unknown with a warning", () => + Effect.gen(function* () { + const probe = yield* Effect.scoped( + Effect.gen(function* () { + const fxPath = yield* writeFakeFxCli({ statusJson: "not-json" }); + return yield* checkFxProviderStatus( + decodeFxSettings({ enabled: true, binaryPath: fxPath }), + ); + }), + ); + expect(probe.draft.status).toBe("warning"); + expect(probe.draft.auth.status).toBe("unknown"); + }), + ); + + it.effect("keeps Default plus custom models when models listing fails", () => + Effect.gen(function* () { + const probe = yield* Effect.scoped( + Effect.gen(function* () { + const fxPath = yield* writeFakeFxCli({ modelsExit: 3 }); + return yield* checkFxProviderStatus( + decodeFxSettings({ enabled: true, binaryPath: fxPath, customModels: ["my-fx-model"] }), + ); + }), + ); + expect(probe.draft.status).toBe("warning"); + expect(probe.draft.models.map((model) => model.slug)).toEqual(["default", "my-fx-model"]); + }), + ); +}); diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts new file mode 100644 index 000000000000..1a505fe76f75 --- /dev/null +++ b/packages/provider-fx/src/server/status.ts @@ -0,0 +1,433 @@ +import { + type CustomModelSetting, + type ServerProvider, + type ServerProviderAuth, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type { FxSettings } from "../settings.ts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { + FX_DEFAULT_MODEL_SLUG, + FX_EFFORT_OPTION_DESCRIPTOR, + FX_SUPPORTED_RUNTIME_MODES, +} from "./acpSupport.ts"; +import { + AUTH_PROBE_TIMEOUT_MS, + buildServerProvider, + isCommandMissingCause, + nonEmptyTrimmed, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "@t3tools/provider-core/server/maintenanceResolver"; + +const FX_PRESENTATION = { + displayName: "fx", + showInteractionModeToggle: false, + supportedRuntimeModes: FX_SUPPORTED_RUNTIME_MODES, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const FX_MODEL_CAPABILITIES = createModelCapabilities({ + optionDescriptors: [FX_EFFORT_OPTION_DESCRIPTOR], +}); + +export type FxLogin = + | { readonly _tag: "SignedIn"; readonly method: string } + | { readonly _tag: "SignedOut"; readonly help: string } + | { readonly _tag: "Unknown" }; + +export interface FxStatus { + readonly login: FxLogin; + readonly defaultModel: string | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyUnknownString(value: unknown): string | undefined { + return typeof value === "string" ? nonEmptyTrimmed(value) : undefined; +} + +export interface FxProviderStatus { + readonly draft: ServerProviderDraft; + readonly defaultModel: string | undefined; +} + +const SIGNED_OUT_HELP = "Run `fx login` to sign in."; + +const FxStatusWire = Schema.Struct({ + auth: Schema.optional(Schema.String), + auth_refreshable: Schema.optional(Schema.Boolean), + auth_help: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), +}); +const decodeFxStatusWire = Schema.decodeUnknownOption(Schema.fromJsonString(FxStatusWire)); + +export function parseFxStatusJson(stdout: string): FxStatus { + const decoded = decodeFxStatusWire(stdout); + if (Option.isNone(decoded)) { + return { login: { _tag: "Unknown" }, defaultModel: undefined }; + } + const defaultModel = nonEmptyTrimmed(decoded.value.model); + const auth = nonEmptyTrimmed(decoded.value.auth); + if (auth === "missing") { + return { + login: { + _tag: "SignedOut", + help: nonEmptyTrimmed(decoded.value.auth_help) ?? SIGNED_OUT_HELP, + }, + defaultModel, + }; + } + if (auth !== undefined) { + return { login: { _tag: "SignedIn", method: auth }, defaultModel }; + } + return { login: { _tag: "Unknown" }, defaultModel }; +} + +export function fxLoginToProviderAuth(login: FxLogin): ServerProviderAuth { + switch (login._tag) { + case "SignedIn": + return { status: "authenticated", label: login.method }; + case "SignedOut": + return { status: "unauthenticated" }; + case "Unknown": + return { status: "unknown" }; + } +} + +export function parseFxModelsJson(stdout: string): ReadonlyArray { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return []; + } + if (!isRecord(parsed) || !Array.isArray(parsed.ids)) { + return []; + } + return parsed.ids.flatMap((id) => { + const slug = nonEmptyUnknownString(id); + return slug === undefined ? [] : [slug]; + }); +} + +export function buildFxModels(input: { + readonly ids: ReadonlyArray; + readonly defaultModel: string | undefined; + readonly customModels: ReadonlyArray; +}): ReadonlyArray { + const defaultEntry: ServerProviderModel = { + slug: FX_DEFAULT_MODEL_SLUG, + name: input.defaultModel ? `Default (${input.defaultModel})` : "Default", + isCustom: false, + isDefault: true, + capabilities: FX_MODEL_CAPABILITIES, + }; + const seen = new Set([FX_DEFAULT_MODEL_SLUG]); + const catalog: Array = []; + for (const id of input.ids) { + if (seen.has(id)) continue; + seen.add(id); + catalog.push({ + slug: id, + name: id, + isCustom: false, + capabilities: FX_MODEL_CAPABILITIES, + }); + } + return providerModelsFromSettings( + [defaultEntry, ...catalog], + input.customModels, + FX_MODEL_CAPABILITIES, + ); +} + +const runFxCliCommand = ( + settings: FxSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "fx"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export function buildInitialFxProviderSnapshot( + settings: FxSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + if (!settings.enabled) { + return fxDisabledDraft(settings, checkedAt); + } + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: true, + checkedAt, + models: buildFxModels({ + ids: [], + defaultModel: undefined, + customModels: settings.customModels, + }), + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking fx CLI availability...", + }, + }); + }); +} + +function fxDisabledDraft(settings: FxSettings, checkedAt: string): ServerProviderDraft { + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: false, + checkedAt, + models: buildFxModels({ + ids: [], + defaultModel: undefined, + customModels: settings.customModels, + }), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "fx is disabled in T3 Code settings.", + }, + }); +} + +function fxVersionProbeDraft( + settings: FxSettings, + checkedAt: string, + probe: { + readonly installed: boolean; + readonly version: string | null; + readonly message: string; + }, +): ServerProviderDraft { + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: buildFxModels({ + ids: [], + defaultModel: undefined, + customModels: settings.customModels, + }), + probe: { + installed: probe.installed, + version: probe.version, + status: "error", + auth: { status: "unknown" }, + message: probe.message, + }, + }); +} + +function fxProbe( + draft: ServerProviderDraft, + defaultModel: string | undefined = undefined, +): FxProviderStatus { + return { draft, defaultModel }; +} + +export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function* ( + settings: FxSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + + if (!settings.enabled) { + return fxProbe(fxDisabledDraft(settings, checkedAt)); + } + + const versionResult = yield* runFxCliCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("fx CLI health check failed.", { errorTag: error._tag }); + const missing = isCommandMissingCause(error); + return fxProbe( + fxVersionProbeDraft(settings, checkedAt, { + installed: !missing, + version: null, + message: missing + ? "fx is not installed or not on PATH." + : "Failed to execute fx CLI health check.", + }), + ); + } + if (Option.isNone(versionResult.success)) { + yield* Effect.logWarning("fx CLI version probe timed out."); + return fxProbe( + fxVersionProbeDraft(settings, checkedAt, { + installed: true, + version: null, + message: "fx is installed but timed out while running `fx --version`.", + }), + ); + } + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("fx CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return fxProbe( + fxVersionProbeDraft(settings, checkedAt, { + installed: true, + version, + message: "fx is installed but failed to run.", + }), + ); + } + + const statusResult = yield* runFxCliCommand(settings, ["status", "--json"], environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.result, + ); + const statusOutput = + Result.isSuccess(statusResult) && + Option.isSome(statusResult.success) && + statusResult.success.value.code === 0 + ? statusResult.success.value + : undefined; + const status = statusOutput + ? parseFxStatusJson(statusOutput.stdout) + : { login: { _tag: "Unknown" } as const, defaultModel: undefined }; + + const modelsResult = yield* runFxCliCommand(settings, ["models", "--json"], environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.result, + ); + const modelsOutput = + Result.isSuccess(modelsResult) && + Option.isSome(modelsResult.success) && + modelsResult.success.value.code === 0 + ? modelsResult.success.value + : undefined; + const ids = modelsOutput ? parseFxModelsJson(modelsOutput.stdout) : []; + const modelsFailed = modelsOutput === undefined; + if (modelsFailed) { + yield* Effect.logWarning("fx model listing failed or timed out."); + } + + const models = buildFxModels({ + ids, + defaultModel: status.defaultModel, + customModels: settings.customModels, + }); + const auth = fxLoginToProviderAuth(status.login); + + if (status.login._tag === "SignedOut") { + return fxProbe( + buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "error", + auth, + message: status.login.help, + }, + }), + status.defaultModel, + ); + } + + if (status.login._tag === "Unknown") { + return fxProbe( + buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "warning", + auth, + message: "fx is installed but login state could not be determined.", + }, + }), + status.defaultModel, + ); + } + + return fxProbe( + buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: modelsFailed ? "warning" : "ready", + auth, + ...(modelsFailed + ? { + message: "fx is installed but model listing failed. Model options may be incomplete.", + } + : {}), + }, + }), + status.defaultModel, + ); +}); + +export const enrichFxSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => + enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => input.publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("fx version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); diff --git a/packages/provider-fx/src/server/textGeneration.ts b/packages/provider-fx/src/server/textGeneration.ts new file mode 100644 index 000000000000..9be43baafafd --- /dev/null +++ b/packages/provider-fx/src/server/textGeneration.ts @@ -0,0 +1,20 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; + +export const makeUnsupportedFxTextGeneration = (): ProviderTextGeneration => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "fx does not provide application text generation yet.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; diff --git a/packages/provider-fx/src/settings.ts b/packages/provider-fx/src/settings.ts new file mode 100644 index 000000000000..fd94e3e57e60 --- /dev/null +++ b/packages/provider-fx/src/settings.ts @@ -0,0 +1,37 @@ +/** + * fx instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-fx/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const FxSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("fx").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the fx binary. All fx instances on this machine share its fx login.", + providerSettingsForm: { placeholder: "fx", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type FxSettings = typeof FxSettings.Type; diff --git a/packages/provider-fx/src/testing.ts b/packages/provider-fx/src/testing.ts new file mode 100644 index 000000000000..2875fc80638f --- /dev/null +++ b/packages/provider-fx/src/testing.ts @@ -0,0 +1,6 @@ +/** + * fx internals the server's replay tests drive directly. + * + * @module provider-fx/testing + */ +export { FX_DRIVER_KIND, FxProviderCapabilitiesV2, makeFxAdapterV2 } from "./server/adapter.ts"; diff --git a/packages/provider-fx/tsconfig.json b/packages/provider-fx/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-fx/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 249f45a453cc..44dee26b1292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,6 +383,9 @@ importers: '@t3tools/provider-cursor': specifier: workspace:* version: link:../../packages/provider-cursor + '@t3tools/provider-fx': + specifier: workspace:* + version: link:../../packages/provider-fx '@t3tools/provider-grok': specifier: workspace:* version: link:../../packages/provider-grok @@ -636,6 +639,9 @@ importers: '@t3tools/provider-cursor': specifier: workspace:* version: link:../../packages/provider-cursor + '@t3tools/provider-fx': + specifier: workspace:* + version: link:../../packages/provider-fx '@t3tools/provider-grok': specifier: workspace:* version: link:../../packages/provider-grok @@ -802,6 +808,9 @@ importers: '@t3tools/provider-cursor': specifier: workspace:* version: link:../../packages/provider-cursor + '@t3tools/provider-fx': + specifier: workspace:* + version: link:../../packages/provider-fx '@t3tools/provider-grok': specifier: workspace:* version: link:../../packages/provider-grok @@ -1291,6 +1300,46 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-fx: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../provider-acp + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-grok: dependencies: '@t3tools/contracts': From 9a151e488b785dac42ed620407a4f3ca3c77a2fb Mon Sep 17 00:00:00 2001 From: agadzik Date: Fri, 9 Oct 2026 15:05:03 -0400 Subject: [PATCH 02/11] docs(user): add the fx provider guide Document fx setup, permission modes, and the install table row. T3 Code does not offer Full access or Auto-accept edits for fx. --- docs/README.md | 2 +- docs/user/install.md | 5 ++-- docs/user/permission-modes.md | 6 +++-- docs/user/providers-fx.md | 47 +++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 docs/user/providers-fx.md diff --git a/docs/README.md b/docs/README.md index d7628960cb8b..3ed29639c76c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ - [Outside agents (MCP)](./user/outside-agents.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) --- diff --git a/docs/user/install.md b/docs/user/install.md index 67df94217ac3..275e49d851e8 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -144,6 +144,7 @@ computer. | Antigravity | Install and sign in with Google from T3 Code's provider settings. | | Pi | Install [Pi](https://pi.dev), then run `pi` once to finish its login or API-key setup. | | Muse Code | Install [Muse Code](https://dev.meta.ai/docs/muse-code) on the server, run `muse login`, then enable it in Settings → Providers. | +| fx | Install [fx](https://fx.sh) with `curl -fsSL https://fx.sh/setup.sh \| bash`, then run `fx login`. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. @@ -172,8 +173,8 @@ their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), -[Antigravity](./providers-antigravity.md), [Pi](./providers-pi.md), and -[Muse Code](./providers-muse.md). +[Antigravity](./providers-antigravity.md), [Pi](./providers-pi.md), +[Muse Code](./providers-muse.md), and [fx](./providers-fx.md). ## Next steps diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 1bcdeffee5c4..5620e6a69b1a 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -21,13 +21,15 @@ not prevent the agent from asking questions about the task. ## Provider differences Providers enforce permissions differently. Some read-only actions can proceed in **Supervised**. -**Auto** uses automatic review on Codex, Claude, Cursor, and Grok; providers without an equivalent, +**Auto** uses automatic review on Codex, Claude, Cursor, Grok, and fx; providers without an equivalent, including OpenCode and Antigravity, fall back to asking. On Grok, commands its review blocks come -to you for approval. +to you for approval. On fx, remaining prompts also come to you. Muse Code offers only **Supervised** and **Full access**. A Muse thread already set to another mode runs in **Supervised**. +fx offers no **Auto-accept edits** or **Full access**. An fx thread already set to either runs in **Supervised**. + Grok offers no **Auto-accept edits**. A Grok thread already set to it runs in **Supervised**. Grok file-change approvals offer **Allow all edits this session**. Its command approvals have no session-wide choice, because Grok would remember that command for the whole project. diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md new file mode 100644 index 000000000000..429d4edc7b99 --- /dev/null +++ b/docs/user/providers-fx.md @@ -0,0 +1,47 @@ +# fx + +T3 Code can run [fx](https://fx.sh). fx uses your Vercel AI Gateway login, or another backend you configure with fx itself. + +## Set up fx + +1. Install fx on the machine that runs the T3 Code server: + + ```bash + curl -fsSL https://fx.sh/setup.sh | bash + ``` + +2. Run `fx login` in a terminal on that machine. +3. Open **Settings > Providers**, enable fx, and refresh the provider. + +If `fx` is not on the server's `PATH`, set **Binary path** to the executable. + +Every fx instance on the same machine shares that machine's fx login. T3 Code does not keep a separate login per instance. + +## Models and backend + +The model picker lists **Default** first. That option keeps the model fx is already configured to run. Other ids come from `fx models`. + +fx also exposes an **Effort** control. Effort starts at Medium, and T3 Code remembers the level you pick for your next thread. T3 Code does not show fx's backend selector. To use Codex, Grok, or Vercel AI Gateway, run `fx provider` on the environment's machine. + +Type `$` in the composer to list fx skills from fx's skill folders. + +## Permission modes + +fx offers two composer modes: + +| Mode | Behavior | +| --- | --- | +| **Supervised** | fx asks before it changes files or runs tools. | +| **Auto** | fx handles routine actions itself. Anything it still asks about reaches you. | + +T3 Code does not offer **Full access**, **Auto-accept edits**, or Plan mode for fx. A thread already set to one of those runs in **Supervised**. Changing the permission mode starts a new fx session for that thread. Newer fx versions save the mode a thread uses as fx's default permission mode, so fx in the terminal starts in it. + +## Not supported yet + +- Mid-turn steering. A message sent while fx is working interrupts and restarts the turn, as on other ACP providers. +- Plan mode. +- **Full access**. +- **Auto-accept edits**. +- Commit messages, pull request text, branch names, and thread titles. Pick another provider for those. + +See [provider setup](./install.md#providers) and [Permission modes](./permission-modes.md). From 6272f074ecef95bec8e702e3f20ee974f8d0a15a Mon Sep 17 00:00:00 2001 From: agadzik Date: Fri, 9 Oct 2026 15:05:03 -0400 Subject: [PATCH 03/11] feat(marketing): add fx to the harness grid Show fx on the landing page with the other CLI harnesses and wrap long harness labels. --- apps/marketing/public/harnesses/fx-dark.svg | 1 + apps/marketing/src/pages/index.astro | 23 ++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 apps/marketing/public/harnesses/fx-dark.svg diff --git a/apps/marketing/public/harnesses/fx-dark.svg b/apps/marketing/public/harnesses/fx-dark.svg new file mode 100644 index 000000000000..f819104a251b --- /dev/null +++ b/apps/marketing/public/harnesses/fx-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 292fc5985475..3a27ef72b90b 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -41,6 +41,7 @@ const mobileEndorsementRows = [ { key: "claude", icon: "/harnesses/claude-ai-icon.svg", cls: "hf-claude" }, { key: "codex", icon: "/harnesses/openai_dark.svg", cls: "hf-codex" }, { key: "grok", icon: "/harnesses/grok-dark.svg", cls: "hf-grok" }, + { key: "fx", icon: "/harnesses/fx-dark.svg", cls: "hf-fx" }, { key: "cursor", icon: "/harnesses/cursor_light.svg", cls: "hf-cursor" }, ].map((m) => ( @@ -55,7 +56,7 @@ const mobileEndorsementRows = [

- Orchestrate Claude Code, Codex, Antigravity, OpenCode, Cursor, and Grok from one surface. + Orchestrate Claude Code, Codex, Antigravity, OpenCode, Cursor, Grok, and fx from one surface. Bring your own subscription. Fork the whole thing.

@@ -205,7 +206,7 @@ const mobileEndorsementRows = [

Bring your own sub

T3 Code doesn't resell tokens. Plug in Claude Code, Codex, Antigravity, - OpenCode, Cursor, or Grok with the credentials you already have. We + OpenCode, Cursor, Grok, or fx with the credentials you already have. We orchestrate them, you keep your plan.

@@ -253,6 +254,13 @@ const mobileEndorsementRows = [
Google sign-in
+
+
+
+
fx
+
fx login
+
+