From 106278adadd580738a1d44c155ac81ece2d44a99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 05:37:54 +0100 Subject: [PATCH] feat(providers): add fx as an opt-in bundled ACP provider fx ships a native ACP server, so it follows the catalog opt-in shape of the other bundled ACP provider and needs no vendor protocol code. fx files its provider selector first under ACP's "model" category, then the model, Fast, and Ultrafast. The ACP adapter took the first option in that category as the model, so the model picker listed fx's providers. It now prefers the option with id "model" and keeps the rest as settings. --- docs/plugins.md | 2 +- .../e2e/browser/acp-provider-catalog.spec.ts | 27 ++++++++- packages/app/src/assets/acp-provider-icons.ts | 1 + packages/app/src/data/acp-provider-catalog.ts | 15 ++++- .../hooks/use-acp-provider-catalog.test.ts | 36 +++++++++--- .../app/src/hooks/use-acp-provider-catalog.ts | 8 ++- .../screens/settings/providers-section.tsx | 12 ++-- .../src/server/acp-internal/connection.ts | 29 +++++++--- packages/plugin/src/server/acp.test.ts | 56 +++++++++++++++++++ .../src/messages.checkout-pr-schema.test.ts | 21 +++---- packages/protocol/src/messages.ts | 2 + packages/protocol/src/provider-icon-names.ts | 1 + .../src/server/daemon-e2e/fx-fixture.cjs | 44 +++++++++++++++ .../src/server/daemon-e2e/models.e2e.test.ts | 40 +++++++++++++ .../src/server/plugins/builtin/index.ts | 1 + .../websocket-server.relay-reconnect.test.ts | 1 + .../server/src/server/websocket-server.ts | 2 + plugins/fx-provider/icon.svg | 1 + plugins/fx-provider/index.server.ts | 7 +++ plugins/fx-provider/paseo-plugin.json | 1 + plugins/fx-provider/server/file-tools.test.ts | 47 ++++++++++++++++ plugins/fx-provider/server/file-tools.ts | 26 +++++++++ plugins/fx-provider/server/provider.ts | 17 ++++++ public-docs/supported-providers.md | 15 +++++ public-docs/supported-providers.zh-CN.md | 7 +++ 25 files changed, 381 insertions(+), 38 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/fx-fixture.cjs create mode 100644 plugins/fx-provider/icon.svg create mode 100644 plugins/fx-provider/index.server.ts create mode 100644 plugins/fx-provider/paseo-plugin.json create mode 100644 plugins/fx-provider/server/file-tools.test.ts create mode 100644 plugins/fx-provider/server/file-tools.ts create mode 100644 plugins/fx-provider/server/provider.ts diff --git a/docs/plugins.md b/docs/plugins.md index 3fa61d1457..f76bb00905 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -118,7 +118,7 @@ appear in the plugin catalog. Editing one in development requires a daemon resta Directory, Git, and npm installs cannot use a built-in ID. A provider registration with `requiresConfiguration: true` participates only while its ID exists -under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }` +under `agents.providers`. Hoplite and fx use this for catalog opt-in. Add writes `{ "enabled": true }` without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting `enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent provider does not enter executable resolution or catalog discovery. diff --git a/packages/app/e2e/browser/acp-provider-catalog.spec.ts b/packages/app/e2e/browser/acp-provider-catalog.spec.ts index ceca7489e3..0f01e02ded 100644 --- a/packages/app/e2e/browser/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts @@ -28,7 +28,7 @@ const ACP_PROVIDER = { interface ProviderCatalogDaemonClient { getLastServerInfoMessage(): { - features?: { hopliteProvider?: boolean; plugins?: boolean }; + features?: { fxProvider?: boolean; hopliteProvider?: boolean; plugins?: boolean }; } | null; connect(): Promise; close(): Promise; @@ -58,6 +58,31 @@ test.describe("ACP provider catalog", () => { }); }); +test("adds fx from the catalog with only a config entry", async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "fx-catalog-e2e", + }); + try { + expect(client.getLastServerInfoMessage()?.features?.fxProvider).toBe(true); + await client.patchDaemonConfig({ removeProviders: ["fx"] }); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await openAddProviderArea(page); + + await findAcpCatalogProvider(page, "fx"); + await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") }); + await installAcpCatalogProvider(page, "fx"); + await expectProviderInstalledInSettings(page, "fx"); + await page.screenshot({ path: testInfo.outputPath("fx-installed.png") }); + expect((await client.getDaemonConfig()).config.providers?.fx).toEqual({ enabled: true }); + } finally { + await client.patchDaemonConfig({ removeProviders: ["fx"] }).catch(() => undefined); + await client.close().catch(() => undefined); + } +}); + for (const theme of ["light", "dark"] as const) { test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => { const client = await connectDaemonClient({ diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index 703473e6be..c04e0c42af 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -38,6 +38,7 @@ export const ACP_PROVIDER_ICON_SVGS = { '\n', "fast-agent": '\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n', + fx: '', gemini: '\n \n\n', "github-copilot-cli": diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 46b7d81a2d..bfa7ffe3cf 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -1,4 +1,5 @@ import { ACP_PROVIDER_ICON_SVGS } from "@/assets/acp-provider-icons"; +import type { HostFeatureName } from "@/runtime/host-features"; export interface AcpProviderCatalogEntry { id: string; @@ -8,7 +9,7 @@ export interface AcpProviderCatalogEntry { iconSvg: string | null; installLink: string; command: readonly [string, ...string[]]; - bundledProvider?: boolean; + bundledProvider?: { readonly hostFeature: HostFeatureName }; env?: Readonly>; params?: Readonly>; } @@ -179,6 +180,16 @@ const CATALOG_DATA = [ installLink: "https://fast-agent.ai/acp/", command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"], }, + { + id: "fx", + bundledProvider: { hostFeature: "fxProvider" }, + title: "fx", + description: "Vercel Labs' tiny, open, native coding agent", + version: "manual", + iconId: "fx", + installLink: "https://fx.sh/docs/getting-started/installation", + command: ["fx", "acp"], + }, { id: "gemini", title: "Gemini CLI", @@ -241,7 +252,7 @@ const CATALOG_DATA = [ }, { id: "hoplite", - bundledProvider: true, + bundledProvider: { hostFeature: "hopliteProvider" }, title: "Hoplite", description: "Cloud coding agent through the local hoplite acp bridge", version: "manual", diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index 41806de307..f160630508 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -52,6 +52,7 @@ describe("ACP provider catalog", () => { expect(findProvider("devin").command).toEqual(["devin", "acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); expect(findProvider("hoplite").command).toEqual(["hoplite", "acp"]); + expect(findProvider("fx").command).toEqual(["fx", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); expect(findProvider("poolside").command).toEqual(["pool", "acp"]); @@ -75,7 +76,7 @@ describe("ACP provider catalog", () => { command: ["hoplite", "acp"], installLink: "https://hoplite.sh/docs/cli#install", }); - expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({ + expect(buildAcpProviderConfigPatch(findProvider("hoplite"), () => true)).toEqual({ providers: { hoplite: { enabled: true }, }, @@ -86,14 +87,31 @@ describe("ACP provider catalog", () => { expect(findProvider("hoplite").iconSvg).toContain('fill="currentColor"'); }); - it.each([undefined, false])( - "rejects unsupported bundled Hoplite before producing a patch (%s)", - (supported) => { - expect(() => buildAcpProviderConfigPatch(findProvider("hoplite"), supported)).toThrow( - "Update the host to add Hoplite.", - ); - }, - ); + it("offers fx as a bundled provider gated on its own host feature", () => { + expect(findProvider("fx")).toMatchObject({ + title: "fx", + installLink: "https://fx.sh/docs/getting-started/installation", + }); + expect(findProvider("fx").iconSvg).toContain('fill="currentColor"'); + const supported: string[] = []; + expect( + buildAcpProviderConfigPatch(findProvider("fx"), (feature) => { + supported.push(feature); + return true; + }), + ).toEqual({ providers: { fx: { enabled: true } } }); + expect(supported).toEqual(["fxProvider"]); + }); + + it.each([ + ["hoplite", "Hoplite"], + ["fx", "fx"], + ])("rejects unsupported bundled %s before producing a patch", (id, title) => { + const unsupported = () => false; + expect(() => buildAcpProviderConfigPatch(findProvider(id), unsupported)).toThrow( + `Update the host to add ${title}.`, + ); + }); it("maps a catalog entry to the daemon provider config patch", () => { expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({ diff --git a/packages/app/src/hooks/use-acp-provider-catalog.ts b/packages/app/src/hooks/use-acp-provider-catalog.ts index f20c31caf2..9eae51f33e 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import type { MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; import { ACP_PROVIDER_CATALOG, type AcpProviderCatalogEntry } from "@/data/acp-provider-catalog"; +import type { HostFeatureName } from "@/runtime/host-features"; export type AcpProviderCatalogItem = AcpProviderCatalogEntry; @@ -10,11 +11,14 @@ export function getAcpProviderCatalog(): AcpProviderCatalogItem[] { export function buildAcpProviderConfigPatch( entry: AcpProviderCatalogItem, - supportsHopliteProvider = false, + hostSupports: (feature: HostFeatureName) => boolean = () => false, ): MutableDaemonConfigPatch { if (entry.bundledProvider) { // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. - if (!supportsHopliteProvider) throw new Error("Update the host to add Hoplite."); + // COMPAT(fxProvider): added after v0.15.3, remove after 2027-05-09 once host floor includes the bundled adapter. + if (!hostSupports(entry.bundledProvider.hostFeature)) { + throw new Error(`Update the host to add ${entry.title}.`); + } return { providers: { [entry.id]: { enabled: true } } }; } return { diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 09b09586d3..078cf086f5 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -14,7 +14,8 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-api-context"; import { settingsStyles } from "@/styles/settings"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useHostFeature } from "@/runtime/host-features"; +import { selectHostFeature, useHostFeature } from "@/runtime/host-features"; +import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; @@ -357,7 +358,6 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const { t } = useTranslation(); const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); - const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider"); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); const { config, patchConfig } = useDaemonConfig(serverId); @@ -444,7 +444,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { if (installingProviderId) return; setInstallingProviderId(entry.id); try { - await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider)); + await patchConfig( + buildAcpProviderConfigPatch(entry, (feature) => + selectHostFeature(useSessionStore.getState(), serverId, feature), + ), + ); await refresh([entry.id]); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); @@ -452,7 +456,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, - [installingProviderId, patchConfig, refresh, supportsHopliteProvider, toast], + [installingProviderId, patchConfig, refresh, serverId, toast], ); return ( diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index 86e22b2afe..cf7f1204e3 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -804,13 +804,12 @@ class AcpRuntime { this.modes = { ...this.modes, currentModeId: change.value }; return; } - let category: "model" | "thought_level" | null = null; - if (change.target === "model") category = "model"; - if (change.target === "thinking") category = "thought_level"; - const configId = - change.target === "setting" - ? change.id - : this.configOptions.find((option) => option.category === category)?.id; + let configId: string | undefined; + if (change.target === "setting") configId = change.id; + if (change.target === "model") configId = modelOption(this.configOptions)?.id; + if (change.target === "thinking") { + configId = this.configOptions.find((option) => option.category === "thought_level")?.id; + } if (!configId) { throw new Error(`ACP session does not expose ${change.target} configuration`); } @@ -1375,7 +1374,7 @@ function toProviderConfigState( modes: NewSessionResponse["modes"], options: readonly SessionConfigOption[], ): ProviderConfigState { - const model = options.find((option) => option.category === "model"); + const model = modelOption(options); const thinking = options.find((option) => option.category === "thought_level"); return { model: selectedValue(model), @@ -1393,11 +1392,23 @@ function toProviderConfigState( label: option.label, })), settings: options - .filter((option) => option.category !== "model" && option.category !== "thought_level") + .filter( + (option) => + option !== model && + option.category !== "thought_level" && + !(modes && option.category === "mode"), + ) .map(toProviderSetting), }; } +// ACP's category is a grouping hint, so one category can hold several controls. fx files its +// provider first, then the model, Fast, and Ultrafast under "model". +function modelOption(options: readonly SessionConfigOption[]): SessionConfigOption | undefined { + const candidates = options.filter((option) => option.category === "model"); + return candidates.find((option) => option.id === "model") ?? candidates[0]; +} + function toProviderCatalog( modes: NewSessionResponse["modes"], options: readonly SessionConfigOption[], diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index 1d99c73a75..47bc4917ec 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -1155,6 +1155,62 @@ lines.on("line", (line) => { }, ); + it("selects the model control when an agent files several controls under the model category", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +let model = "small"; +const modes = { currentModeId: "auto", availableModes: [{ id: "auto", name: "Auto" }, { id: "ask", name: "Ask" }] }; +const options = () => [ + { id: "provider", name: "Provider", category: "model", type: "select", currentValue: "gateway", options: [{ name: "Gateway", value: "gateway" }, { name: "Local", value: "local" }] }, + { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "Small", value: "small" }, { name: "Large", value: "large" }] }, + { id: "mode", name: "Session Mode", category: "mode", type: "select", currentValue: "auto", options: [{ name: "Auto", value: "auto" }, { name: "Ask", value: "ask" }] }, + { id: "fast", name: "Fast Mode", category: "model", type: "select", currentValue: "false", options: [{ name: "Off", value: "false" }, { name: "On", value: "true" }] }, +]; +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes, configOptions: options() } }); + else if (message.method === "session/set_config_option") { + if (message.params.configId === "model") model = message.params.value; + send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); + } +});`); + const { connection, events } = await connect(executable, [ + "prompt.message", + "session.configure", + ]); + await connection.send({ type: "catalog", requestId: "catalog-1" }); + await waitForEvent(events, (event) => event.type === "catalog"); + expect(events.find((event) => event.type === "catalog")).toMatchObject({ + catalog: { + defaultModel: "small", + models: [ + { id: "small", label: "Small" }, + { id: "large", label: "Large" }, + ], + }, + }); + + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.send({ + type: "session.configure", + requestId: "configure-1", + sessionId: "session-1", + changes: { model: "large" }, + }); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "configure-1", + ); + const config = events.findLast((event) => event.type === "session.config"); + expect(config).toMatchObject({ config: { model: "large" } }); + if (config?.type !== "session.config") throw new Error("Expected session.config"); + expect(config.config.settings?.map((setting) => setting.id)).toEqual(["provider", "fast"]); + await connection.close(); + }); + it("gives discovery transformers the discovered session configuration facade", async () => { const executable = await fakeAcp(`const readline = require("node:readline"); const lines = readline.createInterface({ input: process.stdin }); diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index 3966d6eb71..db412a831a 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -813,13 +813,14 @@ describe("checkout PR schemas", () => { }); }); -test.each([undefined, false, true])( - "accepts optional Hoplite capability (%s)", - (hopliteProvider) => { - const features = hopliteProvider === undefined ? {} : { hopliteProvider }; - expect( - ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features }) - .features, - ).toEqual(features); - }, -); +test.each( + ["hopliteProvider", "fxProvider"].flatMap((name) => + [undefined, false, true].map((value) => [name, value] as const), + ), +)("accepts optional bundled provider capability (%s: %s)", (name, value) => { + const features = value === undefined ? {} : { [name]: value }; + expect( + ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features }) + .features, + ).toEqual(features); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 94d6190f57..e016275c02 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4065,6 +4065,8 @@ export const ServerInfoStatusPayloadSchema = z providerRemoval: z.boolean().optional(), // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. hopliteProvider: z.boolean().optional(), + // COMPAT(fxProvider): added after v0.15.3, remove after 2027-05-09 once host floor includes the bundled adapter. + fxProvider: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), // COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02. diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 5f707dfeff..37dac496dc 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -28,6 +28,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "dirac", "factory-droid", "fast-agent", + "fx", "gemini", "gjc", "glm-acp-agent", diff --git a/packages/server/src/server/daemon-e2e/fx-fixture.cjs b/packages/server/src/server/daemon-e2e/fx-fixture.cjs new file mode 100644 index 0000000000..b702ac6c4f --- /dev/null +++ b/packages/server/src/server/daemon-e2e/fx-fixture.cjs @@ -0,0 +1,44 @@ +// Local ACP peer shaped like `fx acp` 0.0.16; no fx install or credentials. +const fs = require("node:fs"); +const readline = require("node:readline"); +fs.appendFileSync(process.env.FX_TEST_RECORD, "launch\n"); +let model = "fixture-small"; +const modes = { + currentModeId: "auto", + availableModes: [ + { id: "auto", name: "Auto" }, + { id: "ask", name: "Ask" }, + { id: "full-access", name: "Full access" }, + ], +}; +const select = (id, name, category, currentValue, values) => ({ + id, + name, + category, + type: "select", + currentValue, + options: values.map((value) => ({ value, name: value })), +}); +const configOptions = () => [ + select("provider", "Provider", "model", "gateway", ["gateway", "codex", "grok"]), + select("model", "Model", "model", model, ["fixture-small", "fixture-large"]), + select("mode", "Session Mode", "mode", modes.currentModeId, ["auto", "ask", "full-access"]), + select("fast", "Fast Mode", "model", "false", ["false", "true"]), +]; +const send = (message) => + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + let result = {}; + if (request.method === "initialize") + result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new") + result = { sessionId: "fx-fixture", modes, configOptions: configOptions() }; + if (request.method === "session/set_config_option") { + if (request.params.configId === "model") model = request.params.value; + result = { configOptions: configOptions() }; + } + if (request.method === "session/prompt") result = { stopReason: "end_turn" }; + send({ id: request.id, result }); +}); diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index cfa420654b..a2064da0f1 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -164,6 +164,46 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ } }, 120000); +test("bundled fx requires a config entry and lists models instead of its providers", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-fx-optin-")); + const record = path.join(root, "launches.txt"); + await writeFile(record, ""); + const daemon = await createTestPaseoDaemon({ + paseoHomeRoot: root, + cleanup: false, + mcpEnabled: false, + builtinPlugins: new BuiltinPluginLoader(undefined, ["fx-provider"]), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.15.3", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + expect(client.getLastServerInfoMessage()?.features?.fxProvider).toBe(true); + const before = await client.getProvidersSnapshot(); + expect(before.entries.some((entry) => entry.provider === "fx")).toBe(false); + expect(await readFile(record, "utf8")).toBe(""); + + await client.patchDaemonConfig({ + providers: { + fx: { + enabled: true, + command: [process.execPath, path.resolve(import.meta.dirname, "fx-fixture.cjs")], + env: { FX_TEST_RECORD: record }, + }, + }, + }); + const { models } = await client.listProviderModels("fx", { cwd: root }); + expect(models.map((model) => model.id)).toEqual(["fixture-small", "fixture-large"]); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60000); + test("bundled Hoplite requires a config entry and removal survives restart", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-hoplite-optin-")); const record = path.join(root, "launches.txt"); diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index 463ee8407d..d51f6ee4d8 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -8,6 +8,7 @@ export const builtinPlugins = [ "codex-usage-source", "copilot-usage-source", "cursor-usage-source", + "fx-provider", "grok-usage-source", "hoplite-provider", "kimi-usage-source", diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index c99d5ac9cc..7dcb02669c 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1153,6 +1153,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); expect(serverInfo.features?.hopliteProvider).toBe(false); + expect(serverInfo.features?.fxProvider).toBe(false); expect(serverInfo.features?.pluginLogs).toBe(true); expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index e507da044b..ccb82d4832 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2187,6 +2187,8 @@ export class VoiceAssistantWebSocketServer { // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"), + // COMPAT(fxProvider): added after v0.15.3, remove after 2027-05-09 once host floor includes the bundled adapter. + fxProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("fx"), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. diff --git a/plugins/fx-provider/icon.svg b/plugins/fx-provider/icon.svg new file mode 100644 index 0000000000..82849ca8d2 --- /dev/null +++ b/plugins/fx-provider/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/fx-provider/index.server.ts b/plugins/fx-provider/index.server.ts new file mode 100644 index 0000000000..564e989aba --- /dev/null +++ b/plugins/fx-provider/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { createFxProvider } from "./server/provider.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createFxProvider()); + return () => {}; +} diff --git a/plugins/fx-provider/paseo-plugin.json b/plugins/fx-provider/paseo-plugin.json new file mode 100644 index 0000000000..2628ef32be --- /dev/null +++ b/plugins/fx-provider/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "fx-provider", "requirements": { "paseo": ">=0.11.0" } } diff --git a/plugins/fx-provider/server/file-tools.test.ts b/plugins/fx-provider/server/file-tools.test.ts new file mode 100644 index 0000000000..23b4dafdee --- /dev/null +++ b/plugins/fx-provider/server/file-tools.test.ts @@ -0,0 +1,47 @@ +import type { AcpToolCallSnapshot } from "@getpaseo/plugin/server/acp"; +import { describe, expect, it } from "vitest"; +import { fileToolTransformer } from "./file-tools.js"; + +const context = { sessionId: "session-1" }; + +// Shapes captured from `fx acp` 0.0.16. +function fxToolCall(name: string, input: AcpToolCallSnapshot["input"]): AcpToolCallSnapshot { + return { + id: "call_1", + name, + title: "Writing", + kind: "edit", + status: "pending", + input, + output: null, + locations: [], + }; +} + +function transform(toolCall: AcpToolCallSnapshot): AcpToolCallSnapshot { + return fileToolTransformer.toolCall!(toolCall, context); +} + +describe("fx file tool calls", () => { + it("presents write_file as a write to its path", () => { + expect( + transform(fxToolCall("write_file", { path: "hello.txt", content: "hi\n" })).input, + ).toEqual({ filePath: "hello.txt", newString: "hi\n" }); + }); + + it("presents edit_file as a replacement in its path", () => { + expect( + transform(fxToolCall("edit_file", { path: "a.ts", old_string: "a", new_string: "b" })).input, + ).toEqual({ filePath: "a.ts", oldString: "a", newString: "b" }); + }); + + it("keeps the presentation when a later update reaches the same call", () => { + const presented = transform(fxToolCall("write_file", { path: "hello.txt", content: "hi\n" })); + expect(transform({ ...presented, status: "completed" }).input).toEqual(presented.input); + }); + + it("leaves other tools alone", () => { + const shell = fxToolCall("shell", { path: "/tmp", command: "ls" }); + expect(transform(shell)).toEqual(shell); + }); +}); diff --git a/plugins/fx-provider/server/file-tools.ts b/plugins/fx-provider/server/file-tools.ts new file mode 100644 index 0000000000..e955b4b793 --- /dev/null +++ b/plugins/fx-provider/server/file-tools.ts @@ -0,0 +1,26 @@ +import type { AcpToolCallSnapshot, AcpTransformer } from "@getpaseo/plugin/server/acp"; + +type ToolInput = AcpToolCallSnapshot["input"]; + +// fx names its file arguments for the model (`path`, `old_string`), so its file tools need the +// argument names Paseo's edit rows read. The adapter reapplies this to each update of a call. +export const fileToolTransformer: AcpTransformer = { + toolCall(toolCall) { + const input = toEditRowInput(toolCall.name, toolCall.input); + return input ? { ...toolCall, input } : toolCall; + }, +}; + +function toEditRowInput(tool: string | undefined, input: ToolInput): ToolInput | null { + if (typeof input !== "object" || input === null || Array.isArray(input)) return null; + if (typeof input.path !== "string") return null; + if (tool === "write_file") return { filePath: input.path, newString: input.content ?? null }; + if (tool === "edit_file") { + return { + filePath: input.path, + oldString: input.old_string ?? null, + newString: input.new_string ?? null, + }; + } + return null; +} diff --git a/plugins/fx-provider/server/provider.ts b/plugins/fx-provider/server/provider.ts new file mode 100644 index 0000000000..f7cc53526e --- /dev/null +++ b/plugins/fx-provider/server/provider.ts @@ -0,0 +1,17 @@ +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; +import { fileToolTransformer } from "./file-tools.js"; + +export function createFxProvider(): ProviderRegistration { + return { + requiresConfiguration: true, + ...runAcpProvider({ + id: "fx", + label: "fx", + description: "Vercel Labs' native coding agent through `fx acp`", + icon: "icon.svg", + command: ["fx", "acp"], + transformers: [fileToolTransformer], + }), + }; +} diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index 1aa981ba29..1fe249a1e0 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -78,6 +78,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [Dirac](https://dirac.run), open-source agent with hash-anchored parallel edits. - [Factory Droid](https://factory.ai/product/cli), Factory AI's coding agent. - [fast-agent](https://fast-agent.ai/acp/), multi-provider coding agent. +- [fx](#fx), Vercel Labs' native coding agent. - [Gajae Code](https://gajae-code.com), subscription-based coding agent with plan-before-mutation workflows. - [Gemini CLI](https://geminicli.com), Google's official Gemini CLI. - [GitHub Copilot](https://github.com/features/copilot/cli/), GitHub's AI pair programmer via ACP. @@ -102,6 +103,20 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers). +### fx + +[Install fx](https://fx.sh/docs/getting-started/installation) on your daemon host and +[connect a provider](https://fx.sh/docs/getting-started/authentication), for example with +`fx login`. In **Settings → Hosts → your host → Providers**, find **fx** in the catalog and +choose **Add**. Paseo launches `fx acp` and uses your fx credentials and settings. Until fx has +credentials, the provider reports the sign-in message from fx. + +The model list comes from your active fx provider, so run `fx provider` to change the list a new +agent starts from. Changing an agent's permission mode also changes fx's saved permission mode. +See [fx's ACP guide](https://fx.sh/docs/using-fx/acp) for what fx supports. Use a +[command override](/docs/custom-providers) if `fx` is outside your daemon's PATH; keep `acp` in +the arguments. + ### Hoplite Install the [Hoplite CLI](https://hoplite.sh/docs/cli) on your daemon host and run diff --git a/public-docs/supported-providers.zh-CN.md b/public-docs/supported-providers.zh-CN.md index d57505f0a6..afb7538431 100644 --- a/public-docs/supported-providers.zh-CN.md +++ b/public-docs/supported-providers.zh-CN.md @@ -68,6 +68,7 @@ Paseo MCP 工具和引导功能不可用。你自己为 `agy` 配置的 MCP 服 - [Dirac](https://dirac.run):开源 Agent,支持基于哈希锚点的并行编辑。 - [Factory Droid](https://factory.ai/product/cli):Factory AI 的编程 Agent。 - [fast-agent](https://fast-agent.ai/acp/):支持多家 Provider 的编程 Agent。 +- [fx](#fx):Vercel Labs 的原生编程 Agent。 - [Gajae Code](https://gajae-code.com):订阅制编程 Agent,采用先规划再修改的工作流。 - [Gemini CLI](https://geminicli.com):Google 官方的 Gemini CLI。 - [GitHub Copilot](https://github.com/features/copilot/cli/):通过 ACP 接入的 GitHub AI 结对编程助手。 @@ -92,6 +93,12 @@ Paseo MCP 工具和引导功能不可用。你自己为 `agy` 配置的 MCP 服 应用内目录是权威的、固定版本的来源。这里未列出的 Agent 仍然可以手动添加,参见[自定义 Provider](/docs/custom-providers)。 +### fx {#fx} + +在 daemon 主机上[安装 fx](https://fx.sh/docs/getting-started/installation),并[连接一个模型提供方](https://fx.sh/docs/getting-started/authentication),例如运行 `fx login`。在 **设置 → Hosts → 你的主机 → Providers** 中,在目录里找到 **fx** 并选择 **Add**。Paseo 会启动 `fx acp`,并使用你的 fx 凭据和设置。在 fx 拿到凭据之前,该 provider 会显示 fx 给出的登录提示。 + +模型列表来自当前的 fx 提供方,运行 `fx provider` 可以更改新 Agent 使用的列表。更改 Agent 的权限模式也会更改 fx 保存的权限模式。fx 支持的功能见 [fx 的 ACP 指南](https://fx.sh/docs/using-fx/acp)。如果 `fx` 不在 daemon 的 PATH 中,请使用[命令覆盖](/docs/custom-providers),并在参数中保留 `acp`。 + ### Hoplite {#hoplite} 在 daemon 主机上安装 [Hoplite CLI](https://hoplite.sh/docs/cli) 并运行 `hoplite login`。在 **设置 → Hosts → 你的主机 → Providers** 中,在目录里找到 **Hoplite** 并选择 **添加**。Paseo 会通过通用 ACP 适配器启动 `hoplite acp`。