Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 2b229223db test(server): reproduce lost Claude autonomous finish notification 2026-10-16 00:05:05 +02:00
f56c013928 Open configured terminals after worktree setup (#6999)
* fix(server): open worktree.terminals when a workspace is created on its own

runWorktreeSetupInBackground only opened the paseo.json worktree.terminals
when its caller passed runAutoTerminals, and only the fork-PR "allow setup"
handler did. workspace.create, the request the New Workspace screen sends,
ran worktree.setup and then skipped the terminals.

Both callers want the terminals, so the flag goes away: terminals follow a
successful setup on a newly created worktree. Reused worktrees stay excluded
by shouldBootstrap, and the agent setup continuation never reaches this
function, so nothing opens twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(server): run automatic terminal regressions in CI

Include workspace creation in the existing integration command, and append to the terminal marker so repeated command execution fails the regression.

* test(server): make workspace fixtures portable on Windows

Run the setup fixture through a command accepted by PowerShell, await its readiness, and fix the fixture Git line-ending policy. Allow Windows directory locks to settle during cleanup.

* test(server): expose setup failures and await fixture cleanup

Check the public setup status before terminal startup so failures distinguish setup errors from terminal creation. Use asynchronous directory removal so Windows cleanup retries leave the event loop available.

* test(server): start the Windows setup service asynchronously

The Windows regression starts its service but setup stays running, so terminal bootstrap is never reached. Launch the fixture service explicitly with Start-Process and retain the readiness and setup-completion assertions.

---------

Co-authored-by: David Yang <9925+davidyang@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 23:11:13 +02:00
paseo-bot[bot]andSleroq 208e323035 Keep usage providers in place after refresh (#6947)
* fix(app): preserve usage provider order across refreshes

* test(usage): keep refresh and scrolling journeys stable

Usage reports can follow the long fixture card, so a fixed wheel distance
can scroll past its final window. Target the named windows with wheel
input while preserving the visibility checks. Assert refreshed provider
order through visible names and keep viewport setup in journey helpers.

Co-authored-by: Sleroq <hireme@sleroq.link>

---------

Co-authored-by: Sleroq <hireme@sleroq.link>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 21:16:52 +02:00
Mohamed Boudra ec7446add4 docs(readme): enlarge sponsor logos 2026-10-15 21:11:51 +02:00
Mohamed Boudra b30302b630 docs(changelog): 0.11.4 2026-10-15 20:26:28 +02:00
Mohamed Boudra 7aa3fac069 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.
2026-10-16 01:19:00 +07:00
19 changed files with 511 additions and 53 deletions
+7 -1
View File
@@ -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))
+1 -1
View File
@@ -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();
+16
View File
@@ -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]);
});
+13 -6
View File
@@ -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 ?? [];
+33
View File
@@ -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",
+2
View File
@@ -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;
}
+1 -1
View File
@@ -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,
);
}
+17 -20
View File
@@ -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],
}),
};