From 16bb179e67f361817eda76537381c3b135a8ef75 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 21:25:23 +0700 Subject: [PATCH] feat(providers): add opt-in Hoplite and searchable provider grid (#6953) Preserve the bundled Hoplite ACP adapter and provisioning transformer, activate it only after Add, and provide a searchable provider grid. Restore the contributor's original icon without the copyright mark. Co-authored-by: Odin Glynn-Martin --- docs/custom-providers.md | 2 +- docs/plugins.md | 6 + .../e2e/browser/acp-provider-catalog.spec.ts | 137 +++++++- .../agent-timeline-scroll-smoothness.spec.ts | 52 +++ packages/app/e2e/support/helpers/settings.ts | 2 +- .../helpers/timeline-scroll-smoothness.ts | 36 ++- packages/app/src/assets/acp-provider-icons.ts | 2 + .../src/assets/acp-provider-icons/hoplite.svg | 1 + .../src/components/provider-catalog-list.tsx | 304 ++++++++---------- .../provider-catalog-search.test.ts | 45 +++ .../src/components/provider-catalog-search.ts | 34 ++ packages/app/src/data/acp-provider-catalog.ts | 12 + .../hooks/use-acp-provider-catalog.test.ts | 27 ++ .../app/src/hooks/use-acp-provider-catalog.ts | 6 + packages/app/src/i18n/resources/ar.ts | 5 + packages/app/src/i18n/resources/en.ts | 5 + packages/app/src/i18n/resources/es.ts | 5 + packages/app/src/i18n/resources/fr.ts | 5 + packages/app/src/i18n/resources/ja.ts | 5 + packages/app/src/i18n/resources/ko.ts | 5 + packages/app/src/i18n/resources/pt-BR.ts | 5 + packages/app/src/i18n/resources/ru.ts | 5 + packages/app/src/i18n/resources/zh-CN.ts | 5 + .../screens/settings/providers-section.tsx | 29 +- .../src/server/acp-internal/connection.ts | 40 ++- packages/plugin/src/server/acp.test.ts | 140 ++++++++ packages/plugin/src/server/provider.ts | 2 + .../src/messages.checkout-pr-schema.test.ts | 11 + packages/protocol/src/messages.ts | 2 + packages/protocol/src/provider-icon-names.ts | 1 + .../src/server/agent/plugin-provider.test.ts | 47 +++ .../src/server/agent/plugin-provider.ts | 1 + .../src/server/agent/provider-registry.ts | 3 + .../server/agent/provider-snapshot-manager.ts | 4 + .../src/server/daemon-e2e/hoplite-fixture.cjs | 37 +++ .../src/server/daemon-e2e/models.e2e.test.ts | 125 ++++++- .../src/server/plugins/builtin/index.ts | 1 + packages/server/src/server/plugins/index.ts | 1 + .../server/plugins/plugin-process-protocol.ts | 2 + .../src/server/plugins/plugin-process.ts | 1 + .../src/server/test-utils/session-stubs.ts | 1 + .../websocket-server.relay-reconnect.test.ts | 1 + .../server/src/server/websocket-server.ts | 1 + packages/server/vitest.config.ts | 6 +- plugins/hoplite-provider/icon.svg | 1 + plugins/hoplite-provider/index.server.ts | 7 + plugins/hoplite-provider/paseo-plugin.json | 1 + plugins/hoplite-provider/server/provider.ts | 17 + .../hoplite-provider/server/run-phase.test.ts | 74 +++++ plugins/hoplite-provider/server/run-phase.ts | 39 +++ public-docs/supported-providers.md | 16 + 51 files changed, 1126 insertions(+), 196 deletions(-) create mode 100644 packages/app/src/assets/acp-provider-icons/hoplite.svg create mode 100644 packages/app/src/components/provider-catalog-search.test.ts create mode 100644 packages/app/src/components/provider-catalog-search.ts create mode 100644 packages/server/src/server/daemon-e2e/hoplite-fixture.cjs create mode 100644 plugins/hoplite-provider/icon.svg create mode 100644 plugins/hoplite-provider/index.server.ts create mode 100644 plugins/hoplite-provider/paseo-plugin.json create mode 100644 plugins/hoplite-provider/server/provider.ts create mode 100644 plugins/hoplite-provider/server/run-phase.test.ts create mode 100644 plugins/hoplite-provider/server/run-phase.ts diff --git a/docs/custom-providers.md b/docs/custom-providers.md index efc5fdf24f..a6c6cefbdc 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout. -Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below. +Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead. ### Adding a generic ACP provider diff --git a/docs/plugins.md b/docs/plugins.md index 866c74128e..4e1985ea81 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -107,6 +107,12 @@ absent from the installed plugin list and source configuration; their client bun appear in the plugin catalog. Editing one in development requires a daemon restart. 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 }` +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. + Provider plugins use separate installation and provider IDs. `muse-provider` registers the selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports availability and a diagnostic after the daemon resolves the executable. Configure command, diff --git a/packages/app/e2e/browser/acp-provider-catalog.spec.ts b/packages/app/e2e/browser/acp-provider-catalog.spec.ts index a0d94e5c02..d6ebb8f547 100644 --- a/packages/app/e2e/browser/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts @@ -1,13 +1,24 @@ -import { test } from "../support/fixtures"; +import { + closeModelPicker, + openModelPicker, + seedAgentProfiles, +} from "../support/helpers/agent-profiles"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { writeFile } from "node:fs/promises"; +import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { getServerId } from "../support/helpers/server-id"; import { expectProviderInstalledInSettings, + findAcpCatalogProvider, installAcpCatalogProvider, openAddProviderArea, openSettingsHost, openSettingsHostSection, + seedSavedSettingsHosts, } from "../support/helpers/settings"; const ACP_PROVIDER = { @@ -16,8 +27,13 @@ const ACP_PROVIDER = { }; interface ProviderCatalogDaemonClient { + getLastServerInfoMessage(): { + features?: { hopliteProvider?: boolean; plugins?: boolean }; + } | null; connect(): Promise; close(): Promise; + getDaemonConfig(): Promise<{ config: { providers?: Record } }>; + getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>; patchDaemonConfig(config: { removeProviders?: string[] }): Promise; } @@ -41,3 +57,122 @@ test.describe("ACP provider catalog", () => { } }); }); + +for (const theme of ["light", "dark"] as const) { + test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-catalog-e2e", + }); + try { + expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true); + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value })); + }, theme); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + await openAddProviderArea(page); + await findAcpCatalogProvider(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) }); + await installAcpCatalogProvider(page, "Hoplite"); + await expectProviderInstalledInSettings(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) }); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true }); + await writeFile( + testInfo.outputPath(`hoplite-added-${theme}.json`), + JSON.stringify(await client.getProvidersSnapshot(), null, 2), + ); + await removeHoplite(page); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined(); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + await writeFile( + testInfo.outputPath(`hoplite-removed-${theme}.json`), + JSON.stringify(await client.getProvidersSnapshot(), null, 2), + ); + await openAddProviderArea(page); + await findAcpCatalogProvider(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) }); + await installAcpCatalogProvider(page, "Hoplite"); + await expectProviderInstalledInSettings(page, "Hoplite"); + } finally { + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined); + await client.close().catch(() => undefined); + } + }); +} + +async function removeHoplite(page: import("@playwright/test").Page): Promise { + await page.getByTestId("provider-actions-hoplite").click(); + page.once("dialog", (dialog) => { + void dialog.accept(); + }); + await page.getByTestId("provider-remove-hoplite").click(); +} + +test("unsupported host asks for an update without writing Hoplite config", async ({ + page, + outdatedDaemon, +}, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-unsupported", + port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port), + }); + try { + expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true); + expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false); + const before = await client.getDaemonConfig(); + await seedSavedSettingsHosts(page, [outdatedDaemon]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, outdatedDaemon.serverId); + await openSettingsHostSection(page, outdatedDaemon.serverId, "providers"); + await openAddProviderArea(page); + await installAcpCatalogProvider(page, "Hoplite"); + await expect(page.getByRole("alert")).toHaveText("Update the host to add Hoplite."); + expect((await client.getDaemonConfig()).config).toEqual(before.config); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") }); + } finally { + await client.close(); + } +}); + +test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-picker", + }); + const profiles = await seedAgentProfiles([ + { id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" }, + ]); + const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" }); + try { + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await openModelPicker(page); + await expect(page.getByTestId("model-provider-mock")).toBeVisible(); + await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") }); + await closeModelPicker(page); + } finally { + await workspace.cleanup(); + await profiles.restore(); + await client.close(); + } +}); diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts index feceed7869..73c662124f 100644 --- a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -133,6 +133,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new ).toHaveLength(1); }); +test("scroll detector follows the entered image through resize and virtual offset correction", () => { + for (const height of [225, 1632]) { + const before: ScrollFrame = { + at: 1000, + scrollTop: 2208, + scrollHeight: 5000, + viewportHeight: 516, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -704, height: 560 }, + { id: "caption", top: -144, height: 100 }, + { id: "old-reader", top: -44, height: 127 }, + ], + anchor: "old-reader", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 3, + unmounted: 0, + }; + const resize = height - 560; + const after: ScrollFrame = { + ...before, + at: 1060, + // The virtualizer corrects 110px of estimates while the wheel moves 160px. + scrollTop: 1938, + scrollHeight: 5000 + resize - 110, + wheelTotal: 160, + lastWheelAt: 1020, + imageLoads: 1, + rows: [ + { id: "image", top: -544, height }, + { id: "caption", top: 16 + resize, height: 100 }, + { id: "old-reader", top: 116 + resize, height: 127 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + // Resizing must not excuse additional viewport movement. + expect( + findScrollJumps([ + before, + { + ...after, + rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), + }, + ]), + ).toHaveLength(1); + } +}); + for (const cadence of scrollCadences) { test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({ page, diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 1702f7fe44..6b810e6d13 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string): export async function installAcpCatalogProvider(page: Page, providerName: string): Promise { await findAcpCatalogProvider(page, providerName); - await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click(); } export async function expectProviderInstalledInSettings( diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts index 01055ce2c1..f7b62fd953 100644 --- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -297,11 +297,7 @@ export function findScrollJumps(frames: ScrollFrame[]) { // Follow that intended row, not text now below the image. const recent = frames.findLast((frame) => frame.at <= previous.at - 100); const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); - const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop); - const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop); - const readingLine = 8 - availableScroll; const currentRows = new Map(current.rows.map((row) => [row.id, row])); - const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine); const anchor = previous.anchor; const before = currentRows.has(anchor) ? previous.rows.find((row) => row.id === anchor)! @@ -316,25 +312,33 @@ export function findScrollJumps(frames: ScrollFrame[]) { // A busy main thread can deliver wheel movement hundreds of milliseconds // after its event. Only assert idle stability after the driver stops input. const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; - // Wheel events can precede their scroll update, or include input already - // applied in earlier frames. Locate the reader with this frame's scroll, - // bounded by recent input. Growth below it can move the old anchor without - // moving the reading line. - const readerFollowsInput = - intendedRow && - currentRows.has(intendedRow.id) && - Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32; - const enteredRowGrowth = readerFollowsInput + // scrollTop also includes virtualizer estimate corrections. Locate the row + // entered by wheel input from its viewport movement instead. Its old height + // identifies the reader before a simultaneous image resize takes effect. + const intendedRow = previous.rows.find((row) => { + const next = currentRows.get(row.id); + if (!next || idle || wheelBudget <= 0) return false; + const travel = next.top - row.top; + return ( + travel >= -8 && + travel <= Math.min(wheelBudget, previous.scrollTop) + 32 && + (next.top <= 8 || current.scrollTop === 0) && + next.top + row.height > 8 + ); + }); + const enteredRowResize = intendedRow ? previous.rows.reduce((growth, row) => { if (row.top < intendedRow.top || row.top >= before.top) return growth; const height = currentRows.get(row.id)?.height ?? row.height; - return growth + Math.max(0, height - row.height); + return growth + height - row.height; }, 0) : 0; - const excessForward = movement > wheelBudget + enteredRowGrowth + 32; + const readingMovement = movement - enteredRowResize; + const excessForward = readingMovement > wheelBudget + 32; // Upward wheel input moves the same text DOWN the viewport. A negative move // is a reversal, independent of legitimate scrollTop compensation on prepend. - if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return []; + if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward) + return []; return [ { frame: index, diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index 65f123cb3d..cebb4ca76d 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -48,6 +48,8 @@ export const ACP_PROVIDER_ICON_SVGS = { goose: '\n \n\n', grok: '\n \n \n\n', + hoplite: + '\n', junie: '\n \n \n \n\n', kilo: '\n \n\n', diff --git a/packages/app/src/assets/acp-provider-icons/hoplite.svg b/packages/app/src/assets/acp-provider-icons/hoplite.svg new file mode 100644 index 0000000000..4d9c443429 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/hoplite.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/components/provider-catalog-list.tsx b/packages/app/src/components/provider-catalog-list.tsx index 4f996fdd40..748554efde 100644 --- a/packages/app/src/components/provider-catalog-list.tsx +++ b/packages/app/src/components/provider-catalog-list.tsx @@ -1,19 +1,21 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Text, View, type LayoutChangeEvent } from "react-native"; import { SvgXml } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { ExternalLink, PackagePlus, Search } from "lucide-react-native"; +import { PackagePlus } from "lucide-react-native"; import { Button } from "@/components/ui/button"; -import { isWeb } from "@/constants/platform"; +import { ExternalLink } from "@/components/ui/external-link"; +import { SearchField } from "@/components/ui/search-field"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { rankProviderCatalog } from "@/components/provider-catalog-search"; import { useAcpProviderCatalog, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; -import type { Theme } from "@/styles/theme"; -import { openExternalUrl } from "@/utils/open-external-url"; -import { EditingTextInput as TextInput } from "@/components/ui/text-input"; +import { SPACING, type Theme } from "@/styles/theme"; +import { settingsStyles } from "@/styles/settings"; interface ProviderCatalogListProps { serverId: string; @@ -21,101 +23,106 @@ interface ProviderCatalogListProps { onInstall: (entry: AcpProviderCatalogItem) => Promise | void; } -const SEARCH_ICON_SIZE = 16; const PROVIDER_FALLBACK_ICON_SIZE = 20; const PROVIDER_REMOTE_ICON_SIZE = 24; +const TILE_GAP = SPACING[3]; +/** Narrowest tile that keeps the type badge and install link on one line. */ +const MIN_TILE_WIDTH = 216; +const MAX_COLUMNS = 4; const ThemedPackagePlus = withUnistyles(PackagePlus); const ThemedSvgXml = withUnistyles(SvgXml); -const ThemedSearch = withUnistyles(Search); -const ThemedExternalLink = withUnistyles(ExternalLink); -const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ - placeholderTextColor: theme.colors.foregroundMuted, -})); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); -function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean { - const normalized = query.trim().toLowerCase(); - if (!normalized) return true; - return [entry.title, entry.id, entry.description].some((value) => - value.toLowerCase().includes(normalized), - ); +function columnCountForWidth(width: number): number { + const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP)); + return Math.min(MAX_COLUMNS, Math.max(1, fits)); } -interface CatalogRowProps { +interface CatalogTileProps { entry: AcpProviderCatalogItem; + /** `undefined` until the grid is measured; a single column fills the row. */ + width: number | undefined; installing: boolean; onInstall: (entry: AcpProviderCatalogItem) => void; } -function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { +function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) { const { t } = useTranslation(); - const actionLabel = installing - ? t("providerCatalog.actions.adding") - : t("providerCatalog.actions.add"); const handleInstall = useCallback(() => { onInstall(entry); }, [entry, onInstall]); + const isSingleColumn = width === undefined; + const tileStyle = useMemo( + () => [settingsStyles.card, styles.tile, isSingleColumn ? styles.tileFull : { width }], + [isSingleColumn, width], + ); - const handleOpenInstallLink = useCallback(() => { - void openExternalUrl(entry.installLink); - }, [entry.installLink]); - - return ( - - - {entry.iconSvg ? ( - - ) : ( - - )} - - - - - {entry.title} - - - {entry.version} - - - - {entry.description || entry.id} - - + {entry.iconSvg ? ( + + ) : ( + + )} + + ); + const addButton = ( + + ); + const details = ( + + + {entry.title} + + + {/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */} + + - - {t("providerCatalog.actions.installInstructions")} - - - + /> - + + ); + + return ( + + {isSingleColumn ? ( + + {icon} + {details} + {addButton} + + ) : ( + <> + + {icon} + {addButton} + + {details} + + )} ); } @@ -129,6 +136,7 @@ export function ProviderCatalogList({ const { entries: catalogEntries } = useAcpProviderCatalog(); const { entries: providerEntries } = useProvidersSnapshot(serverId); const [search, setSearch] = useState(""); + const [gridWidth, setGridWidth] = useState(null); const installedIds = useMemo( () => new Set(providerEntries?.map((entry) => entry.provider) ?? []), @@ -137,41 +145,48 @@ export function ProviderCatalogList({ const availableEntries = useMemo( () => - catalogEntries - .filter((entry) => !installedIds.has(entry.id)) - .filter((entry) => matchesSearch(entry, search)), + rankProviderCatalog( + catalogEntries.filter((entry) => !installedIds.has(entry.id)), + search, + ), [catalogEntries, installedIds, search], ); + const handleGridLayout = useCallback((event: LayoutChangeEvent) => { + setGridWidth(event.nativeEvent.layout.width); + }, []); + + const tileWidth = useMemo(() => { + if (gridWidth === null) return undefined; + const columns = columnCountForWidth(gridWidth); + if (columns === 1) return undefined; + return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns); + }, [gridWidth]); + return ( - - - - - + {availableEntries.length === 0 ? ( - + {t("providerCatalog.noProviders")} ) : ( - + {availableEntries.map((entry) => ( - @@ -183,101 +198,68 @@ export function ProviderCatalogList({ } const styles = StyleSheet.create((theme) => ({ - searchField: { + searchRail: { flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - paddingHorizontal: theme.spacing[3], marginBottom: theme.spacing[3], }, - searchIcon: { - width: 18, + grid: { + flexDirection: "row", + flexWrap: "wrap", + gap: TILE_GAP, + }, + tile: { + gap: theme.spacing[3], + padding: theme.spacing[3], + }, + tileFull: { + width: "100%", + }, + tileHeader: { + flexDirection: "row", alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], }, - searchInput: { - flex: 1, - minWidth: 0, - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - list: { - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - overflow: "hidden", - }, - row: { + singleColumnRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], - padding: theme.spacing[3], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, + }, + details: { + gap: theme.spacing[1], + }, + detailsInRow: { + flex: 1, + minWidth: 0, }, iconFrame: { + flexShrink: 0, width: 36, height: 36, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", + }, + addButton: { flexShrink: 0, - }, - textColumn: { - flex: 1, - minWidth: 0, - gap: theme.spacing[1], - }, - titleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minWidth: 0, + minWidth: 72, }, name: { color: theme.colors.foreground, fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - flexShrink: 1, + fontWeight: theme.fontWeight.normal, }, - version: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, - description: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - installLink: { - alignSelf: "flex-start", + metaRow: { flexDirection: "row", + flexWrap: "wrap", alignItems: "center", - gap: theme.spacing[1], - maxWidth: "100%", - }, - installLinkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - actionButton: { - width: 92, - flexShrink: 0, + gap: theme.spacing[2], }, stateBox: { minHeight: 96, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, alignItems: "center", justifyContent: "center", - gap: theme.spacing[3], padding: theme.spacing[4], }, stateText: { diff --git a/packages/app/src/components/provider-catalog-search.test.ts b/packages/app/src/components/provider-catalog-search.test.ts new file mode 100644 index 0000000000..4079d79602 --- /dev/null +++ b/packages/app/src/components/provider-catalog-search.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { rankProviderCatalog } from "./provider-catalog-search"; + +const entries = [ + { id: "kiro-cli", title: "Kiro CLI" }, + { id: "goose", title: "goose" }, + { id: "kimi", title: "Kimi Code CLI" }, + { id: "gemini", title: "Gemini CLI" }, + { id: "qwen-code", title: "Qwen Code" }, + { id: "codex-acp", title: "Codex" }, +]; + +function titles(list: { title: string }[]): string[] { + return list.map((entry) => entry.title); +} + +describe("rankProviderCatalog", () => { + it("lists every provider alphabetically by name without a query", () => { + expect(titles(rankProviderCatalog(entries, " "))).toEqual([ + "Codex", + "Gemini CLI", + "goose", + "Kimi Code CLI", + "Kiro CLI", + "Qwen Code", + ]); + }); + + it("ranks the strongest match first and drops providers that do not match", () => { + expect(titles(rankProviderCatalog(entries, "cod"))).toEqual([ + "Codex", + "Kimi Code CLI", + "Qwen Code", + ]); + }); + + it("matches in-word subsequences and multiple tokens like the other pickers", () => { + expect(titles(rankProviderCatalog(entries, "gmni"))).toEqual(["Gemini CLI"]); + expect(titles(rankProviderCatalog(entries, "qw cod"))).toEqual(["Qwen Code"]); + }); + + it("matches the provider id", () => { + expect(titles(rankProviderCatalog(entries, "kiro-cli"))).toEqual(["Kiro CLI"]); + }); +}); diff --git a/packages/app/src/components/provider-catalog-search.ts b/packages/app/src/components/provider-catalog-search.ts new file mode 100644 index 0000000000..23303c868f --- /dev/null +++ b/packages/app/src/components/provider-catalog-search.ts @@ -0,0 +1,34 @@ +import { + compareMatchScores, + scoreTextFields, + type MatchScore, +} from "@getpaseo/protocol/search/text-match"; + +export interface SearchableCatalogEntry { + id: string; + title: string; +} + +function compareTitles(a: SearchableCatalogEntry, b: SearchableCatalogEntry): number { + return a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); +} + +/** + * Orders the Add provider catalog. Without a query the list is alphabetical by + * name. With a query it uses the pickers' ranked matcher over the fields the + * tile shows (name and id), best match first, ties alphabetical. + */ +export function rankProviderCatalog( + entries: readonly T[], + query: string, +): T[] { + if (!query.trim()) return [...entries].sort(compareTitles); + + const scored: { entry: T; score: MatchScore }[] = []; + for (const entry of entries) { + const score = scoreTextFields(query, [entry.title, entry.id]); + if (score) scored.push({ entry, score }); + } + scored.sort((a, b) => compareMatchScores(a.score, b.score) || compareTitles(a.entry, b.entry)); + return scored.map(({ entry }) => entry); +} diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 1fdb1c6f5d..52230d6251 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -8,6 +8,7 @@ export interface AcpProviderCatalogEntry { iconSvg: string | null; installLink: string; command: readonly [string, ...string[]]; + bundledProvider?: boolean; env?: Readonly>; params?: Readonly>; } @@ -238,6 +239,16 @@ const CATALOG_DATA = [ installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", command: ["hermes", "acp"], }, + { + id: "hoplite", + bundledProvider: true, + title: "Hoplite", + description: "Cloud coding agent through the local hoplite acp bridge", + version: "manual", + iconId: "hoplite", + installLink: "https://hoplite.sh/docs/cli/acp", + command: ["hoplite", "acp"], + }, { id: "junie", title: "Junie", @@ -389,6 +400,7 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map( version: entry.version, installLink: entry.installLink, command: entry.command, + bundledProvider: "bundledProvider" in entry ? entry.bundledProvider : undefined, env: "env" in entry ? entry.env : undefined, params: "params" in entry ? entry.params : undefined, iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null, 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 63e360aba0..5e642e0091 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -44,6 +44,7 @@ describe("ACP provider catalog", () => { expect(findProvider("codewhale").command).toEqual(["codewhale", "serve", "--acp"]); expect(findProvider("devin").command).toEqual(["devin", "acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); + expect(findProvider("hoplite").command).toEqual(["hoplite", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); expect(findProvider("poolside").command).toEqual(["pool", "acp"]); @@ -59,6 +60,32 @@ describe("ACP provider catalog", () => { expect(findProvider("minimax-code").iconSvg).toContain(" { + expect(findProvider("hoplite")).toMatchObject({ + title: "Hoplite", + command: ["hoplite", "acp"], + installLink: "https://hoplite.sh/docs/cli/acp", + }); + expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({ + providers: { + hoplite: { enabled: true }, + }, + }); + const ids = getAcpProviderCatalog().map((entry) => entry.id); + expect(ids.indexOf("hermes")).toBeLessThan(ids.indexOf("hoplite")); + expect(ids.indexOf("hoplite")).toBeLessThan(ids.indexOf("junie")); + 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("maps a catalog entry to the daemon provider config patch", () => { expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({ providers: { diff --git a/packages/app/src/hooks/use-acp-provider-catalog.ts b/packages/app/src/hooks/use-acp-provider-catalog.ts index 92f9ca07e2..f20c31caf2 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.ts @@ -10,7 +10,13 @@ export function getAcpProviderCatalog(): AcpProviderCatalogItem[] { export function buildAcpProviderConfigPatch( entry: AcpProviderCatalogItem, + supportsHopliteProvider = 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."); + return { providers: { [entry.id]: { enabled: true } } }; + } return { providers: { [entry.id]: { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 87cacb2f82..7434471ec6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1657,14 +1657,19 @@ export const ar: TranslationResources = { title: "إضافة مزود", search: "مقدمي البحث", noProviders: "لم يتم العثور على مقدمي الخدمات", + clearSearch: "مسح البحث", actions: { add: "يضيف", + addProvider: "إضافة {{provider}}", adding: "إضافة", installed: "تم التثبيت", cancel: "يلغي", installInstructions: "تعليمات التثبيت", installInstructionsFor: "تعليمات تثبيت{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "غير قادر على تثبيت الموفر", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f9779a6d55..97ced3f535 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1682,14 +1682,19 @@ export const en = { title: "Add provider", search: "Search providers", noProviders: "No providers found", + clearSearch: "Clear search", actions: { add: "Add", + addProvider: "Add {{provider}}", adding: "Adding", installed: "Installed", cancel: "Cancel", installInstructions: "Install instructions", installInstructionsFor: "{{provider}} install instructions", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Unable to install provider", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 190b36d08d..124e931497 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1703,14 +1703,19 @@ export const es: TranslationResources = { title: "Agregar proveedor", search: "Proveedores de búsqueda", noProviders: "No se encontraron proveedores", + clearSearch: "Borrar búsqueda", actions: { add: "Agregar", + addProvider: "Añadir {{provider}}", adding: "Añadiendo", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instrucciones de instalación", installInstructionsFor: "Instrucciones de instalación de{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "No se puede instalar el proveedor", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6cfe15b1be..8a8e01786d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1699,14 +1699,19 @@ export const fr: TranslationResources = { title: "Ajouter un fournisseur", search: "Rechercher des fournisseurs", noProviders: "Aucun fournisseur trouvé", + clearSearch: "Effacer la recherche", actions: { add: "Ajouter", + addProvider: "Ajouter {{provider}}", adding: "Ajout…", installed: "Installé", cancel: "Annuler", installInstructions: "Instructions d’installation", installInstructionsFor: "Instructions d’installation de {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Impossible d’installer le fournisseur", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index cb37dde381..9f6d4eced3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1674,14 +1674,19 @@ export const ja: TranslationResources = { title: "プロバイダーを追加", search: "プロバイダーを検索", noProviders: "プロバイダーが見つかりません", + clearSearch: "検索をクリア", actions: { add: "追加", + addProvider: "{{provider}} を追加", adding: "追加中", installed: "インストール済み", cancel: "キャンセル", installInstructions: "インストール手順", installInstructionsFor: "{{provider}}のインストール手順", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "プロバイダーをインストールできません", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 418490d395..a78d44f74b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1667,14 +1667,19 @@ export const ko: TranslationResources = { title: "프로바이더 추가", search: "프로바이더 검색", noProviders: "프로바이더를 찾을 수 없습니다", + clearSearch: "검색 지우기", actions: { add: "추가", + addProvider: "{{provider}} 추가", adding: "추가 중", installed: "설치됨", cancel: "취소", installInstructions: "설치 안내", installInstructionsFor: "{{provider}} 설치 안내", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "프로바이더를 설치할 수 없습니다", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b2fe479b74..e5a2e3bfe4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1688,14 +1688,19 @@ export const ptBR: TranslationResources = { title: "Adicionar provedor", search: "Buscar provedores", noProviders: "Nenhum provedor encontrado", + clearSearch: "Limpar pesquisa", actions: { add: "Adicionar", + addProvider: "Adicionar {{provider}}", adding: "Adicionando", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instruções de instalação", installInstructionsFor: "Instruções de instalação de {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Não foi possível instalar provedor", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 086cd51c01..ad0bac05c6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1686,14 +1686,19 @@ export const ru: TranslationResources = { title: "Добавить провайдера", search: "Поиск провайдеров", noProviders: "Провайдеры не найдены", + clearSearch: "Очистить поиск", actions: { add: "Добавить", + addProvider: "Добавить {{provider}}", adding: "Добавление", installed: "Установлено", cancel: "Отмена", installInstructions: "Инструкции по установке", installInstructionsFor: "Инструкции по установке {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Не удалось установить провайдера", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 16dc24b3a5..7b78bfbea6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1640,14 +1640,19 @@ export const zhCN: TranslationResources = { title: "添加 provider", search: "搜索 providers", noProviders: "未找到 providers", + clearSearch: "清除搜索", actions: { add: "添加", + addProvider: "添加 {{provider}}", adding: "正在添加", installed: "已安装", cancel: "取消", installInstructions: "安装说明", installInstructionsFor: "{{provider}} 安装说明", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "无法安装 provider", }, diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 4fb0a43278..11147b3a3e 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -19,6 +19,7 @@ import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildAcpProviderConfigPatch, + getAcpProviderCatalog, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; @@ -326,6 +327,8 @@ export interface ProvidersSectionProps { export function ProvidersSection({ serverId }: ProvidersSectionProps) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); + const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider"); + const [installError, setInstallError] = useState(null); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); const { config, patchConfig } = useDaemonConfig(serverId); @@ -338,9 +341,16 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; - /** Custom providers are deletable only when config.json holds their `extends` definition. */ + /** Config owns derived providers and opt-in bundled catalog providers. */ const isConfigOwnedProvider = useCallback( - (providerId: string) => Boolean(config?.providers?.[providerId]?.extends), + (providerId: string) => + Boolean( + config?.providers?.[providerId] && + (config.providers[providerId].extends || + getAcpProviderCatalog().some( + (entry) => entry.id === providerId && entry.bundledProvider, + )), + ), [config], ); @@ -403,20 +413,18 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const handleInstall = useCallback( async (entry: AcpProviderCatalogItem) => { if (installingProviderId) return; + setInstallError(null); setInstallingProviderId(entry.id); try { - await patchConfig(buildAcpProviderConfigPatch(entry)); + await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider)); await refresh([entry.id]); } catch (error) { - Alert.alert( - t("settings.providers.addErrorTitle"), - error instanceof Error ? error.message : String(error), - ); + setInstallError(error instanceof Error ? error.message : String(error)); } finally { setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, - [installingProviderId, patchConfig, refresh, t], + [installingProviderId, patchConfig, refresh, supportsHopliteProvider], ); return ( @@ -472,6 +480,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { testID="host-page-add-provider-card" style={styles.addProviderSection} > + {installError ? ( + + {installError} + + ) : null} void; private readonly messages = new Map(); private readonly toolCalls = new Map(); + // Running tool rows published by transformers; the turn's terminal closes any left open. + private readonly vendorToolCalls = new Map(); private readonly pendingCompactions = new Set(); private readonly permissions = new Map< string, @@ -595,10 +597,13 @@ class AcpRuntime { turnId, state: "started", }); + // The SDK dispatches notifications without awaiting them, so the prompt response can resolve + // while earlier session updates are still queued. Apply them before the turn ends. const settled = this.call( this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), ).then( - (response): void => { + async (response): Promise => { + await this.drainNotifications(); const state = response.stopReason === "cancelled" ? "canceled" : "completed"; this.fallbackChunkIds.clear(); this.terminalizeTransientItems(state); @@ -610,7 +615,8 @@ class AcpRuntime { }); return undefined; }, - (error): void => { + async (error): Promise => { + await this.drainNotifications(); this.fallbackChunkIds.clear(); this.terminalizeTransientItems("failed"); this.emit({ @@ -1027,9 +1033,17 @@ class AcpRuntime { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, - item: toolTimelineItem(completed), + item: terminalToolItem(toolTimelineItem(snapshot), state), }); } + for (const item of this.vendorToolCalls.values()) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: terminalToolItem(item, state), + }); + } + this.vendorToolCalls.clear(); for (const id of this.pendingCompactions) { this.emit({ type: "timeline.item", @@ -1076,6 +1090,10 @@ class AcpRuntime { if (this.configTransaction) this.stagedTransformerConfig = update.config; else this.emitConfigSnapshot(update.config); } else if (update.type === "timeline") { + if (update.item.type === "tool_call") { + if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item); + else this.vendorToolCalls.delete(update.item.id); + } this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, @@ -1383,7 +1401,9 @@ function configValues( return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); } -function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { +type ToolCallTimelineItem = Extract; + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem { const input = jsonRecord(snapshot.input); const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; const detail = isEdit @@ -1411,6 +1431,18 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { return { ...base, status, error: null }; } +// A row the turn left open ends with the turn. The wire contract requires a non-null error on a +// failed tool call, so a canceled turn cancels its rows instead of failing them. +function terminalToolItem( + item: ToolCallTimelineItem, + state: "completed" | "failed" | "canceled", +): ToolCallTimelineItem { + if (state === "failed") { + return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } }; + } + return { ...item, status: state, error: null }; +} + function jsonValue(value: unknown) { return z.json().parse(value); } diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index a62b2f2c48..13d69416bb 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -619,6 +619,146 @@ lines.on("line", (line) => { await connection.close(); }); + it.each([ + ["end_turn", "completed", "completed"], + ["cancelled", "canceled", "canceled"], + ] as const)( + "closes a transformer's running tool row when the turn ends with %s", + async (stopReason, turnState, rowStatus) => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" }); + instance.respond(message as AcpRequestMessage, { stopReason }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "vendor-row-acp", + label: "Vendor row ACP", + connector: harness.connector, + transformers: [ + { + notification(notification) { + if (notification.method !== "vendor/phase") return null; + return { + type: "timeline", + item: { + type: "tool_call", + id: "phase-1", + callId: "phase-1", + name: "Vendor phase", + detail: { type: "plain_text", label: "Preparing" }, + status: "running", + error: null, + }, + }; + }, + }, + ], + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === turnState, + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [], + ); + expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([ + "running", + rowStatus, + ]); + const closed = events.findIndex( + (event) => + event.type === "timeline.item" && + event.item.id === "phase-1" && + event.item.type === "tool_call" && + event.item.status === rowStatus, + ); + const terminal = events.findIndex( + (event) => event.type === "session.turn" && event.state === turnState, + ); + expect(closed).toBeLessThan(terminal); + // The wire contract only accepts a failed tool call with a non-null error. + expect(rows.at(-1)).toMatchObject({ error: null }); + await connection.close(); + }, + ); + + it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("session/update", { + sessionId: "connector-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Run tests", + kind: "execute", + status: "in_progress", + }, + }); + instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "cancel-tool-acp", + label: "Cancel tool ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === "canceled", + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [], + ); + expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null }); + await connection.close(); + }); + it("serializes configuration mutations", async () => { let releaseMutation: (() => void) | null = null; const configOptions = [ diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 33b49ab447..1646e8473b 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; export interface ProviderRegistration { + /** Activate only while agents.providers contains this provider ID, independently of enabled. */ + requiresConfiguration?: boolean; /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ getCatalogCacheKey?(options: ProviderCatalogOptions): Promise; /** Default executable and arguments; the daemon resolves overrides before status/connect. */ diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index cf15e47962..3966d6eb71 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -812,3 +812,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); + }, +); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index fefc104279..c8fe60ff67 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3710,6 +3710,8 @@ export const ServerInfoStatusPayloadSchema = z commitBaseClassification: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. 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(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 6265b631c3..4ca8ea44e3 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -31,6 +31,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "glm-acp-agent", "goose", "grok", + "hoplite", "junie", "kilo", "kimi", diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 8b894a7765..ae8ed805d7 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -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(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 58bddd07b6..e575ff0d45 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -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, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 2698a3b2ac..9f85e4d37f 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -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; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 82acd13e1f..b10a4f8e02 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -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; } diff --git a/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs new file mode 100644 index 0000000000..3650238ad7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs @@ -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 }); +}); 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 b9293d8c3e..3447444358 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -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"; @@ -162,3 +163,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); diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index c14c027853..cf6ffe9eee 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -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", diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 6c4af29e62..a5ecea8097 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -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) diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index e959780389..b3f363cb29 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -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(), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 3f11926d7d..5e6efa1925 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -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, diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 50c0ee3c76..c5cf271ad3 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -240,6 +240,7 @@ export function createProviderSnapshotManagerStub(): { const off = vi.fn(); const destroy = vi.fn(); const stub = { + supportsConfigRequiredProvider: () => false, getSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, 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 25acb9946b..10694a6ebe 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1141,6 +1141,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.stableProjectIdentity).toBe(true); expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); + expect(serverInfo.features?.hopliteProvider).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 5e93681a66..e833a07ce3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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. diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 4b25764445..c80345c79e 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -19,9 +19,9 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], pool: "forks", fileParallelism: false, - // Windows runners intermittently starve subprocess-heavy Git tests at the - // default worker count, leaving child processes alive past their deadlines. - maxWorkers: process.platform === "win32" ? 2 : undefined, + // Even two concurrent Git-heavy suites can exhaust Windows test deadlines. + // Run files serially so their subprocesses do not compete across suites. + maxWorkers: process.platform === "win32" ? 1 : undefined, exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"], }, }); diff --git a/plugins/hoplite-provider/icon.svg b/plugins/hoplite-provider/icon.svg new file mode 100644 index 0000000000..ed585a9083 --- /dev/null +++ b/plugins/hoplite-provider/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/hoplite-provider/index.server.ts b/plugins/hoplite-provider/index.server.ts new file mode 100644 index 0000000000..4c101df470 --- /dev/null +++ b/plugins/hoplite-provider/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { createHopliteProvider } from "./server/provider.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createHopliteProvider()); + return () => {}; +} diff --git a/plugins/hoplite-provider/paseo-plugin.json b/plugins/hoplite-provider/paseo-plugin.json new file mode 100644 index 0000000000..138cf83ad6 --- /dev/null +++ b/plugins/hoplite-provider/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "hoplite-provider", "requirements": { "paseo": ">=0.11.0" } } diff --git a/plugins/hoplite-provider/server/provider.ts b/plugins/hoplite-provider/server/provider.ts new file mode 100644 index 0000000000..62a67dbdb1 --- /dev/null +++ b/plugins/hoplite-provider/server/provider.ts @@ -0,0 +1,17 @@ +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; +import { runPhaseTransformer } from "./run-phase.js"; + +export function createHopliteProvider(): ProviderRegistration { + return { + requiresConfiguration: true, + ...runAcpProvider({ + id: "hoplite", + label: "Hoplite", + description: "Hoplite's coding agent through the local `hoplite acp` bridge", + icon: "icon.svg", + command: ["hoplite", "acp"], + transformers: [runPhaseTransformer], + }), + }; +} diff --git a/plugins/hoplite-provider/server/run-phase.test.ts b/plugins/hoplite-provider/server/run-phase.test.ts new file mode 100644 index 0000000000..5526f386c6 --- /dev/null +++ b/plugins/hoplite-provider/server/run-phase.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { runPhaseTransformer } from "./run-phase.js"; + +const context = { sessionId: "session-1" }; + +// Frames captured from `hoplite acp` 3.0.0. +function runPhase(phase: string | null, label: string | null) { + return { + method: "_hoplite/run_phase", + params: { + sessionId: "thr_c75d39f7d7624256af918eeee2f4941b", + runId: "run_eb213b97bd3147afb2927fbc27f87180", + phase, + label, + startedAt: phase === null ? null : "2026-10-04T17:51:46.311Z", + }, + }; +} + +describe("runPhaseTransformer", () => { + it("shows a provisioning phase as one running workspace item per run", () => { + const update = runPhaseTransformer.notification?.( + runPhase("fetching_repository", "Cloning repository"), + context, + ); + + expect(update).toEqual({ + type: "timeline", + item: { + type: "tool_call", + id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + callId: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + name: "Hoplite workspace", + detail: { type: "plain_text", label: "Cloning repository", icon: "wrench" }, + status: "running", + error: null, + }, + }); + }); + + it("completes the same item when the run leaves provisioning", () => { + const update = runPhaseTransformer.notification?.(runPhase(null, null), context); + + expect(update).toMatchObject({ + type: "timeline", + item: { + id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + status: "completed", + detail: { label: "Workspace ready" }, + }, + }); + }); + + it("falls back to the phase name when Hoplite sends no label", () => { + const update = runPhaseTransformer.notification?.( + runPhase("configuring_workspace", null), + context, + ); + + expect(update).toMatchObject({ item: { detail: { label: "configuring_workspace" } } }); + }); + + it("ignores other vendor notifications and malformed phases", () => { + expect( + runPhaseTransformer.notification?.({ method: "_hoplite/other", params: {} }, context), + ).toBeNull(); + expect( + runPhaseTransformer.notification?.( + { method: "_hoplite/run_phase", params: { phase: "x" } }, + context, + ), + ).toBeNull(); + }); +}); diff --git a/plugins/hoplite-provider/server/run-phase.ts b/plugins/hoplite-provider/server/run-phase.ts new file mode 100644 index 0000000000..767ee0447d --- /dev/null +++ b/plugins/hoplite-provider/server/run-phase.ts @@ -0,0 +1,39 @@ +import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; +import { z } from "zod"; + +// Hoplite provisions a cloud workspace before the first agent output of a run and reports each +// step as `_hoplite/run_phase`. A null phase ends provisioning for that run. +const runPhaseSchema = z.object({ + runId: z.string().min(1), + phase: z.string().nullable(), + label: z.string().nullable(), +}); + +const RUN_PHASE_METHOD = "_hoplite/run_phase"; + +export const runPhaseTransformer: AcpTransformer = { + notification(notification) { + if (notification.method !== RUN_PHASE_METHOD) return null; + const params = runPhaseSchema.safeParse(notification.params); + if (!params.success) return null; + const { runId, phase, label } = params.data; + const id = `hoplite-run-phase:${runId}`; + const running = phase !== null; + return { + type: "timeline", + item: { + type: "tool_call", + id, + callId: id, + name: "Hoplite workspace", + detail: { + type: "plain_text", + label: running ? (label ?? phase) : "Workspace ready", + icon: "wrench", + }, + status: running ? "running" : "completed", + error: null, + }, + }; + }, +}; diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index f2b1370b68..1aa981ba29 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -85,6 +85,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [goose](https://block.github.io/goose/), Block's local open-source AI agent. - [Grok](https://docs.x.ai/build/overview), xAI's Grok Build agentic coding CLI. - [Hermes Agent](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp), Nous Research's self-improving agent. +- [Hoplite](#hoplite), cloud coding agent through its local ACP bridge. - [Junie](https://junie.jetbrains.com/docs/junie-cli-acp.html), JetBrains' coding agent. - [Kilo Code](https://kilo.ai/docs/code-with-ai/platforms/cli), open-source coding agent. - [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), Moonshot AI's coding assistant. @@ -100,3 +101,18 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md), open-source multi-provider coding agent. 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). + +### Hoplite + +Install the [Hoplite CLI](https://hoplite.sh/docs/cli) on your daemon host and run +`hoplite login`. In **Settings → Hosts → your host → Providers**, find **Hoplite** +in the catalog and choose **Add**. Paseo launches `hoplite acp` through the generic ACP adapter. + +Hoplite runs the agent in a cloud workspace. Open a repository linked to a Hoplite project, +or set `agents.providers.hoplite.env.HOPLITE_PROJECT_ID` to use one project everywhere. +Without a linked repository or pinned project, discovery reports `No Hoplite project`. +Your local files and Paseo's loopback MCP tools are not available in that cloud workspace. + +See [Hoplite's ACP guide](https://hoplite.sh/docs/cli/acp) for authentication and project setup. +Use a [command override](/docs/custom-providers) if `hoplite` is outside your daemon's PATH; +keep `acp` in the arguments.