Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andLorenzo Fiore 3e172d18c4 test: retain real OMP ACP provider ID collision reproduction
Reproduce #3178 through configuration parsing, provider registry resolution, and real session creation. The unique omp-acp ID succeeds, while omp keeps the built-in RPC runtime despite extends: acp and times out. This is an opt-in diagnosis test, with no production behavior changes.

Co-authored-by: Lorenzo Fiore <fiorelorenzo.fl@gmail.com>
2026-10-11 23:55:40 +02:00
26 changed files with 99 additions and 804 deletions
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { formatShortcut } from "@/utils/format-shortcut";
import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
@@ -1027,25 +1026,6 @@ 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,45 +22,6 @@ 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("-");
});
+1 -10
View File
@@ -266,16 +266,7 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Cmd");
// 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];
const humanKey = CODE_TO_KEY[event.code];
if (humanKey) {
parts.push(humanKey);
} else {
-30
View File
@@ -1,5 +1,4 @@
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";
@@ -9,35 +8,6 @@ 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.
Object.assign(process.env, { NODE_ENV: "test" });
process.env.NODE_ENV = "test";
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
+2 -3
View File
@@ -1,4 +1,3 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
@@ -442,8 +441,8 @@ function buildNewAgentConfigPatch(
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = parseSafeInteger(value);
if (parsed === undefined || parsed <= 0) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
@@ -1,4 +1,3 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
@@ -86,8 +85,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined {
return undefined;
}
const parsed = parseSafeInteger(value);
if (parsed === undefined) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
-20
View File
@@ -1,20 +0,0 @@
export function parseSafeInteger(value: string): number | undefined {
const input = value.trim();
const match = /^[+-]?(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(input);
if (!match) {
return undefined;
}
const digits = match[1] + (match[2] ?? "");
const parsed = Number(input);
if (digits.length === 0 || !Number.isSafeInteger(parsed)) {
return undefined;
}
// Check the decimal value before floating-point rounding can hide a fraction.
const decimalPlaces = (match[2]?.length ?? 0) - Number(match[3] ?? "0");
if (decimalPlaces > 0 && /[1-9]/.test(digits.slice(-decimalPlaces))) {
return undefined;
}
return parsed;
}
@@ -848,11 +848,6 @@ async function assertSandboxState({ browser, page, expectedSandbox, stdout, stde
function assertLinuxDesktopIdentity(appPath) {
if (process.platform === "linux") {
assert.deepEqual(
fs.readFileSync(path.join(appPath, "resources", "icon.png")),
fs.readFileSync(path.join(__dirname, "..", "assets", "icon.png")),
"Packaged Linux windows must have the Paseo icon at their runtime lookup path",
);
const metadata = JSON.parse(
extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(),
);
-2
View File
@@ -65,8 +65,6 @@ linux:
extraResources:
- from: bin/paseo
to: bin/paseo
- from: assets/icon.png
to: icon.png
target:
- AppImage
- deb
+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/schedule-run-lifecycle.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: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: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,7 +29,6 @@ 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;
@@ -85,9 +84,7 @@ async function runSupervisorFixture(options: {
];
const child = spawn(command, args, {
cwd: repoRoot,
// 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 },
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
@@ -208,16 +205,10 @@ describe("supervisor durable logging", () => {
expect(result.stderr).toContain('"worker-json-stderr"');
});
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: `
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
const result = await runSupervisorFixture({
closeOutput: true,
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
@@ -229,15 +220,14 @@ 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,11 +222,6 @@ 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) => {
@@ -545,11 +540,6 @@ 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,20 +407,13 @@ export interface SetupFinishNotificationParams {
logger: Logger;
}
type FinishNotificationReason =
| "finished"
| "errored"
| "needs permission"
| "was closed"
| "was canceled";
type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed";
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;
@@ -575,8 +568,10 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
notifySafely("errored");
return;
}
// Idle is published before the terminal stream event for both completion
// and cancellation. Let that event identify the outcome instead.
if (agent.lifecycle === "idle" && hasSeenRunning) {
notifySafely("finished");
return;
}
if (agent.lifecycle === "closed") {
notifySafely("was closed");
return;
@@ -605,18 +600,6 @@ 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,8 +12,6 @@ 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;
@@ -313,167 +311,6 @@ 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,10 +4359,6 @@ 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);
}
@@ -0,0 +1,52 @@
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "vitest";
import { ProviderOverridesSchema } from "@getpaseo/protocol/provider-config";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { buildProviderRegistry } from "./provider-registry.js";
import type { AgentSession } from "./agent-sdk-types.js";
// Retained reproduction for #3178, awaiting the built-in ID configuration decision.
// Requires OMP on PATH (or OMP_COMMAND). Uses no model calls or existing provider state.
const runReproduction = process.env.OMP_PROVIDER_COLLISION_REPRO === "1";
for (const providerId of ["omp-acp", "omp"]) {
test.skipIf(!runReproduction)(
`explicit ACP configuration creates an ACP session under '${providerId}'`,
async () => {
const cwd = await mkdtemp(path.join(process.cwd(), ".provider-collision-"));
const agentDir = path.join(cwd, "agent");
await mkdir(agentDir);
const providerOverrides = ProviderOverridesSchema.parse({
[providerId]: {
extends: "acp",
label: "Oh My Pi ACP",
command: [process.env.OMP_COMMAND ?? "omp", "acp"],
enabled: true,
env: {
PI_CODING_AGENT_DIR: agentDir,
OMP_PROFILE: "default",
PI_PROFILE: "default",
XDG_CONFIG_HOME: path.join(cwd, "config"),
XDG_DATA_HOME: path.join(cwd, "data"),
XDG_CACHE_HOME: path.join(cwd, "cache"),
XDG_STATE_HOME: path.join(cwd, "state"),
},
},
});
const logger = createTestLogger();
const registry = buildProviderRegistry(logger, { providerOverrides });
const client = registry[providerId]!.createClient(logger);
let session: AgentSession | undefined;
try {
session = await client.createSession({ provider: providerId, cwd });
expect(session.provider).toBe(providerId);
} finally {
await session?.close();
await client.shutdown?.();
await rm(cwd, { recursive: true, force: true });
}
},
30_000,
);
}
@@ -23,71 +23,6 @@ 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,14 +1,5 @@
import { test, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
readFileSync,
realpathSync,
mkdirSync,
} from "fs";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -161,78 +152,6 @@ 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();
@@ -6,7 +6,6 @@ import { afterEach, beforeEach, expect, test } from "vitest";
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { runPaseoCli } from "../test-utils/cli.js";
let daemon: TestPaseoDaemon;
let client: DaemonClient;
@@ -29,75 +28,6 @@ afterEach(async () => {
await rm(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});
async function createQuietTerminal() {
const workspaceId = await createWorkspace("CLI capture");
return sdk.terminals.create({
workspaceId,
command: process.execPath,
args: ["-e", "process.stdin.resume()"],
size: { rows: 10, cols: 40 },
});
}
test.each([
["--end", "4503599627370496.5"],
["--start", "1.0000000000000001"],
["--end", "4.5035996273704965e15"],
["--end", "1e-324"],
["--start", "1.5"],
["--end", "1.5"],
["--start", "1abc"],
["--end", "1abc"],
["--start", ""],
["--end", ""],
["--start", "9007199254740993"],
["--end", "9007199254740993"],
])("CLI terminal capture rejects %s %j", async (flag, value) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, flag, value, "--json"],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
details: "Use an integer line number.",
},
});
} finally {
await terminal.kill();
}
});
test.each([
{ args: [], lineCount: 10 },
{ args: ["--start", "0", "--end", "1"], lineCount: 2 },
{ args: ["--end", "1.0"], lineCount: 2 },
{ args: ["--end", "10e-1"], lineCount: 2 },
{ args: ["--end", ".1e1"], lineCount: 2 },
{ args: ["--start", "-2", "--end", "-1"], lineCount: 2 },
{ args: ["--scrollback", "--start", "7", "--end", "1"], lineCount: 2 },
])("CLI terminal capture preserves valid ranges $args", async ({ args, lineCount }) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
terminalId: terminal.id,
lines: Array.from({ length: lineCount }, () => ""),
totalLines: 10,
});
} finally {
await terminal.kill();
}
});
test("SDK and workspace handles preserve ownership and actual process directories", async () => {
const first = await createWorkspace("main");
const second = await createWorkspace("feature-work");
@@ -489,7 +489,7 @@ export async function writeExplorerFile({
const temporaryPath = path.join(
path.dirname(filePath.resolvedPath),
`.paseo-${randomUUID()}.tmp`,
`.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`,
);
let temporaryHandle: FileHandle | null = null;
try {
@@ -13,7 +13,6 @@ import {
} from "./test-utils/index.js";
import type { SessionOutboundMessage } from "./messages.js";
import { getPaseoWorktreesRoot } from "../utils/worktree.js";
import { runPaseoCli } from "./test-utils/cli.js";
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
type WorkspaceUpdateMessage = Extract<SessionOutboundMessage, { type: "workspace_update" }>;
@@ -43,137 +42,6 @@ function makeTempDir(prefix: string): string {
return dir;
}
test.each([
"4503599627370496.5",
"1.0000000000000001",
"2.5",
"2runs",
"9007199254740993",
"0",
"-1",
"",
])("CLI schedule create rejects max-runs %j without saving a schedule", async (maxRuns) => {
const cwd = makeTempDir("schedule-cli-integer-");
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"create",
"Integer limit",
"--cron",
"0 0 1 1 *",
"--provider",
"claude",
"--cwd",
cwd,
"--max-runs",
maxRuns,
"--json",
],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
});
expect((await ctx.client.scheduleList()).schedules).toEqual([]);
});
test.each(["2.5", "2runs", "4503599627370496.5"])(
"CLI schedule update rejects max-runs %j without changing the schedule",
async (maxRuns) => {
const cwd = makeTempDir("schedule-cli-update-integer-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
const before = await ctx.client.scheduleInspect({ id: schedule.id });
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"update",
schedule.id,
"--prompt",
"Should not apply",
"--max-runs",
maxRuns,
"--json",
],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
});
const after = await ctx.client.scheduleInspect({ id: schedule.id });
expect(after.error).toBeNull();
expect(after.schedule).toEqual(before.schedule);
},
);
test.each([
{ limitArgs: [], maxRuns: null },
{ limitArgs: ["--max-runs", "3"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "3.0"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "1e2"], maxRuns: 100 },
])(
"CLI schedule create stores max-runs $maxRuns from $limitArgs",
async ({ limitArgs, maxRuns }) => {
const cwd = makeTempDir("schedule-cli-valid-integer-");
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"create",
"Integer limit",
"--cron",
"0 0 1 1 *",
"--provider",
"claude",
"--cwd",
cwd,
...limitArgs,
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleList()).schedules).toEqual([
expect.objectContaining({ maxRuns, prompt: "Integer limit" }),
]);
},
);
test("CLI schedule update preserves, changes, and clears valid run limits", async () => {
const cwd = makeTempDir("schedule-cli-valid-update-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
for (const { args, maxRuns } of [
{ args: ["--name", "Renamed"], maxRuns: 3 },
{ args: ["--max-runs", "5"], maxRuns: 5 },
{ args: ["--no-max-runs"], maxRuns: null },
]) {
const result = await runPaseoCli({
daemon: ctx.daemon,
args: ["schedule", "update", schedule.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleInspect({ id: schedule.id })).schedule).toMatchObject({
maxRuns,
name: "Renamed",
prompt: schedule.prompt,
target: schedule.target,
cadence: schedule.cadence,
});
}
}, 60_000);
function createGitRepo(): string {
const tempRoot = makeTempDir("schedule-run-worktree-");
const repoDir = path.join(tempRoot, "repo");
@@ -192,13 +60,7 @@ function createGitRepo(): string {
cwd: repoDir,
stdio: "pipe",
});
// Use Git's canonical root, as worktree creation does, including Windows path aliases.
return realpathSync(
execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: repoDir,
encoding: "utf8",
}).trim(),
);
return repoDir;
}
async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise<ScheduleSummary> {
@@ -1,54 +0,0 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { TestPaseoDaemon } from "./paseo-daemon.js";
interface CliOptions {
daemon: Pick<TestPaseoDaemon, "port" | "paseoHome">;
args: string[];
}
interface CliResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
export function runPaseoCli({ daemon, args }: CliOptions): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
"--no-warnings",
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
...args,
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
timeout: 20_000,
killSignal: "SIGKILL",
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.once("error", reject);
child.once("close", (exitCode, signal) => {
if (signal) {
reject(new Error(`CLI exited on ${signal}:\n${stdout}\n${stderr}`));
} else {
resolve({ exitCode, stdout, stderr });
}
});
});
}
+6 -9
View File
@@ -1187,16 +1187,13 @@ 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}`;
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;
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
} catch {
return null;
}
@@ -126,15 +126,10 @@ 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"],
@@ -221,8 +216,6 @@ 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),
},
@@ -338,25 +331,18 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => {
});
describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => {
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();
test("direct launch with a space-containing executable works on this platform", async () => {
const fixture = makeFixture();
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
env,
});
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
});
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");
});
});
+2 -3
View File
@@ -10,7 +10,6 @@ import {
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { rm } from "node:fs/promises";
import {
mkdtempSync,
mkdirSync,
@@ -90,8 +89,8 @@ describe("paseo worktree manager", () => {
});
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {