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`。