mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b229223db | ||
|
|
f56c013928 | ||
|
|
208e323035 | ||
|
|
ec7446add4 | ||
|
|
b30302b630 | ||
|
|
7aa3fac069 |
+7
-1
@@ -1,9 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.3 - 2026-10-15
|
||||
## 0.11.4 - 2026-10-15
|
||||
|
||||
[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Hoplite showing an error in Settings → Providers instead of discovering models in workspaces linked to a Hoplite project
|
||||
|
||||
## 0.11.3 - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added Hoplite as an opt-in provider in the ACP provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0))
|
||||
|
||||
@@ -57,7 +57,7 @@ Paseo is an open source agentic development environment for desktop, mobile, web
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="56"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
</tr>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -100,6 +100,40 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function installReversedUsageRefresh(page: Page) {
|
||||
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
|
||||
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
|
||||
return installUsageReportsFixture(page, {
|
||||
lists: [(request) => (request.forceRefresh ? refreshed : initial)],
|
||||
});
|
||||
}
|
||||
|
||||
async function openUsageSummaryAtWidth(page: Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
if (width === 390) await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
}
|
||||
|
||||
async function expectUsageProviderOrder(page: Page, summary: string[]) {
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
|
||||
"opencode plan",
|
||||
"openai plan",
|
||||
]);
|
||||
await expectPinnedUsage(page, summary);
|
||||
}
|
||||
|
||||
async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) {
|
||||
const requestCount = usage.listRequests().length;
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
@@ -152,6 +186,21 @@ test.describe("usage modal", () => {
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const usage = await installReversedUsageRefresh(page);
|
||||
await openUsageSummaryAtWidth(page, width);
|
||||
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
|
||||
|
||||
await refreshUsageAndWaitForReports(page, usage);
|
||||
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
|
||||
});
|
||||
}
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
scrollUsage,
|
||||
scrollUsageToWindow,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
@@ -146,7 +146,9 @@ test.describe("Usage item", () => {
|
||||
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
|
||||
};
|
||||
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
|
||||
test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => {
|
||||
test(`long usage reports scroll between their first and last windows on ${size}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
@@ -161,10 +163,10 @@ test.describe("Usage item", () => {
|
||||
await waitForSettledPosition(content);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-top`);
|
||||
await scrollUsage(page, 5_000);
|
||||
await scrollUsageToWindow(page, "Window 20");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
|
||||
await scrollUsage(page, -5_000);
|
||||
await scrollUsageToWindow(page, "Window 1");
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
|
||||
@@ -212,6 +212,14 @@ export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
}
|
||||
|
||||
/** Reach a window with the wheel even when other reports follow its card. */
|
||||
export async function scrollUsageToWindow(page: Page, label: string): Promise<void> {
|
||||
const target = await usageModal(page).getByText(label, { exact: true }).boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
if (!target || !viewport) throw new Error("Usage window must exist before scrolling to it.");
|
||||
await scrollUsage(page, target.y + target.height / 2 - viewport.height / 2);
|
||||
}
|
||||
|
||||
/** The modal's own Close button. */
|
||||
export async function closeUsage(page: Page): Promise<void> {
|
||||
await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
@@ -257,6 +257,22 @@ describe("settleReports", () => {
|
||||
expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]);
|
||||
});
|
||||
|
||||
it("keeps the displayed order when providers finish a refresh in reverse order", () => {
|
||||
const refreshed = entry({ sourceId: "alpha", planLabel: "New" });
|
||||
const streamed = upsertReport(upsertReport([alpha, beta], beta), refreshed);
|
||||
expect(settleReports(streamed, [beta, refreshed])).toEqual([refreshed, beta]);
|
||||
});
|
||||
|
||||
it("drops missing reports and appends new ones without moving surviving reports", () => {
|
||||
const gamma = entry({ sourceId: "gamma" });
|
||||
const delta = entry({ sourceId: "delta" });
|
||||
expect(settleReports([alpha, beta, gamma], [delta, gamma, alpha])).toEqual([
|
||||
alpha,
|
||||
gamma,
|
||||
delta,
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes the finished list as is when nothing was shown yet", () => {
|
||||
expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]);
|
||||
});
|
||||
|
||||
@@ -82,17 +82,24 @@ export function upsertReport(
|
||||
}
|
||||
|
||||
/**
|
||||
* The list a finished request leaves: its reports, dropping any the host no longer has, except
|
||||
* that a copy on screen fetched after the request's copy stays.
|
||||
* Keep displayed reports in place: the finished list is in fetch-completion order, which can
|
||||
* change on every refresh. Drop missing reports, append new ones, and keep newer fetched copies.
|
||||
*/
|
||||
export function settleReports(
|
||||
shown: readonly UsageReportEntry[] | undefined,
|
||||
finished: readonly UsageReportEntry[],
|
||||
): UsageReportEntry[] {
|
||||
return finished.map((report) => {
|
||||
const copy = shown?.find((entry) => entry.id === report.id);
|
||||
return copy ? laterFetched(copy, report) : report;
|
||||
});
|
||||
const remaining = new Map(finished.map((report) => [report.id, report]));
|
||||
const settled: UsageReportEntry[] = [];
|
||||
for (const report of shown ?? []) {
|
||||
const arriving = remaining.get(report.id);
|
||||
if (!arriving) {
|
||||
continue;
|
||||
}
|
||||
settled.push(laterFetched(report, arriving));
|
||||
remaining.delete(report.id);
|
||||
}
|
||||
return [...settled, ...remaining.values()];
|
||||
}
|
||||
|
||||
export interface UsageQueryState {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import pino from "pino";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { setupFinishNotification } from "./agent-prompt.js";
|
||||
import { ClaudeAgentClient } from "./providers/claude/agent.js";
|
||||
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
|
||||
|
||||
// Diagnostic reproduction for #6346. Requires Claude Code; intentionally fails on main.
|
||||
// PASEO_DIAGNOSE_6346=1 npx vitest run --config packages/server/vitest.config.ts \
|
||||
// packages/server/src/server/agent/diagnose-6346.real.test.ts
|
||||
// The child runs real Claude Code; the parent is an injected recording provider.
|
||||
test.runIf(process.env.PASEO_DIAGNOSE_6346 === "1")(
|
||||
"Claude background completion notifies its parent after the initial finish",
|
||||
async () => {
|
||||
const diagnosticRoot = join(process.cwd(), ".dev");
|
||||
mkdirSync(diagnosticRoot, { recursive: true });
|
||||
const root = mkdtempSync(join(diagnosticRoot, "diagnose-6346-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const storage = new AgentStorage(join(root, "agents"), logger);
|
||||
const received: unknown[] = [];
|
||||
const manager = new AgentManager({
|
||||
registry: storage,
|
||||
logger,
|
||||
paseoToolsEnabled: false,
|
||||
clients: {
|
||||
claude: new ClaudeAgentClient({ logger }),
|
||||
codex: createTestAgentClient("codex", {
|
||||
onStartTurn: (prompt) => {
|
||||
received.push(prompt);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
const ids: string[] = [];
|
||||
let stop: (() => void) | undefined;
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: root, modeId: "full-access" },
|
||||
undefined,
|
||||
{ workspaceId: "wks_diagnosis" },
|
||||
);
|
||||
ids.push(parent.id);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "claude", cwd: root, model: "haiku", modeId: "bypassPermissions" },
|
||||
undefined,
|
||||
{ workspaceId: "wks_diagnosis" },
|
||||
);
|
||||
ids.push(child.id);
|
||||
stop = setupFinishNotification({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
childAgentId: child.id,
|
||||
callerAgentId: parent.id,
|
||||
logger,
|
||||
});
|
||||
const result = await manager.runAgent(
|
||||
child.id,
|
||||
"Use Bash with run_in_background: true to run exactly: sleep 60 && echo BACKGROUND_DONE_6346. Once it is launched, reply WAITING_6346 and end this turn immediately. Do not wait or poll for it. When its task notification arrives later, report FINAL_6346 with the output.",
|
||||
);
|
||||
expect(result.finalText).toContain("WAITING_6346");
|
||||
await vi.waitFor(() => expect(received).toHaveLength(1));
|
||||
expect(received[0]).toContain("WAITING_6346");
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await manager.getLastAssistantMessage(child.id)).toContain("FINAL_6346");
|
||||
},
|
||||
{ timeout: 180_000, interval: 500 },
|
||||
);
|
||||
await vi.waitFor(() => expect(manager.getAgent(child.id)?.lifecycle).toBe("idle"), {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await vi.waitFor(() => expect(received).toHaveLength(2));
|
||||
expect(received[1]).toContain("FINAL_6346");
|
||||
} finally {
|
||||
stop?.();
|
||||
for (const id of ids.toReversed()) await manager.closeAgent(id);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
@@ -18,6 +19,7 @@ function createGitRepoWithBranch(): { repoDir: string; tempRoot: string } {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-create-worktree-source-"));
|
||||
const repoDir = path.join(tempRoot, "repo");
|
||||
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "core.autocrlf", "false"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
@@ -60,10 +62,134 @@ test("workspace.create worktree source forwards action=checkout + refName into t
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test.each([false, true])(
|
||||
"workspace.create opens worktree.terminals (setup service: %s)",
|
||||
async (withSetupService) => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
...(withSetupService
|
||||
? {
|
||||
// Launch the Windows service asynchronously so it outlives
|
||||
// the setup command instead of holding setup open.
|
||||
setup:
|
||||
process.platform === "win32"
|
||||
? "Start-Process -FilePath node -ArgumentList 'setup.cjs','serve' -WorkingDirectory (Get-Location).Path -WindowStyle Hidden; while (!(Test-Path setup-pid.txt)) { Start-Sleep -Milliseconds 25 }"
|
||||
: "node setup.cjs",
|
||||
}
|
||||
: {}),
|
||||
terminals: [
|
||||
{ name: "preview", command: "echo paseo-auto-terminal >> terminal-marker.txt" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(repoDir, "setup.cjs"),
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
if (process.argv[2] === "serve") {
|
||||
require("node:net").createServer().listen(Number(process.env.PASEO_WORKTREE_PORT), "127.0.0.1", () => {
|
||||
fs.writeFileSync("setup-pid.txt", String(process.pid));
|
||||
process.send?.("ready");
|
||||
});
|
||||
} else {
|
||||
const child = require("node:child_process").spawn(process.execPath, [__filename, "serve"], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
child.once("message", () => { child.disconnect(); child.unref(); });
|
||||
}
|
||||
`,
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json", "setup.cjs"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
let workspaceCwd: string | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// This is the request the New Workspace screen sends: the workspace is
|
||||
// created on its own, with no agent in the same request.
|
||||
const result = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: repoDir,
|
||||
action: "branch-off",
|
||||
branchName: "feature/auto-terminals",
|
||||
worktreeSlug: "feature-auto-terminals",
|
||||
baseBranch: "main",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
const workspace = result.workspace;
|
||||
if (!workspace) {
|
||||
throw new Error("Expected a created workspace");
|
||||
}
|
||||
|
||||
workspaceCwd = workspace.workspaceDirectory;
|
||||
if (withSetupService) {
|
||||
await expect
|
||||
.poll(() => existsSync(path.join(workspaceCwd!, "setup-pid.txt")), { timeout: 30000 })
|
||||
.toBe(true);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await client.fetchWorkspaceSetupStatus(workspace.id)).snapshot, {
|
||||
timeout: 30000,
|
||||
})
|
||||
.toMatchObject({ status: "completed", error: null });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await client.listTerminals(workspace.workspaceDirectory, undefined, {
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
).terminals.map((terminal) => terminal.name),
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toContain("preview");
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const marker = path.join(workspaceCwd!, "terminal-marker.txt");
|
||||
return existsSync(marker) ? readFileSync(marker, "utf8").trim() : null;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe("paseo-auto-terminal");
|
||||
expect(
|
||||
(await client.listTerminals(workspaceCwd, undefined, { workspaceId: workspace.id }))
|
||||
.terminals,
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
const pidFile = workspaceCwd && path.join(workspaceCwd, "setup-pid.txt");
|
||||
if (pidFile && existsSync(pidFile)) {
|
||||
process.kill(Number(readFileSync(pidFile, "utf8")));
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
},
|
||||
180000,
|
||||
);
|
||||
|
||||
test("existing branch changes remain visible after archive and restore", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
||||
@@ -74,7 +200,7 @@ test("existing branch changes remain visible after archive and restore", async (
|
||||
onTestFinished(async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
runGit(repoDir, "switch", "feature/existing-branch");
|
||||
commitFile(repoDir, "feature.txt", "feature change\n");
|
||||
@@ -134,7 +260,7 @@ test("workspace.create keeps a branch-off name separate from its worktree slug",
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
@@ -168,7 +294,7 @@ test("workspace.create accepts a Git-valid branch-off name outside Paseo slug sy
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
@@ -210,7 +336,7 @@ test("workspace.create always creates a new worktree when the slug is already oc
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
@@ -255,7 +381,7 @@ test("workspace.create suffixes past an occupied detached worktree", async () =>
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
@@ -297,7 +423,7 @@ test("workspace.create suffixes an occupied checkout branch", async () => {
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
@@ -340,7 +466,7 @@ async function createRestorableWorkspace(baseRef: string) {
|
||||
onTestFinished(async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
createDivergentBases(repoDir);
|
||||
await client.connect();
|
||||
|
||||
@@ -593,13 +593,10 @@ export async function runWorktreeAutoTerminals(options: {
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
workspaceCwd: string;
|
||||
runtimeEnv: WorktreeRuntimeEnv;
|
||||
terminalManager: TerminalManager | null;
|
||||
logger?: Logger;
|
||||
}): Promise<void> {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
});
|
||||
await runWorktreeTerminalBootstrap(
|
||||
{
|
||||
agentId: options.workspaceId,
|
||||
@@ -610,7 +607,7 @@ export async function runWorktreeAutoTerminals(options: {
|
||||
appendTimelineItem: async () => true,
|
||||
logger: options.logger,
|
||||
},
|
||||
runtimeEnv,
|
||||
options.runtimeEnv,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -817,7 +817,6 @@ export async function handleWorkspaceSetupRunRequest(
|
||||
slug: basename(worktree.worktreePath),
|
||||
worktreePath: worktree.worktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
runAutoTerminals: true,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
@@ -857,7 +856,6 @@ export async function runWorktreeSetupInBackground(
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
workspaceCwd?: string;
|
||||
runAutoTerminals?: boolean;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -898,18 +896,18 @@ export async function runWorktreeSetupInBackground(
|
||||
} else {
|
||||
const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath;
|
||||
const setupCommands = getWorktreeSetupCommands(workspaceCwd);
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
repoRootPath: options.repoRoot,
|
||||
});
|
||||
dependencies.terminalManager?.registerCwdEnv({
|
||||
cwd: workspaceCwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
if (setupCommands.length === 0) {
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
repoRootPath: options.repoRoot,
|
||||
});
|
||||
dependencies.terminalManager?.registerCwdEnv({
|
||||
cwd: workspaceCwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: workspaceCwd,
|
||||
branchName: worktree.branchName,
|
||||
@@ -924,15 +922,14 @@ export async function runWorktreeSetupInBackground(
|
||||
});
|
||||
emitSetupProgress("completed", null);
|
||||
}
|
||||
if (options.runAutoTerminals) {
|
||||
await runWorktreeAutoTerminals({
|
||||
workspaceId,
|
||||
worktree,
|
||||
workspaceCwd,
|
||||
terminalManager: dependencies.terminalManager,
|
||||
logger: dependencies.sessionLogger,
|
||||
});
|
||||
}
|
||||
await runWorktreeAutoTerminals({
|
||||
workspaceId,
|
||||
worktree,
|
||||
workspaceCwd,
|
||||
runtimeEnv,
|
||||
terminalManager: dependencies.terminalManager,
|
||||
logger: dependencies.sessionLogger,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
|
||||
@@ -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