Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?"

Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "fx") {
return (
<Svg width={size} height={size} viewBox="166.241 0 155.861 156" fill="none">
<Path
fill={mono}
d="M237.89 0C243.18 0 249.38 1.42 253.03 3.07L255.09 4.01L250.08 18.63L247.68 17.75C244.9 16.72 241.94 15.8 238.49 15.8C234.98 15.8 232.79 16.56 231.08 18.32C229.23 20.23 227.63 23.64 226.23 29.76L225.14 34.85H241.67L260.43 34.95L260.69 34.95L260.84 35.17L278.85 61.63L296.74 34.95H320.87L291.68 76.74L322.1 119.75H299.33L299.18 119.55L241.14 40.48L239.35 49.4H222.07L205.69 127.21C203.93 135.71 201.19 142.84 196.78 147.87C192.27 153.01 186.2 155.75 178.34 155.75C174.18 155.75 170.75 155.11 167.91 154.11L166.24 153.52V137.18L166.9 137.4L169.53 138.28C172.18 139.16 174.41 139.8 177.14 139.8C178.53 139.8 179.7 139.53 180.73 138.98C181.76 138.43 182.68 137.6 183.52 136.44C185.3 133.99 186.72 130.13 187.9 124.67L203.76 49.4H189.87L191.76 39.44L192.04 39.35L206.82 34.47L208.15 28.64C210.52 18.21 213.77 10.94 218.71 6.32C223.74 1.61 230.13 0 237.89 0ZM273.99 99.08L260.07 120.25H234.54L261 82.02L273.99 99.08Z"
/>
</Svg>
);
}

if (props.provider === "grok") {
const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F";
return (
Expand Down
163 changes: 163 additions & 0 deletions apps/server/src/provider/Drivers/FxDriver.ts
Original file line number Diff line number Diff line change
@@ -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<FxSettings, FxDriverEnv> = {
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<ProviderSnapshotSettings<FxSettings>>({
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;
}),
};
208 changes: 208 additions & 0 deletions apps/server/src/provider/Layers/FxAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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<string, unknown>);
}

const fxAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3code-fx-adapter-test-",
}).pipe(Layer.provideMerge(NodeServices.layer));

const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeFxAdapter>[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<void>();
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);
}),
);
});
Loading
Loading