mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
feat(providers): make bundled Hoplite opt-in from the catalog
Keep the contributor ACP adapter and provisioning progress while activating it only when a provider config entry exists. Removing the entry removes membership across restarts; existing provider defaults remain unchanged. Present the Add provider catalog as a searchable grid and gate Hoplite activation on host support. Co-authored-by: Odin Glynn-Martin <odin.glynn@icloud.com>
This commit is contained in:
co-authored by
Odin Glynn-Martin
parent
665e15ed31
commit
95ea57746a
@@ -1121,3 +1121,50 @@ describe("plugin provider launch and status", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("config-required plugin membership is independent of enablement and other providers", async () => {
|
||||
const logger = createTestLogger();
|
||||
const plugins = new PluginAgentClientRegistry(logger);
|
||||
const harness = createProviderHarness();
|
||||
let connections = 0;
|
||||
let probes = 0;
|
||||
plugins.replace([
|
||||
{
|
||||
...harness.registration,
|
||||
requiresConfiguration: true,
|
||||
async status() {
|
||||
probes++;
|
||||
return { available: true };
|
||||
},
|
||||
async connect(request) {
|
||||
connections++;
|
||||
return harness.registration.connect(request);
|
||||
},
|
||||
},
|
||||
]);
|
||||
const build = (providerOverrides = {}) =>
|
||||
buildProviderRegistry(logger, {
|
||||
pluginProviders: plugins.definitions(),
|
||||
providerOverrides,
|
||||
});
|
||||
try {
|
||||
const before = build();
|
||||
expect(before["plugin-direct"]).toBeUndefined();
|
||||
expect({ probes, connections }).toEqual({ probes: 0, connections: 0 });
|
||||
const added = build({ "plugin-direct": {} });
|
||||
expect(added["plugin-direct"].enabled).toBe(true);
|
||||
const client = added["plugin-direct"].createClient(logger);
|
||||
expect(await client.isAvailable()).toBe(true);
|
||||
const catalog = await client.fetchCatalog({ scope: "global" });
|
||||
expect(catalog.models.map((model) => model.id)).toEqual(["plugin-model"]);
|
||||
expect({ probes, connections }).toEqual({ probes: 1, connections: 1 });
|
||||
const disabled = build({ "plugin-direct": { enabled: false } });
|
||||
expect(disabled["plugin-direct"].enabled).toBe(false);
|
||||
expect(Object.keys(build())).toEqual(Object.keys(before));
|
||||
for (const id of Object.keys(before)) {
|
||||
expect(added[id].enabled).toBe(before[id].enabled);
|
||||
}
|
||||
} finally {
|
||||
await plugins.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -885,6 +885,7 @@ function createPluginProviderDefinition(
|
||||
label: registration.label,
|
||||
description: registration.description ?? `Plugin provider ${registration.label}`,
|
||||
iconSvg: registration.icon,
|
||||
requiresConfiguration: registration.requiresConfiguration,
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
supportsExactMcpPreapproval: true,
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
|
||||
/** Provider metadata and an unconfigured factory; the registry owns override application. */
|
||||
export interface RegisteredProviderDefinition extends AgentProviderDefinition {
|
||||
requiresConfiguration?: boolean;
|
||||
iconSvg?: string;
|
||||
supportsExactMcpPreapproval: boolean;
|
||||
createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient;
|
||||
@@ -925,6 +926,8 @@ export function buildProviderRegistry(
|
||||
throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`);
|
||||
}
|
||||
const override = providerOverrides[provider];
|
||||
if (definition.requiresConfiguration && !override) continue;
|
||||
|
||||
if (override?.extends) {
|
||||
logger.warn({ provider }, "Plugin provider shadowed by configured provider");
|
||||
continue;
|
||||
|
||||
@@ -348,6 +348,10 @@ export class ProviderSnapshotManager {
|
||||
return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider);
|
||||
}
|
||||
|
||||
supportsConfigRequiredProvider(provider: AgentProvider): boolean {
|
||||
return this.pluginProviders.definitions()[provider]?.requiresConfiguration === true;
|
||||
}
|
||||
|
||||
getProviderLabel(provider: AgentProvider): string {
|
||||
return this.generation.definitions[provider]?.label ?? provider;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Local ACP peer for the bundled Hoplite adapter; no Hoplite service or credentials.
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
fs.appendFileSync(process.env.HOPLITE_TEST_RECORD, "launch\n");
|
||||
const configOptions = [
|
||||
{
|
||||
id: "model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
name: "Model",
|
||||
currentValue: "fixture-model",
|
||||
options: [{ value: "fixture-model", name: "Fixture model" }],
|
||||
},
|
||||
];
|
||||
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: "hoplite-fixture",
|
||||
configOptions,
|
||||
};
|
||||
if (request.method === "session/set_config_option") result = { configOptions };
|
||||
if (request.method === "session/prompt") {
|
||||
send({
|
||||
method: "_hoplite/run_phase",
|
||||
params: { runId: "fixture-run", phase: "provisioning", label: "Preparing fixture workspace" },
|
||||
});
|
||||
result = { stopReason: "end_turn" };
|
||||
}
|
||||
send({ id: request.id, result });
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { findExecutable } from "../../executable-resolution/executable-resolution.js";
|
||||
import { BuiltinPluginLoader } from "../plugins/builtin/index.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
@@ -165,3 +166,125 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
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");
|
||||
await writeFile(record, "");
|
||||
const options = {
|
||||
paseoHomeRoot: root,
|
||||
cleanup: false,
|
||||
mcpEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(undefined, [
|
||||
"hoplite-provider",
|
||||
"antigravity-provider",
|
||||
]),
|
||||
};
|
||||
let daemon = await createTestPaseoDaemon(options);
|
||||
const connect = async () => {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.11.2",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
return client;
|
||||
};
|
||||
let client = await connect();
|
||||
try {
|
||||
const before = await client.getProvidersSnapshot();
|
||||
expect(before.entries.some((entry) => entry.provider === "hoplite")).toBe(false);
|
||||
expect(before.entries.some((entry) => entry.provider === "antigravity")).toBe(true);
|
||||
expect(await readFile(record, "utf8")).toBe("");
|
||||
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
hoplite: {
|
||||
enabled: true,
|
||||
command: [process.execPath, path.resolve(import.meta.dirname, "hoplite-fixture.cjs")],
|
||||
env: { HOPLITE_TEST_RECORD: record },
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot()).entries.find(
|
||||
(entry) => entry.provider === "hoplite",
|
||||
)?.status,
|
||||
)
|
||||
.toBe("ready");
|
||||
expect((await client.listProviderModels("hoplite")).models.map((model) => model.id)).toEqual([
|
||||
"fixture-model",
|
||||
]);
|
||||
const agent = await client.createAgent({
|
||||
provider: "hoplite",
|
||||
cwd: root,
|
||||
model: "fixture-model",
|
||||
});
|
||||
await client.sendMessage(agent.id, "Exercise the contributor transformer");
|
||||
await client.waitForFinish(agent.id, 10000);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
|
||||
expect(timeline.entries.map((entry) => entry.item)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
name: "Hoplite workspace",
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
await client.archiveAgent(agent.id);
|
||||
expect(await readFile(record, "utf8")).toContain("launch");
|
||||
|
||||
await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } });
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite")
|
||||
?.enabled,
|
||||
).toBe(false);
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
daemon = await createTestPaseoDaemon({
|
||||
...options,
|
||||
staticDir: daemon.staticDir,
|
||||
providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides,
|
||||
});
|
||||
client = await connect();
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite")
|
||||
?.enabled,
|
||||
).toBe(false);
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
|
||||
).toBe(false);
|
||||
expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
|
||||
const persisted = JSON.parse(
|
||||
await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(persisted.agents?.providers?.hoplite).toBeUndefined();
|
||||
const launchesAfterRemoval = await readFile(record, "utf8");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
daemon = await createTestPaseoDaemon({
|
||||
...options,
|
||||
staticDir: daemon.staticDir,
|
||||
providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides,
|
||||
});
|
||||
client = await connect();
|
||||
const restarted = await client.getProvidersSnapshot();
|
||||
expect(restarted.entries.some((entry) => entry.provider === "hoplite")).toBe(false);
|
||||
expect(restarted.entries.map((entry) => entry.provider)).toEqual(
|
||||
before.entries.map((entry) => entry.provider),
|
||||
);
|
||||
expect(await readFile(record, "utf8")).toBe(launchesAfterRemoval);
|
||||
await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } });
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite")
|
||||
?.enabled,
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(daemon.staticDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
@@ -9,6 +9,7 @@ export const builtinPlugins = [
|
||||
"copilot-usage-source",
|
||||
"cursor-usage-source",
|
||||
"grok-usage-source",
|
||||
"hoplite-provider",
|
||||
"kimi-usage-source",
|
||||
"minimax-usage-source",
|
||||
"muse-provider",
|
||||
|
||||
@@ -629,6 +629,7 @@ export class PluginService {
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
command: provider.command,
|
||||
requiresConfiguration: provider.requiresConfiguration,
|
||||
status: provider.hasStatus
|
||||
? (request) => {
|
||||
if (!this.runtime.getProviderStatus)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { z } from "zod";
|
||||
|
||||
export interface PluginProviderMetadata {
|
||||
requiresConfiguration?: boolean;
|
||||
hasCatalogCacheKey?: boolean;
|
||||
hasStatus?: boolean;
|
||||
command?: readonly [string, ...string[]];
|
||||
@@ -112,6 +113,7 @@ const providerMetadataSchema = z
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
iconPath: z.string().optional(),
|
||||
requiresConfiguration: z.boolean().optional(),
|
||||
hasCatalogCacheKey: z.boolean().optional(),
|
||||
hasStatus: z.boolean().optional(),
|
||||
command: z.tuple([z.string().min(1)], z.string()).optional(),
|
||||
|
||||
@@ -158,6 +158,7 @@ export function createPluginWorker(options: {
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
iconPath: provider.icon,
|
||||
requiresConfiguration: provider.requiresConfiguration,
|
||||
hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined,
|
||||
hasStatus: provider.status !== undefined,
|
||||
command: provider.command,
|
||||
|
||||
@@ -1915,6 +1915,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
commitBaseClassification: true,
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: true,
|
||||
hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"),
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user