From d80407af062c2079a6aefdc5b5d090846263880a Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:46:25 +0000 Subject: [PATCH 1/5] feat: add fx provider Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- README.md | 2 +- apps/mobile/src/components/ProviderIcon.tsx | 11 + apps/server/src/provider/Drivers/FxDriver.ts | 163 ++ .../src/provider/Layers/FxAdapter.test.ts | 167 ++ apps/server/src/provider/Layers/FxAdapter.ts | 1407 +++++++++++++++++ .../src/provider/Layers/FxProvider.test.ts | 164 ++ apps/server/src/provider/Layers/FxProvider.ts | 332 ++++ .../ProviderInstanceRegistryLive.test.ts | 39 +- .../server/src/provider/Services/FxAdapter.ts | 16 + .../src/provider/acp/AcpSessionRuntime.ts | 22 +- .../src/provider/acp/FxAcpSupport.test.ts | 109 ++ apps/server/src/provider/acp/FxAcpSupport.ts | 91 ++ apps/server/src/provider/builtInDrivers.ts | 3 + .../textGeneration/FxTextGeneration.test.ts | 235 +++ .../src/textGeneration/FxTextGeneration.ts | 260 +++ apps/web/src/components/Icons.tsx | 10 + .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/providerDriverMeta.ts | 10 +- apps/web/src/session-logic.ts | 6 + docs/README.md | 2 +- docs/internals/providers.md | 4 +- docs/user/install.md | 19 +- docs/user/providers-fx.md | 55 + packages/contracts/src/model.ts | 3 + packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 32 + 26 files changed, 3138 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/provider/Drivers/FxDriver.ts create mode 100644 apps/server/src/provider/Layers/FxAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/FxAdapter.ts create mode 100644 apps/server/src/provider/Layers/FxProvider.test.ts create mode 100644 apps/server/src/provider/Layers/FxProvider.ts create mode 100644 apps/server/src/provider/Services/FxAdapter.ts create mode 100644 apps/server/src/provider/acp/FxAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/FxAcpSupport.ts create mode 100644 apps/server/src/textGeneration/FxTextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/FxTextGeneration.ts create mode 100644 docs/user/providers-fx.md diff --git a/README.md b/README.md index 8ec101387f67..72f8c181800a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, fx, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..943b8f53787c 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -23,6 +23,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "fx") { + return ( + + + + ); + } + if (props.provider === "grok") { const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; return ( diff --git a/apps/server/src/provider/Drivers/FxDriver.ts b/apps/server/src/provider/Drivers/FxDriver.ts new file mode 100644 index 000000000000..7abe8e43c462 --- /dev/null +++ b/apps/server/src/provider/Drivers/FxDriver.ts @@ -0,0 +1,163 @@ +import { FxSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeFxTextGeneration } from "../../textGeneration/FxTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeFxAdapter } from "../Layers/FxAdapter.ts"; +import { + buildInitialFxProviderSnapshot, + checkFxProviderStatus, + enrichFxSnapshot, +} from "../Layers/FxProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeFxSettings = Schema.decodeSync(FxSettings); + +const DRIVER_KIND = ProviderDriverKind.make("fx"); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type FxDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const FxDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "fx", + supportsMultipleInstances: true, + }, + configSchema: FxSettings, + defaultConfig: (): FxSettings => decodeFxSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies FxSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeFxAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeFxTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkFxProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialFxProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichFxSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Fx snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/FxAdapter.test.ts b/apps/server/src/provider/Layers/FxAdapter.test.ts new file mode 100644 index 000000000000..48580fecfe98 --- /dev/null +++ b/apps/server/src/provider/Layers/FxAdapter.test.ts @@ -0,0 +1,167 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + FxSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { fxPromptSettlementBelongsToContext, makeFxAdapter } from "./FxAdapter.ts"; + +const decodeFxSettings = Schema.decodeSync(FxSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +async function makeMockFxWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "fx-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-fx.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const fxAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-fx-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makeFxAdapter(decodeFxSettings({ binaryPath }), options).pipe(Effect.orDie); + +it("requires a settlement to match the live fx turn", () => { + const staleTurnId = TurnId.make("stale-turn"); + const replacementTurnId = TurnId.make("replacement-turn"); + + assert.isFalse( + fxPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: replacementTurnId, + liveSessionActiveTurnId: replacementTurnId, + turnId: staleTurnId, + }), + ); + assert.isTrue( + fxPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); +}); + +it.layer(fxAdapterTestLayer)("FxAdapterLive", (it) => { + it.effect("runs a standard ACP session without an authenticate request", () => + Effect.gen(function* () { + const threadId = ThreadId.make("fx-mock-thread"); + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "fx-acp-requests-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockFxWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("fx"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("fx"), model: "composer-2" }, + }); + + assert.equal(session.provider, "fx"); + assert.equal(session.model, "composer-2"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello fx", + attachments: [], + }); + yield* Deferred.await(turnCompleted); + + const delta = runtimeEvents.find((event) => event.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventsFiber); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.include( + requests.map((request) => request.method), + "initialize", + ); + assert.include( + requests.map((request) => request.method), + "session/new", + ); + assert.notInclude( + requests.map((request) => request.method), + "authenticate", + ); + assert.isTrue( + requests.some( + (request) => + request.method === "session/set_config_option" && + (request.params as { configId?: string; value?: string } | undefined)?.configId === + "model" && + (request.params as { value?: string } | undefined)?.value === "composer-2", + ), + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/FxAdapter.ts b/apps/server/src/provider/Layers/FxAdapter.ts new file mode 100644 index 000000000000..2c8006aa1c95 --- /dev/null +++ b/apps/server/src/provider/Layers/FxAdapter.ts @@ -0,0 +1,1407 @@ +import { + ApprovalRequestId, + type FxSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyFxAcpModelSelection, + currentFxModelIdFromSessionSetup, + makeFxAcpRuntime, + resolveFxAcpBaseModelId, +} from "../acp/FxAcpSupport.ts"; +import { type FxAdapterShape } from "../Services/FxAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("fx"); +const FX_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface FxAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } + | { readonly _tag: "cancelled" }; + +interface PendingUserInput { + readonly resolution: Deferred.Deferred; +} + +interface FxSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + currentModelId: string | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsCancelled( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: FxSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: FxSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: FxSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + +function parseFxResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== FX_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + return response?.stopReason ?? null; +} + +export function fxPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("fx"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Fx runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Fx ACP callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = fxPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Fx notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: FxSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: FxSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: FxAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const fxModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseFxResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeFxAcpRuntime({ + fxSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = fxModelSelection?.model + ? resolveFxAcpBaseModelId(fxModelSelection.model) + : undefined; + const boundModelId = yield* applyFxAcpModelSelection({ + runtime: acp, + currentModelId: currentFxModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveFxAcpBaseModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: FX_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: FxSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModelId, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Fx runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Fx ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: FxAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent + // folds the new prompt into the ongoing work, so the active turn + // id is reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model + ? resolveFxAcpBaseModelId(turnModelSelection.model) + : undefined; + const currentModelId = yield* applyFxAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveFxAcpBaseModelId(currentModelId) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Fx prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Fx prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Fx session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Fx session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Fx session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Fx prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: FxAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: FxAdapterShape["respondToRequest"] = (threadId, requestId, decision) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: FxAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/ask_user_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); + }); + + const readThread: FxAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: FxAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Fx ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: FxAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: FxAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: FxAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: FxAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies FxAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/FxProvider.test.ts b/apps/server/src/provider/Layers/FxProvider.test.ts new file mode 100644 index 000000000000..03685a23e6a5 --- /dev/null +++ b/apps/server/src/provider/Layers/FxProvider.test.ts @@ -0,0 +1,164 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +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 { FxSettings } from "@t3tools/contracts"; + +import { buildInitialFxProviderSnapshot, checkFxProviderStatus } from "./FxProvider.ts"; + +const decodeFxSettings = Schema.decodeSync(FxSettings); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +describe("buildInitialFxProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialFxProviderSnapshot(decodeFxSettings({ enabled: false })); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a disabled snapshot by default — fx is opt-in", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialFxProviderSnapshot(decodeFxSettings({})); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + }), + ); + + it.effect("returns a pending snapshot when enabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialFxProviderSnapshot(decodeFxSettings({ enabled: true })); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking fx"); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + }), + ); +}); + +it.layer(NodeServices.layer)("checkFxProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkFxProviderStatus( + decodeFxSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/fx-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const secretStderr = "broken fx install: secret-token-value"; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-fx-version-" }); + const fxPath = path.join(dir, "fx"); + yield* fs.writeFileString( + fxPath, + ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), + ); + yield* fs.chmod(fxPath, 0o755); + + return yield* checkFxProviderStatus( + decodeFxSettings({ enabled: true, binaryPath: fxPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("fx CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); + + it.effect("discovers the active fx model catalog through standard ACP config options", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-fx-acp-success-" }); + const fxPath = path.join(dir, "fx"); + const mockAgentPath = path.join(process.cwd(), "apps/server/scripts/acp-mock-agent.ts"); + yield* fs.writeFileString( + fxPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "fx 0.0.99\\n"', + " exit 0", + "fi", + 'if [ "$1" != "acp" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(mockAgentPath)}`, + "", + ].join("\n"), + ); + yield* fs.chmod(fxPath, 0o755); + + return yield* checkFxProviderStatus( + decodeFxSettings({ enabled: true, binaryPath: fxPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("0.0.99"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "default", + "composer-2", + "composer-2[fast=true]", + "gpt-5.3-codex[reasoning=medium,fast=false]", + ]); + }), + ); + + it.effect("reports an error when ACP model discovery is unavailable", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-fx-success-" }); + const fxPath = path.join(dir, "fx"); + yield* fs.writeFileString( + fxPath, + ["#!/bin/sh", 'printf "fx-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(fxPath, 0o755); + + return yield* checkFxProviderStatus( + decodeFxSettings({ enabled: true, binaryPath: fxPath }), + ); + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.installed).toBe(true); + expect(snapshot.models).toEqual([]); + expect(snapshot.message).toContain("ACP startup failed"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/FxProvider.ts b/apps/server/src/provider/Layers/FxProvider.ts new file mode 100644 index 000000000000..63cfa967b8d8 --- /dev/null +++ b/apps/server/src/provider/Layers/FxProvider.ts @@ -0,0 +1,332 @@ +import { + type FxSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { makeFxAcpRuntime, resolveFxAcpBaseModelId } from "../acp/FxAcpSupport.ts"; + +const FX_PRESENTATION = { + displayName: "fx", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const FX_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +const FX_BUILT_IN_MODELS: ReadonlyArray = []; + +export function buildInitialFxProviderSnapshot( + fxSettings: FxSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = fxModelsFromSettings(fxSettings.customModels); + + if (!fxSettings.enabled) { + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "fx is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking fx CLI availability...", + }, + }); + }); +} + +function fxModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = FX_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +function buildFxDiscoveredModelsFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + const modelOption = configOptions?.find( + (option) => option.category === "model" && option.type === "select", + ); + if (!modelOption || modelOption.type !== "select") { + return []; + } + const seen = new Set(); + return modelOption.options + .flatMap((entry) => ("value" in entry ? [entry] : entry.options)) + .map((model): ServerProviderModel | undefined => { + const slug = resolveFxAcpBaseModelId(model.value); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +const discoverFxModelsViaAcp = ( + fxSettings: FxSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeFxAcpRuntime({ + fxSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + return buildFxDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions); + }).pipe(Effect.scoped); + +const runFxVersionCommand = ( + fxSettings: FxSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = fxSettings.binaryPath || "fx"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function* ( + fxSettings: FxSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = fxModelsFromSettings(fxSettings.customModels); + + if (!fxSettings.enabled) { + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "fx is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runFxVersionCommand(fxSettings, 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, + }); + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: fxSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "fx CLI (`fx`) is not installed or not on PATH." + : "Failed to execute fx CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: fxSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "fx CLI 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 buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: fxSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "fx CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverFxModelsViaAcp(fxSettings, environment).pipe( + Effect.timeoutOption(FX_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("fx ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: fxSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "fx CLI is installed but ACP startup failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `fx ACP model discovery timed out after ${FX_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: fxSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `fx CLI is installed but ACP startup timed out after ${FX_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discoveredModels = discoveryExit.value.value; + const models = + discoveredModels.length > 0 + ? fxModelsFromSettings(fxSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: FX_PRESENTATION, + enabled: fxSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +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 => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("fx version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..bbfdd6671c90 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `fx`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `fx` / `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -28,6 +28,7 @@ import { type ClaudeSettings, type CodexSettings, type CursorSettings, + type FxSettings, type GrokSettings, type OpenCodeSettings, ProviderDriverKind, @@ -46,6 +47,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; +import { FxDriver } from "../Drivers/FxDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; @@ -124,6 +126,13 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ ...overrides, }); +const makeFxConfig = (overrides: Partial): FxSettings => ({ + enabled: false, + binaryPath: "fx", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -320,12 +329,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); + const fxId = ProviderInstanceId.make("fx_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); + const fxDriverKind = ProviderDriverKind.make("fx"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -356,6 +367,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeGrokConfig({}), }, + [fxId]: { + driver: fxDriverKind, + displayName: "fx", + enabled: false, + config: makeFxConfig({}), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -365,7 +382,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, FxDriver, OpenCodeDriver], configMap, }); @@ -375,9 +392,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, fxId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -387,16 +404,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); + const fx = yield* registry.getInstance(fxId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); + expect(fx?.driverKind).toBe(fxDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); + expect(fx?.displayName).toBe("fx"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across @@ -409,6 +429,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.adapter, cursor!.adapter, grok!.adapter, + fx!.adapter, openCode!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); @@ -417,6 +438,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.textGeneration, cursor!.textGeneration, grok!.textGeneration, + fx!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); @@ -425,6 +447,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.snapshot, cursor!.snapshot, grok!.snapshot, + fx!.snapshot, openCode!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -461,6 +484,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(grokSnapshot.enabled).toBe(false); expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + const fxSnapshot = yield* fx!.snapshot.getSnapshot; + expect(fxSnapshot.instanceId).toBe(fxId); + expect(fxSnapshot.driver).toBe(fxDriverKind); + expect(fxSnapshot.enabled).toBe(false); + expect(fxSnapshot.continuation?.groupKey).toBe(`${fxDriverKind}:instance:${fxId}`); + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; expect(openCodeSnapshot.instanceId).toBe(openCodeId); expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); diff --git a/apps/server/src/provider/Services/FxAdapter.ts b/apps/server/src/provider/Services/FxAdapter.ts new file mode 100644 index 000000000000..fa6a3cbd6b12 --- /dev/null +++ b/apps/server/src/provider/Services/FxAdapter.ts @@ -0,0 +1,16 @@ +/** + * FxAdapter — shape type for the Fx provider adapter. + * + * The driver model ({@link ../Drivers/FxDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module FxAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * FxAdapterShape — per-instance Fx adapter contract. + */ +export interface FxAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..5f9669c725bb 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -68,7 +68,9 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** Omit when the agent uses credentials it already manages and does not + * advertise ACP authentication methods. */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -541,15 +543,17 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId !== undefined) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: diff --git a/apps/server/src/provider/acp/FxAcpSupport.test.ts b/apps/server/src/provider/acp/FxAcpSupport.test.ts new file mode 100644 index 000000000000..c9bcbdb1fe54 --- /dev/null +++ b/apps/server/src/provider/acp/FxAcpSupport.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { + applyFxAcpModelSelection, + buildFxAcpSpawnInput, + resolveFxAcpBaseModelId, +} from "./FxAcpSupport.ts"; + +describe("resolveFxAcpBaseModelId", () => { + it("preserves raw fx catalog ids and defaults empty ids", () => { + expect(resolveFxAcpBaseModelId(undefined)).toBe("default"); + expect(resolveFxAcpBaseModelId(" ")).toBe("default"); + expect(resolveFxAcpBaseModelId(" fx-test-custom-model ")).toBe("fx-test-custom-model"); + }); +}); + +describe("buildFxAcpSpawnInput", () => { + it("launches fx acp with the configured environment", () => { + const spawn = buildFxAcpSpawnInput({ binaryPath: "/usr/local/bin/fx" }, "/tmp/project", { + XAI_API_KEY: "secret", + FX_HOME: "/tmp/fx-home", + }); + + expect(spawn).toEqual({ + command: "/usr/local/bin/fx", + args: ["acp"], + cwd: "/tmp/project", + env: { + XAI_API_KEY: "secret", + FX_HOME: "/tmp/fx-home", + }, + }); + }); +}); + +describe("applyFxAcpModelSelection", () => { + const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { + const modelCalls: Array = []; + const runtime = { + setModel: (modelId: string) => + Effect.gen(function* () { + modelCalls.push(modelId); + if (failure) return yield* failure; + return {}; + }), + }; + return { runtime, modelCalls }; + }; + + it.effect("updates the standard model config when the requested model differs", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyFxAcpModelSelection({ + runtime, + currentModelId: "default", + requestedModelId: "fx-mock-alt", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual(["fx-mock-alt"]); + expect(result).toBe("fx-mock-alt"); + }), + ); + + it.effect("skips the model update when requested matches current", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyFxAcpModelSelection({ + runtime, + currentModelId: "default", + requestedModelId: "default", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("default"); + }), + ); + + it.effect("skips the model update when no model is requested", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyFxAcpModelSelection({ + runtime, + currentModelId: "default", + requestedModelId: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("default"); + }), + ); + + it.effect("propagates model config failures via mapError", () => + Effect.gen(function* () { + const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known"); + const { runtime } = makeRecordingRuntime(failure); + const error = yield* Effect.flip( + applyFxAcpModelSelection({ + runtime, + currentModelId: "default", + requestedModelId: "fx-mock-alt", + mapError: (cause) => cause.message, + }), + ); + expect(error).toBe(failure.message); + }), + ); +}); diff --git a/apps/server/src/provider/acp/FxAcpSupport.ts b/apps/server/src/provider/acp/FxAcpSupport.ts new file mode 100644 index 000000000000..0c34b6648a5e --- /dev/null +++ b/apps/server/src/provider/acp/FxAcpSupport.ts @@ -0,0 +1,91 @@ +import { type FxSettings } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type FxAcpRuntimeFxSettings = Pick; + +interface FxAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly fxSettings: FxAcpRuntimeFxSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildFxAcpSpawnInput( + fxSettings: FxAcpRuntimeFxSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: fxSettings?.binaryPath || "fx", + args: ["acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeFxAcpRuntime = ( + input: FxAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildFxAcpSpawnInput(input.fxSettings, input.cwd, input.environment), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveFxAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "default"; +} + +export function currentFxModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const modelOption = sessionSetupResult.configOptions?.find( + (option) => option.category === "model" && option.type === "select", + ); + return typeof modelOption?.currentValue === "string" + ? modelOption.currentValue.trim() || undefined + : undefined; +} + +export function applyFxAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setModel(input.requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..4be919ffabc6 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { FxDriver, type FxDriverEnv } from "./Drivers/FxDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -36,6 +37,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | FxDriverEnv | GrokDriverEnv | OpenCodeDriverEnv; @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray): string { + const binDir = NodePath.join(dir, "bin"); + const fxPath = NodePath.join(binDir, "fx"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( + fxPath, + [ + "#!/bin/sh", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + 'if [ "$1" != "acp" ] || [ "$#" != "1" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + NodeFS.chmodSync(fxPath, 0o755); + return fxPath; +} + +function withFakeAcpFx( + env: Record, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, +) { + return Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-fx-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const binaryPath = makeAcpFxWrapper(tempDir, env); + const config = decodeFxSettings({ binaryPath }); + const textGeneration = yield* makeFxTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +function readJsonRpcRequests( + filePath: string, +): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { + return NodeFS.readFileSync(filePath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string; params?: Record }); +} + +it.layer(FxTextGenerationTestLayer)("FxTextGeneration", (it) => { + it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => { + const requestLogDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-fx-text-log-")); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpFx( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add Fx provider", + body: "Wire up the ACP runtime and headless text generation path.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/fx", + stagedSummary: "M apps/server/src/provider/Drivers/FxDriver.ts", + stagedPatch: "diff --git a/.../FxDriver.ts b/.../FxDriver.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), + }); + + expect(generated.subject).toBe("Add Fx provider"); + expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path."); + + const requests = readJsonRpcRequests(requestLogPath); + expect( + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, + ).toMatchObject({ + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === "composer-2", + ), + ).toBe(true); + }), + ); + }); + + it.effect("extracts the JSON object when Fx wraps it in conversational text", () => + withFakeAcpFx( + { + T3_ACP_PROMPT_RESPONSE_TEXT: + "Sure! Here's a thread title:\n\n" + + JSON.stringify({ title: "Investigate failing CI" }) + + "\n\nLet me know if you need anything else.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "the lint job is red", + modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), + }); + expect(generated.title).toBe("Investigate failing CI"); + }), + ), + ); + + it.effect("surfaces ACP request failures as text generation errors", () => + withFakeAcpFx( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }), + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "wire up fx", + modelSelection: createModelSelection( + ProviderInstanceId.make("fx"), + "missing-fx-model", + ), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toContain("Fx ACP base model"); + }), + ), + ); + + it.effect("fails with TextGenerationError when output is empty", () => + withFakeAcpFx( + { + T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/empty/i); + }), + ), + ); + + it.effect("decodes a structured PR title + body", () => + withFakeAcpFx( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: "feat(fx): wire up model configuration", + body: "## Summary\n- Select models with ACP `session/set_config_option`.\n- Preserve raw model ids from the active fx catalog.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feat/fx-provider", + commitSummary: "feat: add fx provider", + diffSummary: "M apps/server/src/provider/Drivers/FxDriver.ts", + diffPatch: "diff --git a/.../FxDriver.ts b/.../FxDriver.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), + }); + + expect(generated.title).toBe("feat(fx): wire up model configuration"); + expect(generated.body).toContain("Preserve raw model ids"); + }), + ), + ); + + it.effect("fails with TextGenerationError when output is unparseable JSON", () => + withFakeAcpFx( + { + T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/invalid structured output/i); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/FxTextGeneration.ts b/apps/server/src/textGeneration/FxTextGeneration.ts new file mode 100644 index 000000000000..f9f691e6c16b --- /dev/null +++ b/apps/server/src/textGeneration/FxTextGeneration.ts @@ -0,0 +1,260 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { type FxSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyFxAcpModelSelection, + currentFxModelIdFromSessionSetup, + makeFxAcpRuntime, + resolveFxAcpBaseModelId, +} from "../provider/acp/FxAcpSupport.ts"; + +const FX_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* ( + fxSettings: FxSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runFxJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = resolveFxAcpBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeFxAcpRuntime({ + fxSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* applyFxAcpModelSelection({ + runtime, + currentModelId: currentFxModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolvedModel, + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Fx ACP base model for text generation.", + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(FX_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Fx ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Fx ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Fx ACP request was cancelled." + : "Fx Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Fx Agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Fx ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("FxTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runFxJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("FxTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runFxJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("FxTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runFxJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("FxTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runFxJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..dddea441f3a6 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -202,6 +202,16 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( ); +export const FxIcon: Icon = ({ className, ...props }) => ( + + + +); + export const GrokIcon: Icon = ({ className, ...props }) => ( > = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("fx")]: FxIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..e6ffb2fc568e 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,12 +2,13 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + FxSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, FxIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +62,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("fx"), + label: "fx", + icon: FxIcon, + badgeLabel: "Early Access", + settingsSchema: FxSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..0efa6a86914d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("fx"), + label: "fx", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/README.md b/docs/README.md index 622d81064387..2ddce070e3cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [fx](./user/providers-fx.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..36bb18146d13 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -15,6 +15,7 @@ orchestration layer does not know which one is behind a thread. | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `fx` | [`Drivers/FxDriver.ts`][fx] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an @@ -80,6 +81,7 @@ when a request opens (approval) or user input is requested, via [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts +[fx]: ../../apps/server/src/provider/Drivers/FxDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..11907fd7c302 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,15 +54,16 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | - -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn +| Provider | CLI | Default binary | Log in with | +| ---------- | ----------------------------------------------------- | -------------- | ----------------------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| fx | [fx](https://fx.sh) | `fx` | `fx login codex` or `fx login grok` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | + +Codex and Claude are on by default. Cursor, Grok Build, fx, and OpenCode are off by default; turn them on in **Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md new file mode 100644 index 000000000000..cdaf7dbf11eb --- /dev/null +++ b/docs/user/providers-fx.md @@ -0,0 +1,55 @@ +# fx + +fx is an experimental coding agent that can use Vercel AI Gateway, an eligible ChatGPT subscription through Codex OAuth, or an eligible Grok subscription through xAI OAuth. + +## Install + +Install fx on the machine running the T3 Code server: + +```bash +curl -fsSL https://fx.sh/setup.sh | bash +``` + +If T3 Code cannot find `fx` on `PATH`, open **Settings**, select the fx provider, and set its **Binary path**. + +## Authenticate + +Choose the account you want fx to use before starting a T3 Code thread. + +For Codex subscription access: + +```bash +fx login codex +``` + +For Grok subscription access: + +```bash +fx login grok +``` + +For Vercel AI Gateway: + +```bash +fx login +``` + +fx stores and refreshes these sessions itself. T3 Code launches `fx acp` and uses the provider and model catalog selected by fx. + +## Enable In T3 Code + +fx is off by default while the integration is in early access. + +1. Open **Settings**. +2. Enable the fx provider. +3. Refresh its status. +4. Pick an fx model when creating a thread. + +Use fx's `/setup` command in a terminal to switch between Gateway, Codex, and Grok. Refresh the provider in T3 Code afterwards to load the active catalog. + +## Troubleshooting + +- Run `fx --version` on the T3 Code server to confirm the binary is available. +- Run `fx` in the project once to verify the selected subscription works. +- If models are missing, finish `fx login codex`, `fx login grok`, or `fx login`, then refresh the provider status. +- Model changes use fx's standard ACP model configuration and work in existing T3 Code threads. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..c2f6b446a7b4 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const FX_DRIVER_KIND = ProviderDriverKind.make("fx"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -211,6 +212,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "opus-4.5-thinking": "claude-opus-4-5", "opus-4.5": "claude-opus-4-5", }, + [FX_DRIVER_KIND]: {}, [OPENCODE_DRIVER_KIND]: {}, }; @@ -220,6 +222,7 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [FX_DRIVER_KIND]: "fx", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f59da5ece14..632c4357585d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -186,6 +186,7 @@ describe("provider enabled defaults", () => { expect(decoded.providers.claudeAgent.enabled).toBe(true); expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); + expect(decoded.providers.fx.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d249..513b04130bce 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -467,6 +467,30 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +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 CLI binary.", + providerSettingsForm: { placeholder: "fx", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type FxSettings = typeof FxSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and Grok): the binding is not yet stable @@ -660,6 +684,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + fx: FxSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -800,6 +825,12 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const FxSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -848,6 +879,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + fx: Schema.optionalKey(FxSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), From 0e97ae9886468652b2616753113f333819df8ba4 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:59:45 +0000 Subject: [PATCH 2/5] fix: address fx provider review findings Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/provider/Drivers/FxDriver.ts | 2 +- apps/server/src/provider/Layers/FxAdapter.ts | 61 +++++++++++++------ .../src/provider/Layers/FxProvider.test.ts | 7 ++- apps/server/src/provider/Layers/FxProvider.ts | 2 - .../provider/Layers/ProviderRegistry.test.ts | 1 + .../src/provider/acp/FxAcpSupport.test.ts | 14 +++++ apps/server/src/provider/acp/FxAcpSupport.ts | 11 ++-- .../textGeneration/FxTextGeneration.test.ts | 49 +++++++++++++++ .../src/textGeneration/FxTextGeneration.ts | 56 +++++++++++++++-- .../src/textGeneration/TextGeneration.ts | 8 ++- packages/contracts/src/model.ts | 2 + packages/shared/src/model.test.ts | 5 ++ 12 files changed, 183 insertions(+), 35 deletions(-) diff --git a/apps/server/src/provider/Drivers/FxDriver.ts b/apps/server/src/provider/Drivers/FxDriver.ts index 7abe8e43c462..b6d2a953c1be 100644 --- a/apps/server/src/provider/Drivers/FxDriver.ts +++ b/apps/server/src/provider/Drivers/FxDriver.ts @@ -142,7 +142,7 @@ export const FxDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Fx snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the fx provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Layers/FxAdapter.ts b/apps/server/src/provider/Layers/FxAdapter.ts index 2c8006aa1c95..895453a632ea 100644 --- a/apps/server/src/provider/Layers/FxAdapter.ts +++ b/apps/server/src/provider/Layers/FxAdapter.ts @@ -19,7 +19,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; + import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; @@ -113,6 +113,11 @@ interface FxSessionContext { stopped: boolean; } +interface ThreadLockEntry { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; +} + function settlePendingApprovalsAsCancelled( pendingApprovals: ReadonlyMap, ): Effect.Effect { @@ -232,7 +237,7 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); const sessions = new Map(); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -263,26 +268,42 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - const getThreadSemaphore = (threadId: string) => + const acquireThreadSemaphore = (threadId: string) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), + const existing = current.get(threadId); + if (existing) { + const next = new Map(current); + next.set(threadId, { ...existing, users: existing.users + 1 }); + return Effect.succeed([existing.semaphore, next] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, { semaphore, users: 1 }); + return [semaphore, next] as const; + }), ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); + }); + + const releaseThreadSemaphore = (threadId: string) => + SynchronizedRef.update(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (!existing) return current; + const next = new Map(current); + if (existing.users <= 1) { + next.delete(threadId); + } else { + next.set(threadId, { ...existing, users: existing.users - 1 }); + } + return next; }); const withThreadLock = (threadId: string, effect: Effect.Effect) => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + Effect.acquireUseRelease( + acquireThreadSemaphore(threadId), + (semaphore) => semaphore.withPermit(effect), + () => releaseThreadSemaphore(threadId), + ); const settlePromptInFlight = ( threadId: ThreadId, @@ -563,7 +584,6 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt const acp = yield* makeFxAcpRuntime({ fxSettings, ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, @@ -586,6 +606,7 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt : {}), ...acpNativeLoggers, }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( @@ -593,7 +614,7 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + detail: "Failed to start the fx ACP runtime.", cause, }), ), @@ -919,7 +940,7 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: cause.message, + detail: "Failed to read an fx prompt attachment.", cause, }), ), diff --git a/apps/server/src/provider/Layers/FxProvider.test.ts b/apps/server/src/provider/Layers/FxProvider.test.ts index 03685a23e6a5..004816c37377 100644 --- a/apps/server/src/provider/Layers/FxProvider.test.ts +++ b/apps/server/src/provider/Layers/FxProvider.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off +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 * as Effect from "effect/Effect"; @@ -9,6 +13,8 @@ import { FxSettings } from "@t3tools/contracts"; import { buildInitialFxProviderSnapshot, checkFxProviderStatus } from "./FxProvider.ts"; const decodeFxSettings = Schema.decodeSync(FxSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; @@ -99,7 +105,6 @@ it.layer(NodeServices.layer)("checkFxProviderStatus", (it) => { const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-fx-acp-success-" }); const fxPath = path.join(dir, "fx"); - const mockAgentPath = path.join(process.cwd(), "apps/server/scripts/acp-mock-agent.ts"); yield* fs.writeFileString( fxPath, [ diff --git a/apps/server/src/provider/Layers/FxProvider.ts b/apps/server/src/provider/Layers/FxProvider.ts index 63cfa967b8d8..4ecef022731f 100644 --- a/apps/server/src/provider/Layers/FxProvider.ts +++ b/apps/server/src/provider/Layers/FxProvider.ts @@ -125,11 +125,9 @@ const discoverFxModelsViaAcp = ( environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const acp = yield* makeFxAcpRuntime({ fxSettings, environment, - childProcessSpawner, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..4cccb6e08704 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1740,6 +1740,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "fx", "grok", "opencode", ]); diff --git a/apps/server/src/provider/acp/FxAcpSupport.test.ts b/apps/server/src/provider/acp/FxAcpSupport.test.ts index c9bcbdb1fe54..0809f514a645 100644 --- a/apps/server/src/provider/acp/FxAcpSupport.test.ts +++ b/apps/server/src/provider/acp/FxAcpSupport.test.ts @@ -77,6 +77,20 @@ describe("applyFxAcpModelSelection", () => { }), ); + it.effect("uses fx's active model for the default sentinel", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyFxAcpModelSelection({ + runtime, + currentModelId: "provider-active-model", + requestedModelId: "default", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("provider-active-model"); + }), + ); + it.effect("skips the model update when no model is requested", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); diff --git a/apps/server/src/provider/acp/FxAcpSupport.ts b/apps/server/src/provider/acp/FxAcpSupport.ts index 0c34b6648a5e..7a658cb94e33 100644 --- a/apps/server/src/provider/acp/FxAcpSupport.ts +++ b/apps/server/src/provider/acp/FxAcpSupport.ts @@ -14,7 +14,6 @@ interface FxAcpRuntimeInput extends Omit< AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" > { - readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly fxSettings: FxAcpRuntimeFxSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; } @@ -37,17 +36,16 @@ export const makeFxAcpRuntime = ( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, spawn: buildFxAcpSpawnInput(input.fxSettings, input.cwd, input.environment), }).pipe( - Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), - ), + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)), ), ); return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( @@ -80,6 +78,9 @@ export function applyFxAcpModelSelection(input: { readonly requestedModelId: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { + if (input.requestedModelId === "default") { + return Effect.succeed(input.currentModelId); + } const shouldSwitchModel = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; if (!shouldSwitchModel) { diff --git a/apps/server/src/textGeneration/FxTextGeneration.test.ts b/apps/server/src/textGeneration/FxTextGeneration.test.ts index 3c05e866e14b..fa54d5206363 100644 --- a/apps/server/src/textGeneration/FxTextGeneration.test.ts +++ b/apps/server/src/textGeneration/FxTextGeneration.test.ts @@ -124,6 +124,55 @@ it.layer(FxTextGenerationTestLayer)("FxTextGeneration", (it) => { ); }); + it.effect("sends image attachments as ACP content blocks", () => { + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-fx-image-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpFx( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "fix/image-context" }), + }, + (textGeneration) => + Effect.gen(function* () { + const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const attachmentId = "fx-text-image"; + NodeFS.mkdirSync(attachmentsDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "hello"); + + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "fix the screenshot regression", + attachments: [ + { + type: "image", + id: attachmentId, + name: "regression.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + modelSelection: createModelSelection(ProviderInstanceId.make("fx"), "composer-2"), + }); + + expect(generated.branch).toBe("fix/image-context"); + const promptRequest = readJsonRpcRequests(requestLogPath).find( + (request) => request.method === "session/prompt", + ); + const prompt = promptRequest?.params?.prompt as + | ReadonlyArray> + | undefined; + expect(prompt).toContainEqual({ + type: "image", + data: Buffer.from("hello").toString("base64"), + mimeType: "image/png", + }); + }), + ); + }); + it.effect("extracts the JSON object when Fx wraps it in conversational text", () => withFakeAcpFx( { diff --git a/apps/server/src/textGeneration/FxTextGeneration.ts b/apps/server/src/textGeneration/FxTextGeneration.ts index f9f691e6c16b..9d7ebb3f017c 100644 --- a/apps/server/src/textGeneration/FxTextGeneration.ts +++ b/apps/server/src/textGeneration/FxTextGeneration.ts @@ -1,16 +1,24 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; -import { type FxSettings, type ModelSelection } from "@t3tools/contracts"; +import { + type ChatAttachment, + type FxSettings, + type ModelSelection, + TextGenerationError, +} from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { TextGenerationError } from "@t3tools/contracts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, @@ -40,6 +48,8 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* ) { const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; const runFxJson = ({ operation, @@ -47,6 +57,7 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* prompt, outputSchemaJson, modelSelection, + attachments, }: { operation: | "generateCommitMessage" @@ -57,6 +68,7 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* prompt: string; outputSchemaJson: S; modelSelection: ModelSelection; + attachments?: ReadonlyArray | undefined; }): Effect.Effect => Effect.gen(function* () { const resolvedModel = resolveFxAcpBaseModelId(modelSelection.model); @@ -64,10 +76,12 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* const runtime = yield* makeFxAcpRuntime({ fxSettings, environment, - childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner), + Effect.provideService(Crypto.Crypto, crypto), + ); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; @@ -81,6 +95,36 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* return Ref.update(outputRef, (current) => current + content.text); }); + const imagePromptParts = yield* Effect.forEach(attachments ?? [], (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new TextGenerationError({ + operation, + detail: `Invalid fx text-generation attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Failed to read an fx text-generation attachment.", + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptResult = yield* Effect.gen(function* () { const started = yield* runtime.start(); yield* applyFxAcpModelSelection({ @@ -96,7 +140,7 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* }); return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], + prompt: [{ type: "text", text: prompt }, ...imagePromptParts], }); }).pipe( Effect.timeoutOption(FX_TIMEOUT_MS), @@ -223,6 +267,7 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, + attachments: input.attachments, }); return { @@ -244,6 +289,7 @@ export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, + attachments: input.attachments, }); return { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..2d84bd319d9f 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "fx" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index c2f6b446a7b4..fa85fcca4050 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -152,6 +152,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial { expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); }); + + it("uses fx's active-model sentinel when no model is selected", () => { + expect(resolveModelSlugForProvider(ProviderDriverKind.make("fx"), undefined)).toBe("default"); + }); }); From 5c78ba92afbd960c9d108b181df708402cae49ef Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:07:10 +0000 Subject: [PATCH 3/5] fix: keep fx model state in sync Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .../src/provider/Layers/FxAdapter.test.ts | 41 +++++++++++++++++++ apps/server/src/provider/Layers/FxAdapter.ts | 12 ++++-- .../src/provider/acp/FxAcpSupport.test.ts | 6 +-- apps/server/src/provider/acp/FxAcpSupport.ts | 3 -- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/FxAdapter.test.ts b/apps/server/src/provider/Layers/FxAdapter.test.ts index 48580fecfe98..79f9c3bde1da 100644 --- a/apps/server/src/provider/Layers/FxAdapter.test.ts +++ b/apps/server/src/provider/Layers/FxAdapter.test.ts @@ -164,4 +164,45 @@ it.layer(fxAdapterTestLayer)("FxAdapterLive", (it) => { ); }), ); + + it.effect("keeps the selected model when prompt preparation fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("fx-model-switch-preparation-failure"); + const wrapperPath = yield* Effect.promise(() => makeMockFxWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const switchedModel = "gpt-5.3-codex[reasoning=medium,fast=false]"; + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("fx"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("fx"), model: "composer-2" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "use the screenshot", + attachments: [ + { + type: "image", + id: "missing-image", + name: "missing.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + modelSelection: { instanceId: ProviderInstanceId.make("fx"), model: switchedModel }, + }), + ); + + const session = (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId); + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.equal(session?.status, "ready"); + assert.equal(session?.model, switchedModel); + + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/FxAdapter.ts b/apps/server/src/provider/Layers/FxAdapter.ts index 895453a632ea..f3f4ba63be67 100644 --- a/apps/server/src/provider/Layers/FxAdapter.ts +++ b/apps/server/src/provider/Layers/FxAdapter.ts @@ -917,6 +917,14 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt cause, ), }); + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveFxAcpBaseModelId(currentModelId) + : undefined; + ctx.session = { + ...ctx.session, + ...(displayModel ? { model: displayModel } : {}), + }; const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -965,10 +973,6 @@ export function makeFxAdapter(fxSettings: FxSettings, options?: FxAdapterLiveOpt }); } - ctx.currentModelId = currentModelId; - const displayModel = currentModelId - ? resolveFxAcpBaseModelId(currentModelId) - : undefined; for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { yield* Effect.yieldNow; } diff --git a/apps/server/src/provider/acp/FxAcpSupport.test.ts b/apps/server/src/provider/acp/FxAcpSupport.test.ts index 0809f514a645..4578998a4cec 100644 --- a/apps/server/src/provider/acp/FxAcpSupport.test.ts +++ b/apps/server/src/provider/acp/FxAcpSupport.test.ts @@ -77,7 +77,7 @@ describe("applyFxAcpModelSelection", () => { }), ); - it.effect("uses fx's active model for the default sentinel", () => + it.effect("switches back to the default catalog model", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); const result = yield* applyFxAcpModelSelection({ @@ -86,8 +86,8 @@ describe("applyFxAcpModelSelection", () => { requestedModelId: "default", mapError: (cause) => cause.message, }); - expect(modelCalls).toEqual([]); - expect(result).toBe("provider-active-model"); + expect(modelCalls).toEqual(["default"]); + expect(result).toBe("default"); }), ); diff --git a/apps/server/src/provider/acp/FxAcpSupport.ts b/apps/server/src/provider/acp/FxAcpSupport.ts index 7a658cb94e33..e6cbf754c9fe 100644 --- a/apps/server/src/provider/acp/FxAcpSupport.ts +++ b/apps/server/src/provider/acp/FxAcpSupport.ts @@ -78,9 +78,6 @@ export function applyFxAcpModelSelection(input: { readonly requestedModelId: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - if (input.requestedModelId === "default") { - return Effect.succeed(input.currentModelId); - } const shouldSwitchModel = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; if (!shouldSwitchModel) { From c0b73d8dd3836ff49e0c236cf47f36f951ecf32d Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:03:21 +0000 Subject: [PATCH 4/5] fix: use official fx logo Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/mobile/src/components/ProviderIcon.tsx | 4 ++-- apps/web/src/components/Icons.tsx | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 943b8f53787c..5c8d551f3563 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -25,10 +25,10 @@ export function ProviderIcon(props: ProviderIconProps) { if (props.provider === "fx") { return ( - + ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index dddea441f3a6..6f0f7bfa57fc 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -205,10 +205,14 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( export const FxIcon: Icon = ({ className, ...props }) => ( - + ); From 04bd65cc92503ad42d08f5fa5162484da490ee9f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:10:12 +0000 Subject: [PATCH 5/5] fix: keep fx logo color consistent Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/Icons.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 6f0f7bfa57fc..525602ae053b 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -207,12 +207,9 @@ export const FxIcon: Icon = ({ className, ...props }) => ( {...props} viewBox="166.241 0 155.861 156" fill="none" - className={cn("text-[#171717] dark:text-[#F5F5F5]", className)} + className={cn("fill-[#171717] dark:fill-[#F5F5F5]", className)} > - + );