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..5c8d551f3563 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..b6d2a953c1be
--- /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 the fx provider snapshot.",
+ 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..79f9c3bde1da
--- /dev/null
+++ b/apps/server/src/provider/Layers/FxAdapter.test.ts
@@ -0,0 +1,208 @@
+// @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",
+ ),
+ );
+ }),
+ );
+
+ 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
new file mode 100644
index 000000000000..f3f4ba63be67
--- /dev/null
+++ b/apps/server/src/provider/Layers/FxAdapter.ts
@@ -0,0 +1,1432 @@
+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 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;
+}
+
+interface ThreadLockEntry {
+ readonly semaphore: Semaphore.Semaphore;
+ readonly users: number;
+}
+
+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 acquireThreadSemaphore = (threadId: string) =>
+ SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
+ 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;
+ }),
+ );
+ });
+
+ 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.acquireUseRelease(
+ acquireThreadSemaphore(threadId),
+ (semaphore) => semaphore.withPermit(effect),
+ () => releaseThreadSemaphore(threadId),
+ );
+
+ 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 } : {}),
+ 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(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
+ Effect.provideService(Crypto.Crypto, crypto),
+ Effect.provideService(Scope.Scope, sessionScope),
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId: input.threadId,
+ detail: "Failed to start the fx ACP runtime.",
+ 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,
+ ),
+ });
+ 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(
+ 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: "Failed to read an fx prompt attachment.",
+ 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.",
+ });
+ }
+
+ 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..004816c37377
--- /dev/null
+++ b/apps/server/src/provider/Layers/FxProvider.test.ts
@@ -0,0 +1,169 @@
+// @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";
+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);
+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("'", `'"'"'`)}'`;
+}
+
+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");
+ 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..4ecef022731f
--- /dev/null
+++ b/apps/server/src/provider/Layers/FxProvider.ts
@@ -0,0 +1,330 @@
+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 acp = yield* makeFxAcpRuntime({
+ fxSettings,
+ environment,
+ 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/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/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..4578998a4cec
--- /dev/null
+++ b/apps/server/src/provider/acp/FxAcpSupport.test.ts
@@ -0,0 +1,123 @@
+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("switches back to the default catalog model", () =>
+ 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(["default"]);
+ 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..e6cbf754c9fe
--- /dev/null
+++ b/apps/server/src/provider/acp/FxAcpSupport.ts
@@ -0,0 +1,89 @@
+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 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,
+ 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, 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("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(
+ {
+ 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..9d7ebb3f017c
--- /dev/null
+++ b/apps/server/src/textGeneration/FxTextGeneration.ts
@@ -0,0 +1,306 @@
+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 ChatAttachment,
+ type FxSettings,
+ type ModelSelection,
+ TextGenerationError,
+} from "@t3tools/contracts";
+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
+import { extractJsonObject } from "@t3tools/shared/schemaJson";
+
+import { resolveAttachmentPath } from "../attachmentStore.ts";
+import { ServerConfig } from "../config.ts";
+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 fileSystem = yield* FileSystem.FileSystem;
+ const serverConfig = yield* ServerConfig;
+
+ const runFxJson = ({
+ operation,
+ cwd,
+ prompt,
+ outputSchemaJson,
+ modelSelection,
+ attachments,
+ }: {
+ operation:
+ | "generateCommitMessage"
+ | "generatePrContent"
+ | "generateBranchName"
+ | "generateThreadTitle";
+ cwd: string;
+ prompt: string;
+ outputSchemaJson: S;
+ modelSelection: ModelSelection;
+ attachments?: ReadonlyArray | undefined;
+ }): Effect.Effect =>
+ Effect.gen(function* () {
+ const resolvedModel = resolveFxAcpBaseModelId(modelSelection.model);
+ const outputRef = yield* Ref.make("");
+ const runtime = yield* makeFxAcpRuntime({
+ fxSettings,
+ environment,
+ cwd,
+ clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
+ }).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner),
+ 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 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({
+ 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 }, ...imagePromptParts],
+ });
+ }).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,
+ attachments: input.attachments,
+ });
+
+ 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,
+ attachments: input.attachments,
+ });
+
+ return {
+ title: sanitizeThreadTitle(generated.title),
+ } satisfies TextGeneration.ThreadTitleGenerationResult;
+ });
+
+ return {
+ generateCommitMessage,
+ generatePrContent,
+ generateBranchName,
+ generateThreadTitle,
+ } satisfies TextGeneration.TextGeneration["Service"];
+});
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/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index cd0854e176b7..525602ae053b 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -202,6 +202,17 @@ export const CursorIcon: Icon = ({ className, ...props }) => (
);
+export const FxIcon: Icon = ({ className, ...props }) => (
+
+);
+
export const GrokIcon: Icon = ({ className, ...props }) => (