mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
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.
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DaemonClient>({ 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);
|
||||
}
|
||||
});
|
||||
@@ -547,8 +547,13 @@ class AcpRuntime {
|
||||
return sessionCapabilities;
|
||||
}
|
||||
|
||||
async discover(cwd = process.cwd()): Promise<ProviderCatalog> {
|
||||
const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] }));
|
||||
async discover(cwd?: string): Promise<ProviderCatalog> {
|
||||
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 ?? [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -44,6 +44,8 @@ export type AcpConnector = () => AcpStream | Promise<AcpStream>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user