From 7aa3fac069d4d07e64ed7696b0e161058cda6e09 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 16 Oct 2026 01:18:27 +0700 Subject: [PATCH] fix(providers): scope project-dependent model discovery to workspaces Global discovery has no linked project. Initialize the bridge to check availability, but only open discovery sessions when a workspace is supplied. --- .../hoplite-model-discovery.real.spec.ts | 110 ++++++++++++++++++ .../src/server/acp-internal/connection.ts | 9 +- packages/plugin/src/server/acp.test.ts | 33 ++++++ packages/plugin/src/server/acp.ts | 2 + .../src/server/daemon-e2e/hoplite-fixture.cjs | 7 ++ .../src/server/daemon-e2e/models.e2e.test.ts | 16 ++- plugins/hoplite-provider/server/provider.ts | 1 + 7 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts diff --git a/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts new file mode 100644 index 0000000000..60e13b70a6 --- /dev/null +++ b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts @@ -0,0 +1,110 @@ +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { + closeModelPicker, + drillIntoProvider, + openModelPicker, +} from "../support/helpers/agent-profiles"; +import { expectComposerVisible, expectComposerFocused } from "../support/helpers/composer"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { getServerId } from "../support/helpers/server-id"; +import { openSettingsHost, openSettingsHostSection } from "../support/helpers/settings"; + +// Use an existing repository linked to the signed-in user's Hoplite project. +const cwd = process.env.E2E_HOPLITE_CWD ?? path.resolve(__dirname, "../../../.."); + +test.use({ + viewport: { width: 1440, height: 1000 }, + e2eDaemonConfig: { version: 1, agents: { providers: { hoplite: { enabled: true } } } }, +}); + +async function openHopliteSettings(page: Page): Promise { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await page.getByRole("button", { name: "Hoplite provider details", exact: true }).click(); +} + +async function expectEmptyGlobalCatalog(page: Page, client: DaemonClient): Promise { + const models = await client.listProviderModels("hoplite"); + expect(models.error).toBeNull(); + expect(models.models).toEqual([]); + const snapshot = await client.getProvidersSnapshot(); + expect(snapshot.entries.find((entry) => entry.provider === "hoplite")).toMatchObject({ + status: "ready", + models: [], + }); + await expect(page.getByText("No models detected", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0); +} + +async function refreshGlobalCatalog(page: Page): Promise { + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled({ + timeout: 120_000, + }); +} + +async function openWorkspaceModels(page: Page, workspaceId: string): Promise { + await gotoWorkspace(page, workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await expectComposerFocused(page); + await openModelPicker(page); + await page.getByTestId("sheet-header-back").click(); + await drillIntoProvider(page, "hoplite"); +} + +test("real Hoplite has an empty global catalog and discovers models in its linked workspace", async ({ + page, + e2eWorkerClient, +}, testInfo) => { + test.setTimeout(180_000); + const client = await connectDaemonClient({ clientIdPrefix: "hoplite-discovery" }); + const created = await e2eWorkerClient.createWorkspace({ + source: { kind: "directory", path: cwd }, + title: "Hoplite discovery QA", + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + try { + await test.step("global settings succeed with no models, including refresh", async () => { + await openHopliteSettings(page); + await expectEmptyGlobalCatalog(page, client); + await refreshGlobalCatalog(page); + await expectEmptyGlobalCatalog(page, client); + await page.screenshot({ path: testInfo.outputPath("hoplite-global-empty.png") }); + }); + + await test.step("the linked workspace discovers real models", async () => { + const models = await client.listProviderModels("hoplite", { cwd }); + expect(models.error).toBeNull(); + expect(models.models!.length).toBeGreaterThan(0); + await testInfo.attach("workspace-models", { + body: JSON.stringify(models, null, 2), + contentType: "application/json", + }); + await openWorkspaceModels(page, workspace.id); + await expect(page.getByText(models.models![0]!.label, { exact: true }).last()).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("hoplite-workspace-models.png") }); + await closeModelPicker(page); + }); + + await test.step("workspace discovery does not populate the global catalog", async () => { + await openHopliteSettings(page); + await expectEmptyGlobalCatalog(page, client); + await refreshGlobalCatalog(page); + await expectEmptyGlobalCatalog(page, client); + const models = await client.listProviderModels("hoplite", { cwd }); + expect(models.error).toBeNull(); + expect(models.models!.length).toBeGreaterThan(0); + }); + } finally { + await client.close(); + await e2eWorkerClient.removeProject(workspace.projectId); + } +}); diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index 09984a3d14..05403594cf 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -547,8 +547,13 @@ class AcpRuntime { return sessionCapabilities; } - async discover(cwd = process.cwd()): Promise { - const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] })); + async discover(cwd?: string): Promise { + if (cwd === undefined && this.options.options.acpOptions?.workspaceOnlyCatalog) { + return { models: [], modes: [] }; + } + const response = await this.call( + this.connection.newSession({ cwd: cwd ?? process.cwd(), mcpServers: [] }), + ); this.nativeSessionId = response.sessionId; this.modes = response.modes; this.configOptions = response.configOptions ?? []; diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index 13d69416bb..40c8019439 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -272,6 +272,39 @@ lines.on("line", (line) => { } }); + it("still reports initialization failures for workspace-only global discovery", async () => { + let unavailable = false; + const { connector } = connectorHarness({ + handleRequest(instance, request) { + if (unavailable && request.method === "initialize") { + instance.reject(request, "Bridge unavailable"); + return true; + } + return false; + }, + }); + const registration = runAcpProvider({ + id: "workspace-acp", + label: "Workspace ACP", + connector, + acpOptions: { workspaceOnlyCatalog: true }, + }); + const connection = await registration.connect({ versions: [1], capabilities: [] }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + try { + unavailable = true; + await connection.send({ type: "catalog", requestId: "global" }); + expect(await waitForEvent(events, (event) => event.type === "request.failed")).toMatchObject({ + requestId: "global", + error: { message: expect.stringContaining("Bridge unavailable") }, + }); + expect(events.some((event) => event.type === "catalog")).toBe(false); + } finally { + await connection.close(); + } + }); + it("rejects connection cleanly when the ACP executable does not exist", async () => { const registration = runAcpProvider({ id: "missing-acp", diff --git a/packages/plugin/src/server/acp.ts b/packages/plugin/src/server/acp.ts index 533453f15e..826bc23235 100644 --- a/packages/plugin/src/server/acp.ts +++ b/packages/plugin/src/server/acp.ts @@ -44,6 +44,8 @@ export type AcpConnector = () => AcpStream | Promise; export interface AcpOptions { protocol?: "auto" | "v1"; startupTimeoutMs?: number; + /** Global discovery initializes ACP but returns an empty catalog without opening a session. */ + workspaceOnlyCatalog?: boolean; waitForInitialCommands?: boolean; initialCommandsTimeoutMs?: number; } diff --git a/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs index 3650238ad7..542c2cf2ba 100644 --- a/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs +++ b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs @@ -20,6 +20,13 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { let result = {}; if (request.method === "initialize") result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new" && request.params.cwd !== process.env.HOPLITE_TEST_CWD) { + send({ + id: request.id, + error: { code: -32602, message: "No Hoplite project for this directory" }, + }); + return; + } if (request.method === "session/new") result = { sessionId: "hoplite-fixture", 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 3447444358..cfa420654b 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -199,10 +199,13 @@ test("bundled Hoplite requires a config entry and removal survives restart", asy hoplite: { enabled: true, command: [process.execPath, path.resolve(import.meta.dirname, "hoplite-fixture.cjs")], - env: { HOPLITE_TEST_RECORD: record }, + env: { HOPLITE_TEST_RECORD: record, HOPLITE_TEST_CWD: root }, }, }, }); + const globalModels = await client.listProviderModels("hoplite"); + expect(globalModels.error).toBeNull(); + expect(globalModels.models).toEqual([]); await expect .poll( async () => @@ -211,9 +214,14 @@ test("bundled Hoplite requires a config entry and removal survives restart", asy )?.status, ) .toBe("ready"); - expect((await client.listProviderModels("hoplite")).models.map((model) => model.id)).toEqual([ - "fixture-model", - ]); + const workspaceModels = await client.listProviderModels("hoplite", { cwd: root }); + expect(workspaceModels.error).toBeNull(); + expect(workspaceModels.models.map((model) => model.id)).toEqual(["fixture-model"]); + await client.refreshProvidersSnapshot({ providers: ["hoplite"] }); + expect((await client.listProviderModels("hoplite")).models).toEqual([]); + expect( + (await client.listProviderModels("hoplite", { cwd: root })).models.map((model) => model.id), + ).toEqual(["fixture-model"]); const agent = await client.createAgent({ provider: "hoplite", cwd: root, diff --git a/plugins/hoplite-provider/server/provider.ts b/plugins/hoplite-provider/server/provider.ts index 62a67dbdb1..cb362052ea 100644 --- a/plugins/hoplite-provider/server/provider.ts +++ b/plugins/hoplite-provider/server/provider.ts @@ -11,6 +11,7 @@ export function createHopliteProvider(): ProviderRegistration { description: "Hoplite's coding agent through the local `hoplite acp` bridge", icon: "icon.svg", command: ["hoplite", "acp"], + acpOptions: { workspaceOnlyCatalog: true }, transformers: [runPhaseTransformer], }), };