mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
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.
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -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<ProviderCatalogDaemonClient>({
|
||||
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<ProviderCatalogDaemonClient>({
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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<Record<string, string>>;
|
||||
params?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
|
||||
"dirac",
|
||||
"factory-droid",
|
||||
"fast-agent",
|
||||
"fx",
|
||||
"gemini",
|
||||
"gjc",
|
||||
"glm-acp-agent",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="166.241 0 155.861 155.75" fill="currentColor"><path d="M237.892 0C243.178 8.73262e-05 249.383 1.41857 253.025 3.07422L255.093 4.01367L250.076 18.6299L247.677 17.7461C244.901 16.7234 241.938 15.7998 238.491 15.7998C234.984 15.7998 232.787 16.5601 231.08 18.3213C229.231 20.2297 227.629 23.6395 226.229 29.7578L226.227 29.7617L226.228 29.7627L225.136 34.8506H241.669L260.432 34.9502L260.694 34.9512L260.842 35.1689L278.854 61.6328L296.736 34.9502H320.866L291.676 76.7402L322.102 119.75H299.331L299.182 119.546L241.144 40.4775L239.35 49.4004H222.069L205.688 127.208C203.927 135.709 201.185 142.838 196.777 147.866C192.27 153.008 186.201 155.75 178.342 155.75C174.181 155.75 170.746 155.109 167.909 154.107L166.241 153.519V137.182L166.899 137.4L169.531 138.278C172.182 139.162 174.414 139.8 177.142 139.8C178.528 139.8 179.703 139.526 180.732 138.98C181.764 138.434 182.677 137.6 183.52 136.442C185.304 133.989 186.716 130.135 187.897 124.672L203.76 49.4004H189.874L191.759 39.4375L192.039 39.3457L206.818 34.4668L208.154 28.6416C210.516 18.2065 213.772 10.9381 218.708 6.31836C223.737 1.61217 230.127 3.08752e-05 237.892 0ZM273.987 99.084L260.073 120.25H234.541L261.003 82.0215L273.987 99.084Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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 () => {};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "fx-provider", "requirements": { "paseo": ">=0.11.0" } }
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user