Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] d6ec757026 fix(app): keep malformed font settings from breaking the UI
Unfinished CSS strings in font preferences consumed the shared theme stylesheet. Reject incomplete syntax at the settings boundary, recover persisted malformed values through the existing default path, and restore rejected edits in the font field.
2026-10-12 10:39:17 +02:00
paseo-bot[bot] 9246813fd2 Preserve spaces in project folder names for Changes and worktrees (#6686)
* fix(workspaces): preserve spaces in project directory names

Git path output and app workspace descriptors each stripped significant path whitespace. Preserve directory names while retaining single-record Git output validation and workspace separator normalization.

* test(workspaces): capture Changes after files appear

* test(workspaces): release fixtures when setup fails

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 10:25:18 +02:00
Elowen 06f0522258 fix(server): invalidate worktree listings after archive (#6545)
* fix(server): invalidate worktree listings after archive

* fix(server): clear archived worktrees across cached checkout roots
2026-10-12 08:58:54 +02:00
paseo-bot[bot]and见安 9b92b2e25c Preserve base worktree paths when merging (#6668)
* fix(git): preserve the base worktree path when merging

* test(git): cover merging into whitespace worktree paths

* test(git): follow the server object parameter convention

* test(git): keep merge fixture line endings deterministic

Disable automatic CRLF checkout in the fixture repository so the exact-content assertion works with Windows Git defaults.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 06:00:59 +02:00
Elowen a310444c67 fix: save workspace files with long valid names (#6658)
* fix(file-explorer): keep atomic save temporary names short

* test(file-explorer): cover saving long names through daemon RPC
2026-10-12 04:58:00 +02:00
paseo-bot[bot]and见安 c364b16d40 Report canceled child tasks and wait for replacement tasks to finish (#6660)
* fix(server): report canceled child turns to their callers

* fix(server): wait for replacement child tasks before notifying callers

A displaced turn emits cancellation while its replacement is still running. Notify on terminal events only once the child becomes idle, so Stop and Reload report cancellation while replacement prompts produce one eventual outcome.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

* test(server): remove debug output from cancellation regressions

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 04:00:12 +02:00
paseo-bot[bot] dc7bc8db12 fix(app): keep typechecking after Expo generates environment types (#6656)
Expo declares NODE_ENV readonly after web startup. Preserve the early test environment setup with Object.assign and exercise the real configuration with Expo types in a failing-first compiler regression.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 02:49:09 +02:00
paseo-bot[bot] 40f0393064 Record shortcuts using the keyboard layout character (#6652)
* fix(app): record shortcuts using the keyboard layout character

Shortcut capture stored the QWERTY position while dispatch matched the logical character, so a Dvorak B became Cmd+N. Use the existing logical key mappings for capture and preserve code fallback for named and rewritten Option keys.

* fix(app): keep named shortcut keys on their supported codes

Character capture must not advertise unsupported named keys. Restrict the logical lookup to printable mappings so keypad Enter keeps its existing unsupported behavior.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 01:58:11 +02:00
paseo-bot[bot] 166aabd57b Keep subprocess regression tests independent of terminal colors (#6648)
* test(server): isolate subprocess regressions from terminal colors

Inherited NO_COLOR and FORCE_COLOR make Node emit an unrelated warning. It contaminates argv-fixture stderr and fails the closed-output supervisor fixture before its pipe handlers are installed. Clear both settings only in these fixture subprocesses, preserving their output and lifecycle assertions.

* test(server): exercise conflicting colors regardless of parent environment

Run the closed-output and spaced-executable regressions with explicit conflicting color inputs as well as the inherited environment, so removing fixture isolation fails on ordinary CI environments too.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 00:47:28 +02:00
37 changed files with 981 additions and 84 deletions
@@ -0,0 +1,48 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openSettingsSection } from "../support/helpers/settings";
async function openAppearance(page: Page) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
}
async function setFontFamily(page: Page, name: string, family: string) {
const input = page.getByRole("textbox", { name });
await input.fill(family);
await input.press("Tab");
}
for (const name of ["Code font family", "Interface font family"]) {
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
await openAppearance(page);
await setFontFamily(page, name, "'Fira Code', monospace");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await setFontFamily(page, name, "'foo");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
await page.reload();
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await setFontFamily(page, name, "");
await expect(page.getByRole("textbox", { name })).toHaveValue("");
});
}
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
);
});
await openAppearance(page);
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
});
@@ -0,0 +1,64 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test as base } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const test = base.extend<{ projectWithTrailingSpace: string }>({
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
try {
const repoPath = path.join(root, "repo ");
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.local",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repoPath, stdio: "pipe" },
);
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
const added = await client.addProject(repoPath);
expect(added.error).toBeNull();
const projectId = added.project!.projectId;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repoPath, projectId },
});
expect(created.error).toBeNull();
await provide(created.workspace!.id);
} finally {
await client.removeProject(projectId);
}
} finally {
await rm(root, { recursive: true, force: true });
}
},
});
test.skip(
process.platform === "win32",
"Windows does not preserve trailing spaces in folder names",
);
test("Changes shows files in a project whose folder ends in a space", async ({
page,
projectWithTrailingSpace,
}, testInfo) => {
await gotoWorkspace(page, projectWithTrailingSpace);
await openChangesTreePanel(page);
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("changes.png") });
});
@@ -15,6 +15,7 @@ import {
parseClampedFontSize,
parseTerminalScrollbackLines,
resolveContentMaxWidth,
sanitizeFontFamily,
saveAppSettings,
type SettingsDeps,
} from "./storage";
@@ -1094,3 +1095,34 @@ describe("content max width", () => {
expect((await load("wide")).contentMaxWidth).toBeNull();
});
});
describe("font family CSS safety", () => {
it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])(
"rejects unfinished CSS syntax: %s",
(value) => expect(sanitizeFontFamily(value)).toBeNull(),
);
it.each([
"Menlo",
"'Fira Code', monospace",
'"Fira Code", monospace',
"'Font\\'s name'",
"Font\\ Name",
"'Font (Mono)'",
"'Font [Mono]'",
])("preserves complete font families: %s", (value) =>
expect(sanitizeFontFamily(value)).toBe(value),
);
it.each(["uiFontFamily", "monoFontFamily"])(
"recovers a persisted unmatched quote in %s",
async (field) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }),
}),
});
expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" });
},
);
});
+18 -1
View File
@@ -522,7 +522,24 @@ export function sanitizeFontFamily(value: unknown): string | null {
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
return null; // control chars would corrupt the font-family string
}
return trimmed; // quotes/commas are legit in stacks
// Unistyles serializes theme values into one shared stylesheet. An unfinished
// string, escape, comment, or block consumes the declarations that follow.
let quote: string | null = null;
let escaped = false;
for (const char of trimmed) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (quote !== null) {
if (char === quote) quote = null;
} else if (char === "'" || char === '"') {
quote = char;
} else if (/[()[\]/*]/.test(char)) {
return null;
}
}
return quote === null && !escaped ? trimmed : null;
}
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { formatShortcut } from "@/utils/format-shortcut";
import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
@@ -1026,6 +1027,25 @@ describe("unassigned shortcuts", () => {
});
describe("matching", () => {
it("dispatches a captured Dvorak binding on the same logical key", () => {
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Cmd+B");
const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! });
expect(
resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match
?.action,
).toBe("sidebar.toggle.left");
expect(
resolveShortcut({
event: { key: "x", code: "KeyB", metaKey: true },
context: { isMac: true, isDesktop: true },
bindings,
}).match,
).toBeNull();
});
it("stops matching a shortcut the user unassigned", () => {
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
@@ -22,6 +22,45 @@ function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
}
describe("keyboardEventToComboString", () => {
it("captures the Dvorak character rather than its QWERTY position", () => {
expect(
keyboardEventToComboString(keyboardEvent({ key: "b", code: "KeyN", metaKey: true })),
).toBe("Cmd+B");
});
it("captures shifted Dvorak letters and punctuation at other physical positions", () => {
expect(
keyboardEventToComboString(
keyboardEvent({ key: "T", code: "KeyK", metaKey: true, shiftKey: true }),
),
).toBe("Shift+Cmd+T");
expect(
keyboardEventToComboString(keyboardEvent({ key: ";", code: "KeyZ", ctrlKey: true })),
).toBe("Ctrl+;");
expect(
keyboardEventToComboString(
keyboardEvent({ key: "?", code: "BracketLeft", ctrlKey: true, shiftKey: true }),
),
).toBe("Ctrl+Shift+/");
});
it("keeps named keys and rewritten Option characters capturable", () => {
expect(keyboardEventToComboString(keyboardEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe(
"ArrowLeft",
);
expect(
keyboardEventToComboString(keyboardEvent({ key: "†", code: "KeyT", altKey: true })),
).toBe("Alt+T");
});
it("does not record a named key that dispatch does not support", () => {
expect(
keyboardEventToComboString(
keyboardEvent({ key: "Enter", code: "NumpadEnter", ctrlKey: true }),
),
).toBeNull();
});
it("captures the minus key (regression: previously returned null)", () => {
expect(keyboardEventToComboString(keyboardEvent({ key: "-", code: "Minus" }))).toBe("-");
});
+10 -1
View File
@@ -266,7 +266,16 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Cmd");
const humanKey = CODE_TO_KEY[event.code];
// Capture the same logical character that dispatch matches. Keep the code
// fallback for named keys and Option combinations whose key is rewritten.
const eventKey = event.key.toLowerCase();
const logicalKey = Object.entries(KEY_MAP).find(
([, mapping]) =>
mapping.key?.length === 1 &&
(mapping.key.toLowerCase() === eventKey ||
(event.shiftKey && mapping.shiftedKey === event.key)),
)?.[0];
const humanKey = logicalKey ?? CODE_TO_KEY[event.code];
if (humanKey) {
parts.push(humanKey);
} else {
@@ -241,7 +241,7 @@ interface FontFamilyRowProps {
draft: string;
withBorder: boolean;
onChangeDraft: (value: string) => void;
onCommit: (value: string) => void;
onCommit: (value: string) => string;
}
function FontFamilyRow({
@@ -255,13 +255,16 @@ function FontFamilyRow({
onChangeDraft,
onCommit,
}: FontFamilyRowProps) {
const inputRef = useRef<EditingTextInputHandle>(null);
const handleCommit = useCallback(() => {
onCommit(draft);
const committed = onCommit(draft);
inputRef.current?.replaceText(committed);
}, [draft, onCommit]);
// Resync from the committed value when it changes elsewhere.
useEffect(() => {
onChangeDraft(value);
inputRef.current?.replaceText(value);
// Only resync on external value changes, not on local keystrokes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
@@ -273,6 +276,7 @@ function FontFamilyRow({
<Text style={settingsStyles.rowHint}>{hint}</Text>
</View>
<TextInput
ref={inputRef}
initialValue={draft}
onChangeText={onChangeDraft}
onBlur={handleCommit}
@@ -533,12 +537,13 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setUiFontDraft(settings.uiFontFamily);
return;
return settings.uiFontFamily;
}
setUiFontDraft(sanitized);
if (sanitized !== settings.uiFontFamily) {
void updateSettings({ uiFontFamily: sanitized });
}
return sanitized;
},
[settings.uiFontFamily, updateSettings],
);
@@ -548,12 +553,13 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setMonoFontDraft(settings.monoFontFamily);
return;
return settings.monoFontFamily;
}
setMonoFontDraft(sanitized);
if (sanitized !== settings.monoFontFamily) {
void updateSettings({ monoFontFamily: sanitized });
}
return sanitized;
},
[settings.monoFontFamily, updateSettings],
);
@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory";
describe("resolveWorkspaceDirectory", () => {
it("preserves significant whitespace in a workspace folder name", () => {
expect(resolveWorkspaceDirectory({ workspaceDirectory: "/repo " })).toBe("/repo ");
expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo /" })).toBe("/repo ");
});
it("canonicalizes a workspace directory and returns null when blank", () => {
expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe(
"C:/repo/app",
+2 -3
View File
@@ -13,11 +13,10 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st
}
export function normalizeWorkspacePath(value: string | null | undefined): string | null {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
if (typeof value !== "string" || value.trim().length === 0) {
return null;
}
const withUnixSeparators = trimmed.replace(/\\/g, "/");
const withUnixSeparators = value.replace(/\\/g, "/");
if (withUnixSeparators === "/") {
return withUnixSeparators;
}
+30
View File
@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -8,6 +9,35 @@ const appDir = fileURLToPath(new URL(".", import.meta.url));
const rootDir = path.resolve(appDir, "../..");
const require = createRequire(import.meta.url);
const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs");
const tsgoEntry = path.join(
path.dirname(require.resolve("@typescript/native-preview/package.json")),
"bin/tsgo.js",
);
it("the test configuration typechecks with Expo's generated environment declaration", () => {
const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-"));
try {
writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// <reference types="expo/types" />\n');
writeFileSync(
path.join(fixtureDir, "tsconfig.json"),
JSON.stringify({
extends: "../tsconfig.json",
files: ["../vitest.config.ts", "expo-env.d.ts"],
include: [],
}),
);
const result = spawnSync(
process.execPath,
[tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")],
{ cwd: appDir, encoding: "utf8", timeout: 30_000 },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
}, 40_000);
it.each([
{
+1 -1
View File
@@ -4,7 +4,7 @@ import path from "path";
import fs from "fs";
// Set this before Vite resolves dependencies and Vitest starts its workers.
process.env.NODE_ENV = "test";
Object.assign(process.env, { NODE_ENV: "test" });
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
+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/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/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && 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\"",
"test:integration": "vitest run --maxWorkers=1 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/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\"",
"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",
@@ -29,6 +29,7 @@ async function runSupervisorFixture(options: {
blockLogPath?: boolean;
/** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */
closeOutput?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
@@ -84,7 +85,9 @@ async function runSupervisorFixture(options: {
];
const child = spawn(command, args, {
cwd: repoRoot,
env: { ...process.env },
// A Node color-conflict warning can hit closed stderr before runSupervisor installs
// its pipe-error handlers. This fixture exercises the supervisor's own output.
env: { ...process.env, ...options.env, NO_COLOR: undefined, FORCE_COLOR: undefined },
stdio: ["ignore", "pipe", "pipe"],
});
@@ -205,10 +208,16 @@ describe("supervisor durable logging", () => {
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
const result = await runSupervisorFixture({
closeOutput: true,
workerSource: `
test.each([
{ colors: "inherited", env: {} },
{ colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } },
])(
"keeps supervising the worker when nobody reads its stdout or stderr with $colors colors",
async ({ env }) => {
const result = await runSupervisorFixture({
closeOutput: true,
env,
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
@@ -220,14 +229,15 @@ describe("supervisor durable logging", () => {
}, 500);
setInterval(() => {}, 1000);
`,
});
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain("first stderr line\n");
expect(result.log).toContain("later stdout line\n");
expect(result.log).toContain('"reason":"closed_output_probe"');
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain("first stderr line\n");
expect(result.log).toContain("later stdout line\n");
expect(result.log).toContain('"reason":"closed_output_probe"');
},
);
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
@@ -222,6 +222,11 @@ function createFinishNotificationScenario(
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: { type: "turn_completed", provider: "codex" },
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
@@ -540,6 +545,11 @@ it("does not notify archived callers", async () => {
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: { type: "turn_completed", provider: "codex" },
});
await vi.waitFor(() => {
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
@@ -407,13 +407,20 @@ export interface SetupFinishNotificationParams {
logger: Logger;
}
type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed";
type FinishNotificationReason =
| "finished"
| "errored"
| "needs permission"
| "was closed"
| "was canceled";
const finishNotificationEvents = {
finished: "finished",
errored: "errored",
"needs permission": "permission-required",
"was closed": "closed",
// Use the existing failure event so older clients still parse agent-message metadata.
"was canceled": "errored",
} as const;
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
@@ -568,10 +575,8 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
notifySafely("errored");
return;
}
if (agent.lifecycle === "idle" && hasSeenRunning) {
notifySafely("finished");
return;
}
// Idle is published before the terminal stream event for both completion
// and cancellation. Let that event identify the outcome instead.
if (agent.lifecycle === "closed") {
notifySafely("was closed");
return;
@@ -600,6 +605,18 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") {
// Replacing a turn keeps the child running through the displaced turn's
// cancellation. The task settles only when the child becomes idle.
if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return;
if (event.event.type === "turn_completed" && hasSeenRunning) {
notifySafely("finished");
} else if (event.event.type === "turn_canceled" && hasSeenTurn) {
notifySafely("was canceled");
}
return;
}
if (event.event.type === "permission_requested") {
// A permission pause is an intermediate checkpoint. Forget the run
// observed before it so an idle state during follow-up startup cannot
@@ -12,6 +12,8 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import type { AgentClient, AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { parseAgentMessage } from "./agent-messages/index.js";
interface StructuredContent {
[key: string]: unknown;
@@ -311,6 +313,167 @@ afterAll(async () => {
});
describe("Suite A: Core Fixes", () => {
test.each([
["Stop", "cancelAgent"],
["Reload", "refreshAgent"],
] as const)(
"%s tells the parent that a watched child was canceled",
async (_action, method) => {
let releaseChild!: () => void;
let childReachedGate!: () => void;
const heldTurn = new Promise<void>((resolve) => {
releaseChild = resolve;
});
const childAtGate = new Promise<void>((resolve) => {
childReachedGate = resolve;
});
const parentPrompts: string[] = [];
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
onStartTurn(prompt, config) {
if (config.title === "Watching parent" && typeof prompt === "string")
parentPrompts.push(prompt);
},
async beforeTurnComplete(prompt) {
if (
typeof prompt === "string" &&
parseAgentMessage(prompt).text === "Hold this child turn"
) {
childReachedGate();
await heldTurn;
}
},
}),
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.11.2",
});
let mcp: McpClient | undefined;
let unsubscribe: (() => void) | undefined;
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const parent = await client.createAgent({
provider: "claude",
cwd: daemon.paseoHome,
title: "Watching parent",
modeId: "bypassPermissions",
});
mcp = await createMcpClient(
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: parent.id }),
);
const created = await callToolStructured(mcp, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Watched child",
provider: "codex/gpt-5.4",
initialPrompt: "Hold this child turn",
notifyOnFinish: true,
settings: { modeId: "full-access" },
});
const childId = str(created.agentId);
await childAtGate;
expect(daemon.daemon.agentManager.getAgent(childId)?.lifecycle).toBe("running");
const terminalEvents: string[] = [];
unsubscribe = daemon.daemon.agentManager.subscribe(
(event) => {
if (
event.type === "agent_stream" &&
event.agentId === childId &&
(event.event.type === "turn_completed" || event.event.type === "turn_canceled")
)
terminalEvents.push(event.event.type);
},
{ agentId: childId, replayState: false },
);
await client[method](childId);
await waitFor({
timeoutMs: 10000,
label: "parent cancellation notification",
check: () => (parentPrompts.length === 1 ? true : null),
});
const notification = parseAgentMessage(parentPrompts[0]);
expect(terminalEvents).toEqual(["turn_canceled"]);
expect(notification.source).toEqual({
kind: "agent-notification",
agentId: childId,
title: "Watched child",
event: "errored",
});
expect(notification.text).toContain(`Agent ${childId} (Watched child) was canceled.`);
expect(notification.text).not.toContain("finished.");
await waitFor({
timeoutMs: 10000,
label: "parent notification turn drained",
check: () =>
daemon.daemon.agentManager.getAgent(parent.id)?.lifecycle === "idle" ? true : null,
});
expect(parentPrompts).toHaveLength(1);
} finally {
releaseChild();
unsubscribe?.();
await mcp?.close();
await client.close();
await daemon.close();
}
},
30000,
);
test.each([
{ outcome: "finished", prompt: "say done and stop", status: "idle" },
{ outcome: "errored", prompt: "Emit a turn failure", status: "error" },
])(
"a watched child still reports $outcome after send_agent_prompt",
async ({ outcome, prompt, status }) => {
const childId = await createChildAgent({
title: "Follow-up child",
settings: { modeId: "bypassPermissions" },
});
try {
await waitFor({
timeoutMs: 10000,
label: "initial child turn completed",
check: () =>
daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null,
});
await callToolStructured(agentScopedClient, "send_agent_prompt", {
agentId: childId,
prompt,
notifyOnFinish: true,
});
const notifications = await waitFor({
timeoutMs: 10000,
label: `${outcome} notification`,
check: () => {
const items = daemonHandle.daemon.agentManager
.getTimeline(parentAgentId)
.filter(
(item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId,
);
return items.length > 0 ? items : null;
},
});
expect(notifications).toEqual([
expect.objectContaining({
type: "tool_call",
agentMessage: {
event: outcome,
sender: { id: childId, title: "Follow-up child" },
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
},
}),
]);
expect(daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle).toBe(status);
} finally {
await archiveAgentIfPresent(childId);
}
},
30000,
);
test("AGENT_WAIT_TIMEOUT_MS is 30000", () => {
expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000);
});
@@ -4359,6 +4359,10 @@ describe("send_agent_prompt MCP tool", () => {
await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(finishNotifications()).toHaveLength(1);
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(parentClient.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: childId, event: "finished" },
});
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
@@ -112,7 +112,7 @@ export interface PaseoToolHostDependencies {
github?: ForgeService;
workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
"getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees"
>;
findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"];
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
@@ -23,6 +23,71 @@ afterEach(async () => {
}, 60000);
describe("file explorer", () => {
test.each([
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
{ description: "an ordinary name", fileName: "notes.txt" },
{ description: "a long Unicode name", fileName: `${"文".repeat(76)}.txt` },
])("saves text files with $description", async ({ fileName }) => {
const cwd = tmpCwd();
try {
const created = await ctx.client.createFileEntry({
cwd,
parentPath: ".",
name: fileName,
kind: "file",
});
expect(created).toMatchObject({ path: fileName, success: true, error: null });
writeFileSync(path.join(cwd, fileName), "original payload\n");
const initial = await ctx.client.readFile(cwd, fileName);
const saved = await ctx.client.writeFile({
cwd,
path: fileName,
content: "saved payload\n",
expectedModifiedAt: initial.modifiedAt,
expectedRevision: initial.revision,
});
expect(saved).toMatchObject({ status: "written", size: 14 });
const after = await ctx.client.readFile(cwd, fileName);
expect(after.path).toBe(fileName);
expect(new TextDecoder().decode(after.bytes)).toBe("saved payload\n");
const staleSave = await ctx.client.writeFile({
cwd,
path: fileName,
content: "stale payload\n",
expectedModifiedAt: initial.modifiedAt,
expectedRevision: initial.revision,
});
expect(staleSave).toMatchObject({
status: "conflict",
version: {
status: "ready",
path: fileName,
size: after.size,
modifiedAt: after.modifiedAt,
revision: after.revision,
},
});
const afterConflict = await ctx.client.readFile(cwd, fileName);
expect(new TextDecoder().decode(afterConflict.bytes)).toBe("saved payload\n");
const secondSave = await ctx.client.writeFile({
cwd,
path: fileName,
content: "second payload\n",
expectedModifiedAt: afterConflict.modifiedAt,
expectedRevision: afterConflict.revision,
});
expect(secondSave).toMatchObject({ status: "written", size: 15 });
const final = await ctx.client.readFile(cwd, fileName);
expect(new TextDecoder().decode(final.bytes)).toBe("second payload\n");
expect(readdirSync(cwd)).toEqual([fileName]);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test.each([
{
query: "implementation",
@@ -1,5 +1,14 @@
import { test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
readFileSync,
realpathSync,
mkdirSync,
} from "fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -152,6 +161,78 @@ afterEach(async () => {
await ctx.cleanup();
}, 60000);
interface GitCommandOptions {
cwd: string;
args: string[];
}
async function mergeFeatureIntoBaseWorktree(baseName: string): Promise<void> {
const directory = realpathSync(tmpCwd());
const base = path.join(directory, baseName);
const feature = path.join(directory, "feature");
function git({ cwd, args }: GitCommandOptions): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
mkdirSync(base);
git({ cwd: base, args: ["init", "-b", "main"] });
git({ cwd: base, args: ["config", "user.name", "Paseo Test"] });
git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] });
git({ cwd: base, args: ["config", "commit.gpgsign", "false"] });
git({ cwd: base, args: ["config", "core.autocrlf", "false"] });
git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] });
git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] });
writeFileSync(path.join(feature, "feature.txt"), "feature payload\n");
git({ cwd: feature, args: ["add", "feature.txt"] });
git({ cwd: feature, args: ["commit", "-m", "feature"] });
const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim();
const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" });
expect(merged).toMatchObject({ success: true, error: null });
expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit);
expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n");
expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature");
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])(
"merges into a base worktree whose path preserves %j",
mergeFeatureIntoBaseWorktree,
);
test.each(["base checkout", "中文 checkout"])(
"merges into a base worktree named %j",
mergeFeatureIntoBaseWorktree,
);
test("merges into a local base branch without another worktree", async () => {
const cwd = realpathSync(tmpCwd());
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
git(["init", "-b", "main"]);
git(["config", "user.name", "Paseo Test"]);
git(["config", "user.email", "test@paseo.local"]);
git(["config", "commit.gpgsign", "false"]);
git(["commit", "--allow-empty", "-m", "base"]);
git(["checkout", "-b", "feature"]);
writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n");
git(["add", "feature.txt"]);
git(["commit", "-m", "feature"]);
const featureCommit = git(["rev-parse", "HEAD"]).trim();
const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" });
expect(merged).toMatchObject({ success: true, error: null });
expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit);
expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("returns diff for modified file in git repo", async () => {
const cwd = tmpCwd();
@@ -0,0 +1,73 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, expect, onTestFinished, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
// Windows does not preserve trailing spaces in folder names.
const testOnPosix = test.skipIf(process.platform === "win32");
let client: DaemonClient;
let repoDir: string;
let projectId: string;
beforeEach(async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-path-spaces-")));
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
repoDir = join(root, "repo ");
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.local",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repoDir, stdio: "pipe" },
);
const daemon = await createTestPaseoDaemon();
onTestFinished(() => daemon.close());
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const testClient = client;
onTestFinished(() => testClient.close());
await client.connect();
const added = await client.addProject(repoDir);
expect(added.error).toBeNull();
expect(added.project?.projectRootPath).toBe(repoDir);
projectId = added.project!.projectId;
});
testOnPosix("a project folder ending in a space keeps its Git status", async () => {
const opened = await client.createWorkspace({
source: { kind: "directory", path: repoDir, projectId },
});
expect(opened.error).toBeNull();
expect(opened.workspace?.workspaceDirectory).toBe(repoDir);
const status = await client.getCheckoutStatus(repoDir);
expect(status.isGit).toBe(true);
expect(status.repoRoot).toBe(repoDir);
expect(status.currentBranch).toBe("main");
});
testOnPosix("a project folder ending in a space can create a worktree", async () => {
const created = await client.createWorkspace({
source: {
kind: "worktree",
projectId,
action: "branch-off",
branchName: "feature/spaces",
baseBranch: "main",
},
});
expect(created.error).toBeNull();
expect(created.workspace?.gitRuntime?.currentBranch).toBe("feature/spaces");
});
@@ -489,7 +489,7 @@ export async function writeExplorerFile({
const temporaryPath = path.join(
path.dirname(filePath.resolvedPath),
`.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`,
`.paseo-${randomUUID()}.tmp`,
);
let temporaryHandle: FileHandle | null = null;
try {
@@ -55,6 +55,7 @@ export function createNoopWorkspaceGitService(
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
invalidateWorktrees: () => {},
getProjectSlug: async (cwd: string) => {
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
@@ -246,6 +246,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
invalidateWorktrees: () => {},
getProjectSlug: async (cwd: string) => {
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
@@ -1,10 +1,13 @@
import { execFileSync, spawn } from "node:child_process";
import { execFile, execFileSync, spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createWorktree } from "../utils/worktree.js";
import {
createDaemonTestContext,
DaemonClient,
@@ -54,6 +57,32 @@ function createGitRepo(): string {
return repoDir;
}
async function runCli(args: string[], cwd: string): Promise<string> {
const result = await promisify(execFile)(
process.execPath,
[
"--conditions=source",
"--import",
import.meta.resolve("tsx"),
fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${ctx.daemon.port}`,
...args,
],
{
cwd,
env: {
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
),
PASEO_HOME: ctx.daemon.paseoHome,
},
timeout: 20000,
},
);
return result.stdout;
}
async function createLocalWorkspace(cwd: string, title: string): Promise<string> {
const result = await ctx.client.createWorkspace({
source: { kind: "directory", path: cwd },
@@ -248,6 +277,78 @@ test("renaming a workspace updates every subscribed client", async () => {
}
});
test.each(["worktree", "workspace"] as const)(
"%s archive immediately removes the deleted worktree from a warm CLI listing",
async (command) => {
const repoDir = createGitRepo();
const created = await ctx.client.createWorkspace({
source: {
kind: "worktree",
cwd: repoDir,
worktreeSlug: "cached-worktree",
baseBranch: "main",
},
});
const workspace = created.workspace;
if (!workspace?.workspaceDirectory) {
throw new Error(created.error ?? "Failed to create worktree workspace");
}
const worktreePath = workspace.workspaceDirectory;
const name = path.basename(worktreePath);
const before = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir));
expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]);
await runCli(
[command, "archive", command === "worktree" ? name : workspace.id, "--json"],
repoDir,
);
expect(existsSync(worktreePath)).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir }).toString(),
).not.toContain(worktreePath);
const after = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir));
console.log(
`${command} archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`,
);
expect(after).toEqual([]);
},
60000,
);
test("archiving an unregistered worktree in a bare repo clears the main checkout listing", async () => {
const sourceRepo = createGitRepo();
const repoRoot = makeTempDir("workspace-archive-bare-");
const bareRepo = path.join(repoRoot, "repo.git");
const mainCheckout = path.join(repoRoot, "main");
execFileSync("git", ["clone", "--bare", sourceRepo, bareRepo], { stdio: "pipe" });
execFileSync("git", ["worktree", "add", mainCheckout, "main"], {
cwd: bareRepo,
stdio: "pipe",
});
const { worktreePath } = await createWorktree({
cwd: mainCheckout,
source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" },
worktreeSlug: "cached-bare-worktree",
runSetup: false,
paseoHome: ctx.daemon.paseoHome,
});
expect(await activeWorkspaceIds()).toEqual(new Set());
const name = path.basename(worktreePath);
const before = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout));
expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]);
await runCli(["worktree", "archive", name, "--json"], mainCheckout);
expect(existsSync(worktreePath)).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainCheckout }).toString(),
).not.toContain(worktreePath);
const after = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout));
console.log(
`bare repo archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`,
);
expect(after).toEqual([]);
}, 60000);
test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => {
const repoDir = createGitRepo();
@@ -140,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
github: createGitHubServiceStub(),
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
} as unknown as Pick<WorkspaceGitService, "getSnapshot">,
invalidateWorktrees: () => {},
} as unknown as Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">,
agentManager: {
listAgents: () => [],
getAgent: () => null,
@@ -617,28 +618,37 @@ describe("archiveByScope", () => {
expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled();
});
test("worktree scope removes an owned directory with zero matching records", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records");
test.each(["intact", "missing"] as const)(
"worktree scope removes an owned directory with zero matching records and %s Git administration",
async (gitAdministration) => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records");
if (gitAdministration === "missing") {
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
cwd: worktree.worktreePath,
})
.toString()
.trim();
rmSync(gitDir, { recursive: true, force: true });
}
const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] });
const invalidateWorktrees = vi.fn();
dependencies.workspaceGitService.invalidateWorktrees = invalidateWorktrees;
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [],
}),
{
const result = await archiveByScope(dependencies, {
scope: { kind: "worktree", targetPath: worktree.worktreePath },
requestId: "req-zero-records",
},
);
});
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
});
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
expect(invalidateWorktrees).toHaveBeenCalledTimes(1);
},
);
test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => {
const { tempDir, repoDir } = createGitRepo();
@@ -32,7 +32,7 @@ export interface ArchiveDependencies {
// Base directory that may hold worktrees across repositories.
paseoWorktreesBaseRoot?: string;
github: ForgeService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">;
agentManager: Pick<AgentManager, "listAgents" | "getAgent" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "listByWorkspace">;
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
@@ -408,6 +408,7 @@ async function maybeRemoveDirectory(
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
});
dependencies.workspaceGitService.invalidateWorktrees();
dependencies.github.invalidate({ cwd: backing.path });
return true;
} catch (error) {
@@ -1819,6 +1819,46 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
service.dispose();
});
test("invalidating worktrees discards warm listings across checkout roots and in-flight reads", async () => {
const worktrees = [
{
path: "/tmp/paseo-home/worktrees/repo/feature",
createdAt: "2026-04-12T00:00:00.000Z",
branchName: "feature",
},
];
const readStarted = createDeferred<void>();
const oldRead = createDeferred<typeof worktrees>();
const alternateCheckout = resolvePath("/tmp/alternate-checkout");
let reads = 0;
const service = createService({
listPaseoWorktrees: async () => {
reads += 1;
if (reads <= 2) return worktrees;
if (reads === 3) {
readStarted.resolve();
return oldRead.promise;
}
return [];
},
});
try {
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual(worktrees);
await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual(worktrees);
const pending = service.listWorktrees(REPO_CWD, { force: true, reason: "test" });
await readStarted.promise;
service.invalidateWorktrees();
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]);
await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual([]);
oldRead.resolve(worktrees);
await expect(pending).resolves.toEqual(worktrees);
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]);
} finally {
oldRead.resolve(worktrees);
await service.dispose();
}
});
test("listWorktrees shares one repo-root scoped read across sibling workspace cwds", async () => {
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-")));
const repoDir = join(tempDir, "repo");
@@ -241,6 +241,7 @@ export interface WorkspaceGitService {
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitWorktreeInfo[]>;
invalidateWorktrees(): void;
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
@@ -918,6 +919,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
: (snapshot.git.repoRoot ?? resolve(cwd));
}
invalidateWorktrees(): void {
this.assertNotDisposed();
// Checkout paths can cache the same list under different roots, including bare repos.
// Detach in-flight reads too, so a pre-mutation result cannot refill the cache.
this.worktreeListCache.clear();
}
async resolveDefaultBranch(
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
@@ -2017,6 +2017,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2088,6 +2089,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2164,6 +2166,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2240,6 +2243,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -501,7 +501,10 @@ export async function handlePaseoWorktreeArchiveRequest(
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
> & {
emit: EmitSessionMessage;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
workspaceGitService: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
@@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit<
ArchiveDependencies,
"workspaceGitService"
> {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
workspaceGitService: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
>;
}
export interface ArchiveCommandInput {
+9 -6
View File
@@ -1187,13 +1187,16 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] {
async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
const entries = parseWorktreeList(stdout);
const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`;
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
const { stdout } = await runGitCommand(
["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref],
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
for (const record of stdout.split("\0\n")) {
const [reference, worktreePath] = record.split("\0");
if (reference === ref) return worktreePath || null;
}
return null;
} catch {
return null;
}
@@ -0,0 +1,26 @@
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
describe("Git path output", () => {
it.each(["\n", "\r\n", ""])("preserves path whitespace with delimiter %j", (delimiter) => {
expect(parseGitRevParsePath(`/repo ${delimiter}`)).toBe("/repo ");
expect(parseGitRevParsePath(` relative path ${delimiter}`)).toBe(" relative path ");
});
it.each([
"",
"\n",
"\r\n",
"--path-format=absolute\n",
"--path-format=absolute\n/repo\n",
"/repo\n/another\n",
"/repo\n\n",
])("rejects invalid output %j", (output) => {
expect(parseGitRevParsePath(output)).toBeNull();
});
it("resolves a relative path without stripping its trailing space", () => {
expect(resolveGitRevParsePath("/base", "repo \n")).toBe(resolve("/base", "repo "));
});
});
@@ -1,18 +1,9 @@
import { resolve } from "node:path";
export function parseGitRevParsePath(stdout: string): string | null {
const trimmed = stdout.trim();
if (!trimmed) {
return null;
}
const lines = trimmed.split(/\r?\n/);
if (lines.length !== 1) {
return null;
}
const path = lines[0]?.trim() ?? "";
if (!path || path.startsWith("--")) {
// Remove Git's record delimiter, not whitespace belonging to the path.
const path = stdout.replace(/\r?\n$/, "");
if (!path || /[\r\n]/.test(path) || path.startsWith("--")) {
return null;
}
@@ -126,10 +126,15 @@ async function runFixture(params: {
command: string;
args: string[];
shell?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<SpawnResult> {
const child = spawnProcess(params.command, params.args, {
env: {
...process.env,
...params.env,
// Terminal color settings must not add Node warnings to the argv fixture's stderr.
NO_COLOR: undefined,
FORCE_COLOR: undefined,
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]),
},
stdio: ["ignore", "pipe", "pipe"],
@@ -216,6 +221,8 @@ async function findAndLaunch(fixture: LaunchFixture): Promise<{
const child = spawnProcess(found, fixture.args, {
env: {
...process.env,
NO_COLOR: undefined,
FORCE_COLOR: undefined,
PASEO_EXPECTED_ARGV_JSON: fixture.expectedArgvJson,
PASEO_ARGV_SLICE_FROM: String(fixture.sliceFrom),
},
@@ -331,18 +338,25 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => {
});
describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => {
test("direct launch with a space-containing executable works on this platform", async () => {
const fixture = makeFixture();
test.each([
{ colors: "inherited", env: {} },
{ colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } },
])(
"direct launch with a space-containing executable works with $colors colors",
async ({ env }) => {
const fixture = makeFixture();
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
});
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
env,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
},
);
});