From 964bf3e13f5d4e5031c816209c5fae9208e9c7bb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 12:03:01 +0700 Subject: [PATCH 01/21] fix(server): centralize git subprocesses with p-limit throttling (#310) * Fix uncaught timeout rejection when archiving agents * fix(server): centralize git subprocesses under runGitCommand with p-limit throttling Supersedes #299. Same problem (unbounded git subprocess concurrency causing zombie accumulation), different approach: a single `runGitCommand(args, opts)` function backed by `p-limit` instead of a custom semaphore pool. - Always uses `spawn` (one code path, no exec/execFile split) - Concurrency configurable via PASEO_GIT_CONCURRENCY env var (default 8) - 30s default timeout for read-only ops, 120s for mutations - Centralized error formatting, stdout truncation, process reaping - Runtime tests verifying throttling, timeout kills, deadlock prevention * fix: update lockfile signatures and Nix hash * fix(server): add Windows compatibility to runGitCommand - shell: true on Windows (resolves .cmd/.bat git shims) - windowsHide: true always (prevents console window flashes) - quoteForCmd for args with spaces when routing through cmd.exe - Tests verify shell/windowsHide behavior per platform * refactor: use spawnProcess instead of duplicating Windows handling spawnProcess from spawn.ts already handles shell: true on Windows, arg quoting for cmd.exe, and windowsHide: true. No need to reimplement. * fix: non-null assertions for stdio streams in strict build --------- Co-authored-by: github-actions[bot] --- nix/package.nix | 2 +- package-lock.json | 28 ++ packages/app/src/components/agent-list.tsx | 3 +- .../screens/workspace/workspace-screen.tsx | 3 +- packages/server/package.json | 1 + .../src/server/checkout-diff-manager.test.ts | 27 +- .../src/server/checkout-diff-manager.ts | 16 +- .../server/src/server/checkout-git-utils.ts | 7 +- .../src/server/workspace-git-service.ts | 8 +- .../server/src/server/worktree-bootstrap.ts | 6 +- .../server/src/server/worktree-session.ts | 7 +- .../src/utils/checkout-git-batching.test.ts | 4 +- packages/server/src/utils/checkout-git.ts | 308 ++++++-------- .../server/src/utils/run-git-command.test.ts | 402 ++++++++++++++++++ packages/server/src/utils/run-git-command.ts | 140 ++++++ packages/server/src/utils/worktree.ts | 62 ++- 16 files changed, 770 insertions(+), 254 deletions(-) create mode 100644 packages/server/src/utils/run-git-command.test.ts create mode 100644 packages/server/src/utils/run-git-command.ts diff --git a/nix/package.nix b/nix/package.nix index 1ef702bf2..5f7783bcd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU="; + npmDepsHash = "sha256-bLM+tKHQiiu2ggZeYKFAO6OQFNGVxzXgxNQf+vNpTUQ="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index 26f28539a..280d4c25b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35406,6 +35406,7 @@ "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", + "p-limit": "^7.3.0", "pi-acp": "^0.0.24", "pino": "^10.2.0", "pino-pretty": "^13.1.3", @@ -35681,6 +35682,21 @@ "node": ">= 0.6" } }, + "packages/server/node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/server/node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -35778,6 +35794,18 @@ "node": ">= 0.6" } }, + "packages/server/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/server/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 63384bb64..404e4b37c 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -258,7 +258,8 @@ export function AgentList({ if (!actionAgent || !actionClient) { return; } - void actionClient.archiveAgent(actionAgent.id); + // Timeout errors are swallowed — the daemon will still process the archive + void actionClient.archiveAgent(actionAgent.id).catch(() => {}); setActionAgent(null); }, [actionAgent, actionClient]); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d17f10ada..af6897336 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1291,7 +1291,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }); } - void archiveAgent({ serverId: normalizedServerId, agentId }); + // Errors (e.g. timeout) are handled by the mutation's onSettled callback + void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {}); }); }, [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey], diff --git a/packages/server/package.json b/packages/server/package.json index 23a3a5d5e..e0b9b5120 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -82,6 +82,7 @@ "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", + "p-limit": "^7.3.0", "pi-acp": "^0.0.24", "pino": "^10.2.0", "pino-pretty": "^13.1.3", diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index b683cd38a..356f8cf15 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,19 +1,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = - vi.hoisted(() => { +const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted( + () => { const hoistedWatchCalls: Array<{ path: string; close: ReturnType }> = []; return { - execMock: vi.fn( - ( - _command: string, - _options: unknown, - callback: (error: null, result: { stdout: string; stderr: string }) => void, - ) => { - callback(null, { stdout: "/tmp/repo\n", stderr: "" }); - }, - ), getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"), readdirMock: vi.fn(async (directory: string) => { @@ -40,10 +31,17 @@ const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, w }), watchCalls: hoistedWatchCalls, }; - }); + }, +); -vi.mock("child_process", () => ({ - exec: execMock, +vi.mock("../utils/run-git-command.js", () => ({ + runGitCommand: vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), })); vi.mock("node:fs/promises", async () => { @@ -88,7 +86,6 @@ describe("CheckoutDiffManager Linux watchers", () => { beforeEach(() => { watchCalls.length = 0; - execMock.mockClear(); getCheckoutDiffMock.mockClear(); resolveCheckoutGitDirMock.mockClear(); readdirMock.mockClear(); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 8a5246cd0..0274fff22 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,16 +1,13 @@ import { watch, type FSWatcher } from "node:fs"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; -import { exec } from "child_process"; -import { promisify } from "util"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; import { getCheckoutDiff } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js"; -const execAsync = promisify(exec); - const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; @@ -177,10 +174,13 @@ export class CheckoutDiffManager { private async resolveCheckoutWatchRoot(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const root = stdout.trim(); return root.length > 0 ? root : null; } catch { diff --git a/packages/server/src/server/checkout-git-utils.ts b/packages/server/src/server/checkout-git-utils.ts index a27460e1b..9c2c2e1e3 100644 --- a/packages/server/src/server/checkout-git-utils.ts +++ b/packages/server/src/server/checkout-git-utils.ts @@ -1,12 +1,9 @@ -import { exec } from "child_process"; -import { promisify } from "util"; import { MergeConflictError, MergeFromBaseConflictError, NotGitRepoError, } from "../utils/checkout-git.js"; - -const execAsync = promisify(exec); +import { runGitCommand } from "../utils/run-git-command.js"; export const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, @@ -22,7 +19,7 @@ export type CheckoutErrorPayload = { export async function resolveCheckoutGitDir(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --absolute-git-dir", { + const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], { cwd, env: READ_ONLY_GIT_ENV, }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 07f29fc88..62508470a 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,8 +1,6 @@ -import { execFile } from "node:child_process"; import { watch, type FSWatcher } from "node:fs"; import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { promisify } from "node:util"; import type pino from "pino"; import type { CheckoutContext } from "../utils/checkout-git.js"; import { @@ -13,10 +11,9 @@ import { resolveGhPath, resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; -const execFileAsync = promisify(execFile); - const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; @@ -587,11 +584,12 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { } async function runGitFetch(cwd: string): Promise { - await execFileAsync("git", ["fetch", "origin", "--prune"], { + await runGitCommand(["fetch", "origin", "--prune"], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", }, + timeout: 120_000, }); } diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index afeec0c29..874cbfa88 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,10 +1,9 @@ import { v4 as uuidv4 } from "uuid"; import type { Logger } from "pino"; -import { exec } from "node:child_process"; -import { promisify } from "node:util"; import { sep } from "node:path"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { createWorktree, getWorktreeTerminalSpecs, @@ -50,7 +49,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; -const execAsync = promisify(exec); const worktreeSetupEligibility = new WeakMap(); type MiddleTruncationAccumulator = { @@ -195,7 +193,7 @@ async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptio } async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { - const { stdout } = await execAsync("git branch --show-current", { + const { stdout } = await runGitCommand(["branch", "--show-current"], { cwd: worktreePath, env: READ_ONLY_GIT_ENV, }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index c1974b567..637a95f4c 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -1,5 +1,3 @@ -import { exec } from "node:child_process"; -import { promisify } from "node:util"; import type { Logger } from "pino"; import { v4 as uuidv4 } from "uuid"; @@ -36,9 +34,8 @@ import { validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js"; - -const execAsync = promisify(exec); const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; export type NormalizedGitOptions = { @@ -154,7 +151,7 @@ export async function buildAgentSessionConfig( if (normalized.createNewBranch) { targetBranch = normalized.newBranchName!; } else { - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd, env: READ_ONLY_GIT_ENV, }); diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 7ef40a386..1d2445bc1 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -20,7 +20,9 @@ vi.mock("child_process", async () => { normalizedArgs[0] === "diff" && normalizedArgs.includes("HEAD") && !normalizedArgs.includes("--numstat") && - !normalizedArgs.includes("--no-index"); + !normalizedArgs.includes("--no-index") && + !normalizedArgs.includes("--shortstat") && + !normalizedArgs.includes("--name-status"); if (isTrackedTextDiff) { spawnCounters.trackedTextDiffCalls += 1; } diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index b862666ad..10d4a972a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,5 +1,4 @@ -import { exec, execFile } from "child_process"; -import { spawnProcess } from "./spawn.js"; +import { execFile } from "child_process"; import { promisify } from "util"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; @@ -8,17 +7,16 @@ import { TTLCache } from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { findExecutable } from "./executable.js"; +import { runGitCommand } from "./run-git-command.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; -const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; -const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000; const PULL_REQUEST_STATUS_CACHE_MAX = 1_000; @@ -63,91 +61,6 @@ export function __setGhPathForTests(path: string | null): void { cachedGhPath = path; } -async function execGit( - command: string, - options: { cwd: string; env?: NodeJS.ProcessEnv }, -): Promise<{ stdout: string; stderr: string }> { - return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER }); -} - -type LimitedTextResult = { - text: string; - truncated: boolean; - exitCode: number | null; - signal: NodeJS.Signals | null; -}; - -async function spawnLimitedText(params: { - cmd: string; - args: string[]; - cwd: string; - env?: NodeJS.ProcessEnv; - maxBytes: number; - acceptExitCodes?: number[]; -}): Promise { - const accept = new Set(params.acceptExitCodes ?? [0]); - - return new Promise((resolvePromise, rejectPromise) => { - const child = spawnProcess(params.cmd, params.args, { - cwd: params.cwd, - env: params.env, - stdio: ["ignore", "pipe", "pipe"], - }); - - const stdoutChunks: Buffer[] = []; - let stdoutBytes = 0; - let truncated = false; - - const stop = () => { - if (child.killed) return; - try { - child.kill("SIGKILL"); - } catch { - // ignore - } - }; - - child.stdout!.on("data", (chunk: Buffer) => { - if (truncated) return; - stdoutBytes += chunk.length; - if (stdoutBytes > params.maxBytes) { - truncated = true; - stop(); - return; - } - stdoutChunks.push(chunk); - }); - - // We don't buffer stderr (it can be large too). Keep it minimal for debugging. - let stderrPreview = ""; - child.stderr!.on("data", (chunk: Buffer) => { - if (stderrPreview.length > 2048) return; - stderrPreview += chunk.toString("utf8"); - }); - - child.on("error", (error) => { - rejectPromise(error); - }); - - child.on("close", (code, signal) => { - if (code !== null && !accept.has(code) && !truncated) { - rejectPromise( - new Error( - `Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`, - ), - ); - return; - } - resolvePromise({ - text: Buffer.concat(stdoutChunks).toString("utf8"), - truncated, - exitCode: code, - signal, - }); - }); - }); -} - type CheckoutFileChange = { path: string; oldPath?: string; @@ -187,8 +100,13 @@ interface GitRef { } async function listGitRefs(cwd: string, refPrefix: string): Promise { - const { stdout } = await execGit( - `git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`, + const { stdout } = await runGitCommand( + [ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname)%09%(committerdate:unix)", + refPrefix, + ], { cwd, env: READ_ONLY_GIT_ENV }, ); return stdout @@ -290,17 +208,12 @@ async function listCheckoutFileChanges( ): Promise { const changes: CheckoutFileChange[] = []; - const { stdout: nameStatusOut } = await execFileAsync( - "git", + const { stdout: nameStatusOut } = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, extra: ["--name-status", ref], }), - { - cwd, - env: READ_ONLY_GIT_ENV, - maxBuffer: SMALL_OUTPUT_MAX_BUFFER, - }, + { cwd, env: READ_ONLY_GIT_ENV }, ); for (const line of nameStatusOut .split("\n") @@ -337,10 +250,13 @@ async function listCheckoutFileChanges( }); } - const { stdout: untrackedOut } = await execGit("git ls-files --others --exclude-standard", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout: untrackedOut } = await runGitCommand( + ["ls-files", "--others", "--exclude-standard"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); for (const file of untrackedOut .split("\n") .map((l) => l.trim()) @@ -375,10 +291,9 @@ async function readGitFileContentAtRef( path: string, ): Promise { try { - const { stdout } = await execFileAsync("git", ["show", `${ref}:${path}`], { + const { stdout } = await runGitCommand(["show", `${ref}:${path}`], { cwd, env: READ_ONLY_GIT_ENV, - maxBuffer: SMALL_OUTPUT_MAX_BUFFER, }); return stdout; } catch { @@ -388,7 +303,7 @@ async function readGitFileContentAtRef( async function tryResolveMergeBase(cwd: string, baseRef: string): Promise { try { - const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, { + const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -428,20 +343,21 @@ async function getTrackedNumstatByPath( ref: string, ignoreWhitespace = false, ): Promise> { - const result = await spawnLimitedText({ - cmd: "git", - args: buildGitDiffArgs({ + const result = await runGitCommand( + buildGitDiffArgs({ ignoreWhitespace, extra: ["--numstat", ref], }), - cwd, - env: READ_ONLY_GIT_ENV, - maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, - acceptExitCodes: [0], - }); + { + cwd, + env: READ_ONLY_GIT_ENV, + maxOutputBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, + acceptExitCodes: [0], + }, + ); const stats = new Map(); - const lines = result.text + const lines = result.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean); @@ -637,14 +553,14 @@ function isGitError(error: unknown): boolean { async function requireGitRepo(cwd: string): Promise { try { - await execAsync("git rev-parse --git-dir", { cwd, env: READ_ONLY_GIT_ENV }); + await runGitCommand(["rev-parse", "--git-dir"], { cwd, env: READ_ONLY_GIT_ENV }); } catch (error) { throw new NotGitRepoError(cwd); } } export async function getCurrentBranch(cwd: string): Promise { - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -654,10 +570,13 @@ export async function getCurrentBranch(cwd: string): Promise { async function getWorktreeRoot(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const root = stdout.trim(); return root.length > 0 ? root : null; } catch { @@ -666,8 +585,8 @@ async function getWorktreeRoot(cwd: string): Promise { } export async function getMainRepoRoot(cwd: string): Promise { - const { stdout: commonDirOut } = await execAsync( - "git rev-parse --path-format=absolute --git-common-dir", + const { stdout: commonDirOut } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, env: READ_ONLY_GIT_ENV }, ); const commonDir = commonDirOut.trim(); @@ -677,7 +596,7 @@ export async function getMainRepoRoot(cwd: string): Promise { return dirname(normalized); } - const { stdout: worktreeOut } = await execAsync("git worktree list --porcelain", { + const { stdout: worktreeOut } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -743,7 +662,7 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] { async function getWorktreePathForBranch(cwd: string, branchName: string): Promise { try { - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -766,8 +685,9 @@ export async function renameCurrentBranch( throw new Error("Cannot rename branch in detached HEAD state"); } - await execAsync(`git branch -m "${newName}"`, { + await runGitCommand(["branch", "-m", newName], { cwd, + timeout: 120_000, }); const currentBranch = await getCurrentBranch(cwd); @@ -800,7 +720,7 @@ async function getConfiguredBaseRefForCwd( } async function isWorkingTreeDirty(cwd: string): Promise { - const { stdout } = await execAsync("git status --porcelain", { + const { stdout } = await runGitCommand(["status", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -809,7 +729,7 @@ async function isWorkingTreeDirty(cwd: string): Promise { export async function getOriginRemoteUrl(cwd: string): Promise { try { - const { stdout } = await execAsync("git config --get remote.origin.url", { + const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -827,7 +747,7 @@ export async function hasOriginRemote(cwd: string): Promise { export async function resolveAbsoluteGitDir(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --absolute-git-dir", { + const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -850,7 +770,7 @@ async function abortGitPullConflictState(cwd: string): Promise { if (existsSync(mergeHeadPath)) { try { - await execAsync("git merge --abort", { cwd }); + await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { // ignore } @@ -858,7 +778,7 @@ async function abortGitPullConflictState(cwd: string): Promise { if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { try { - await execAsync("git rebase --abort", { cwd }); + await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); } catch { // ignore } @@ -867,10 +787,13 @@ async function abortGitPullConflictState(cwd: string): Promise { export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise { try { - const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { - cwd: repoRoot, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + { + cwd: repoRoot, + env: READ_ONLY_GIT_ENV, + }, + ); const ref = stdout.trim(); if (ref) { // Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main") @@ -880,7 +803,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise< ? remoteShort.slice("origin/".length) : remoteShort; try { - await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, { + await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${localName}`], { cwd: repoRoot, env: READ_ONLY_GIT_ENV, }); @@ -893,7 +816,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise< // ignore } - const { stdout } = await execAsync("git branch --format='%(refname:short)'", { + const { stdout } = await runGitCommand(["branch", "--format=%(refname:short)"], { cwd: repoRoot, env: READ_ONLY_GIT_ENV, }); @@ -922,7 +845,7 @@ function normalizeLocalBranchRefName(input: string): string { async function doesGitRefExist(cwd: string, fullRef: string): Promise { try { - await execAsync(`git show-ref --verify --quiet ${fullRef}`, { + await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -953,8 +876,8 @@ async function resolveBestComparisonBaseRef( // Both exist: choose the ref with more unique commits compared to the other. try { - const { stdout } = await execAsync( - `git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`, + const { stdout } = await runGitCommand( + ["rev-list", "--left-right", "--count", `${normalizedBaseRef}...origin/${normalizedBaseRef}`], { cwd, env: READ_ONLY_GIT_ENV }, ); const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/); @@ -980,8 +903,8 @@ async function getAheadBehind( return null; } const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef); - const { stdout } = await execAsync( - `git rev-list --left-right --count ${comparisonBaseRef}...${currentBranch}`, + const { stdout } = await runGitCommand( + ["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`], { cwd, env: READ_ONLY_GIT_ENV }, ); const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/); @@ -998,15 +921,15 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise 0) { - const trackedDiffResult = await spawnLimitedText({ - cmd: "git", - args: buildGitDiffArgs({ + const trackedDiffResult = await runGitCommand( + buildGitDiffArgs({ ignoreWhitespace, extra: [refForDiff, "--", ...trackedDiffPaths], }), - cwd, - env: READ_ONLY_GIT_ENV, - maxBytes: TOTAL_DIFF_MAX_BYTES, - }); - trackedDiffText = trackedDiffResult.text; + { + cwd, + env: READ_ONLY_GIT_ENV, + maxOutputBytes: TOTAL_DIFF_MAX_BYTES, + }, + ); + trackedDiffText = trackedDiffResult.stdout; trackedDiffTruncated = trackedDiffResult.truncated; appendDiff(trackedDiffText); if (trackedDiffTruncated) { @@ -1598,10 +1523,11 @@ export async function commitChanges( ): Promise { await requireGitRepo(cwd); if (options.addAll ?? true) { - await execFileAsync("git", ["add", "-A"], { cwd }); + await runGitCommand(["add", "-A"], { cwd, timeout: 120_000 }); } - await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", options.message], { + await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", options.message], { cwd, + timeout: 120_000, }); } @@ -1640,16 +1566,23 @@ export async function mergeToBase( const originalBranch = await getCurrentBranch(operationCwd); const mode = options.mode ?? "merge"; try { - await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd }); + await runGitCommand(["checkout", normalizedBaseRef], { + cwd: operationCwd, + timeout: 120_000, + }); if (mode === "squash") { - await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd }); + await runGitCommand(["merge", "--squash", currentBranch], { + cwd: operationCwd, + timeout: 120_000, + }); const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`; - await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { + await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd: operationCwd, + timeout: 120_000, }); } else { - await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd }); + await runGitCommand(["merge", currentBranch], { cwd: operationCwd, timeout: 120_000 }); } } catch (error) { const errorDetails = @@ -1658,9 +1591,9 @@ export async function mergeToBase( : String(error); try { const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ - execAsync("git diff --name-only --diff-filter=U", { cwd: operationCwd }), - execAsync("git ls-files -u", { cwd: operationCwd }), - execAsync("git status --porcelain", { cwd: operationCwd }), + runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd: operationCwd }), + runGitCommand(["ls-files", "-u"], { cwd: operationCwd }), + runGitCommand(["status", "--porcelain"], { cwd: operationCwd }), ]); const statusConflicts = statusOutput.stdout .split("\n") @@ -1684,7 +1617,7 @@ export async function mergeToBase( conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); if (conflictDetected) { try { - await execAsync("git merge --abort", { cwd: operationCwd }); + await runGitCommand(["merge", "--abort"], { cwd: operationCwd, timeout: 120_000 }); } catch { // ignore } @@ -1705,7 +1638,10 @@ export async function mergeToBase( } finally { if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) { try { - await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd }); + await runGitCommand(["checkout", originalBranch], { + cwd: operationCwd, + timeout: 120_000, + }); } catch { // ignore } @@ -1735,7 +1671,7 @@ export async function mergeFromBase( const requireCleanTarget = options.requireCleanTarget ?? true; if (requireCleanTarget) { - const { stdout } = await execAsync("git status --porcelain", { + const { stdout } = await runGitCommand(["status", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -1751,7 +1687,7 @@ export async function mergeFromBase( } try { - await execAsync(`git merge ${bestBaseRef}`, { cwd }); + await runGitCommand(["merge", bestBaseRef], { cwd, timeout: 120_000 }); } catch (error) { const errorDetails = error instanceof Error @@ -1759,9 +1695,9 @@ export async function mergeFromBase( : String(error); try { const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ - execAsync("git diff --name-only --diff-filter=U", { cwd }), - execAsync("git ls-files -u", { cwd }), - execAsync("git status --porcelain", { cwd }), + runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd }), + runGitCommand(["ls-files", "-u"], { cwd }), + runGitCommand(["status", "--porcelain"], { cwd }), ]); const statusConflicts = statusOutput.stdout .split("\n") @@ -1785,7 +1721,7 @@ export async function mergeFromBase( conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); if (conflictDetected) { try { - await execAsync("git merge --abort", { cwd }); + await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { // ignore } @@ -1817,7 +1753,7 @@ export async function pullCurrentBranch(cwd: string): Promise { throw new Error("Remote 'origin' is not configured."); } try { - await execAsync("git pull", { cwd }); + await runGitCommand(["pull"], { cwd, timeout: 120_000 }); } catch (error) { await abortGitPullConflictState(cwd); throw error; @@ -1834,7 +1770,7 @@ export async function pushCurrentBranch(cwd: string): Promise { if (!hasRemote) { throw new Error("Remote 'origin' is not configured."); } - await execAsync(`git push -u origin ${currentBranch}`, { cwd }); + await runGitCommand(["push", "-u", "origin", currentBranch], { cwd, timeout: 120_000 }); } export interface CreatePullRequestOptions { @@ -1892,7 +1828,7 @@ function isGhAuthError(error: unknown): boolean { async function resolveGitHubRepo(cwd: string): Promise { try { - const { stdout } = await execAsync("git config --get remote.origin.url", { + const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -1954,7 +1890,7 @@ export async function createPullRequest( throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`); } - await execAsync(`git push -u origin ${head}`, { cwd }); + await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 }); const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts new file mode 100644 index 000000000..022e75dfb --- /dev/null +++ b/packages/server/src/utils/run-git-command.test.ts @@ -0,0 +1,402 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +interface FakeSpawnBehavior { + delayMs?: number; + emitError?: Error; + exitCode?: number | null; + stderrData?: Buffer | string; + stdoutData?: Buffer | string; +} + +interface FakeSpawnController { + activeCount: number; + nextPid: number; + peakActiveCount: number; + processes: FakeChildProcess[]; + queue: FakeSpawnBehavior[]; + reset: () => void; +} + +const fakeSpawnController = vi.hoisted(() => ({ + activeCount: 0, + nextPid: 1000, + peakActiveCount: 0, + processes: [], + queue: [], + reset() { + for (const process of this.processes) { + process.dispose(); + } + + this.activeCount = 0; + this.nextPid = 1000; + this.peakActiveCount = 0; + this.processes = []; + this.queue = []; + }, +})); + +class FakeChildProcess extends EventEmitter { + public readonly pid: number; + public readonly stderr = new EventEmitter(); + public readonly stdout = new EventEmitter(); + public killed = false; + public killSignals: NodeJS.Signals[] = []; + + private readonly behavior: FakeSpawnBehavior; + private readonly timers: NodeJS.Timeout[] = []; + private closed = false; + + public constructor(behavior: FakeSpawnBehavior) { + super(); + this.behavior = behavior; + this.pid = fakeSpawnController.nextPid; + fakeSpawnController.nextPid += 1; + + fakeSpawnController.activeCount += 1; + fakeSpawnController.peakActiveCount = Math.max( + fakeSpawnController.peakActiveCount, + fakeSpawnController.activeCount, + ); + + this.scheduleLifecycle(); + } + + public kill(signal: NodeJS.Signals = "SIGTERM"): boolean { + if (this.closed) return false; + + this.killed = true; + this.killSignals.push(signal); + this.clearTimers(); + this.schedule(() => { + this.finishClose({ + exitCode: null, + signal, + }); + }, 0); + return true; + } + + public dispose(): void { + this.clearTimers(); + this.closed = true; + } + + private scheduleLifecycle(): void { + const stdoutData = this.behavior.stdoutData; + if (stdoutData !== undefined) { + this.schedule(() => { + if (this.closed) return; + this.stdout.emit("data", stdoutData); + }, 0); + } + + const stderrData = this.behavior.stderrData; + if (stderrData !== undefined) { + this.schedule(() => { + if (this.closed) return; + this.stderr.emit("data", stderrData); + }, 0); + } + + if (this.behavior.emitError) { + this.schedule(() => { + if (this.closed) return; + this.finishError(this.behavior.emitError); + }, this.behavior.delayMs ?? 0); + return; + } + + this.schedule(() => { + this.finishClose({ + exitCode: this.behavior.exitCode ?? 0, + signal: null, + }); + }, this.behavior.delayMs ?? 0); + } + + private finishClose({ + exitCode, + signal, + }: { + exitCode: number | null; + signal: NodeJS.Signals | null; + }): void { + if (this.closed) return; + + this.closed = true; + this.clearTimers(); + fakeSpawnController.activeCount -= 1; + this.emit("close", exitCode, signal); + } + + private finishError(error: Error): void { + if (this.closed) return; + + this.closed = true; + this.clearTimers(); + fakeSpawnController.activeCount -= 1; + this.emit("error", error); + } + + private schedule(callback: () => void, delayMs: number): void { + const timer = setTimeout(callback, delayMs); + this.timers.push(timer); + } + + private clearTimers(): void { + for (const timer of this.timers) { + clearTimeout(timer); + } + this.timers.length = 0; + } +} + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + + return { + ...actual, + spawn: vi.fn(() => { + const behavior = fakeSpawnController.queue.shift() ?? {}; + const child = new FakeChildProcess(behavior); + fakeSpawnController.processes.push(child); + return child as unknown as ReturnType; + }), + }; +}); + +function enqueueSpawnBehaviors(...behaviors: FakeSpawnBehavior[]): void { + fakeSpawnController.queue.push(...behaviors); +} + +async function loadRunGitCommand(concurrency: number) { + vi.resetModules(); + vi.stubEnv("PASEO_GIT_CONCURRENCY", String(concurrency)); + return import("./run-git-command.js"); +} + +describe("runGitCommand", () => { + beforeEach(() => { + fakeSpawnController.reset(); + }); + + afterEach(() => { + fakeSpawnController.reset(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("throttles concurrent git commands to the configured limit", async () => { + const { runGitCommand } = await loadRunGitCommand(2); + + enqueueSpawnBehaviors(...Array.from({ length: 16 }, () => ({ delayMs: 25 }))); + + await Promise.all( + Array.from({ length: 16 }, () => + runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + }), + ), + ); + + expect(fakeSpawnController.peakActiveCount).toBe(2); + expect(fakeSpawnController.activeCount).toBe(0); + }); + + it("kills timed out processes and releases the limiter slot", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors({ delayMs: 5_000 }, { delayMs: 0 }); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + timeout: 100, + }), + ).rejects.toThrow("Git command timed out after 100ms: git status"); + + expect(fakeSpawnController.processes[0]?.killed).toBe(true); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + + await expect( + runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 0, + truncated: false, + }); + }); + + it("resolves truncated stdout, caps output, and kills the child process", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors({ + delayMs: 5_000, + stdoutData: "x".repeat(1_000), + }); + + const result = await runGitCommand(["log", "--all", "--oneline"], { + cwd: process.cwd(), + maxOutputBytes: 100, + }); + + expect(result.truncated).toBe(true); + expect(result.stdout.length).toBeLessThanOrEqual(100); + expect(result.stderr).toBe(""); + expect(fakeSpawnController.processes[0]?.killed).toBe(true); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + }); + + it("rejects process errors and frees the limiter for the next command", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors( + { emitError: new Error("spawn exploded") }, + { delayMs: 0, stdoutData: "ok" }, + ); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).rejects.toThrow("spawn exploded"); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 0, + stdout: "ok", + truncated: false, + }); + }); + + it("rejects non-zero exit codes that are not accepted and frees the slot", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors( + { + delayMs: 0, + exitCode: 1, + stderrData: "fatal: nope\n", + }, + { delayMs: 0, stdoutData: "ok" }, + ); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).rejects.toThrow(/Git command failed: git status \(exit code: 1, signal: none\)/); + + await expect( + runGitCommand(["status"], { + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 0, + stdout: "ok", + }); + }); + + it("resolves accepted non-zero exit codes", async () => { + const { runGitCommand } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors({ + delayMs: 0, + exitCode: 1, + stderrData: "fatal: but allowed\n", + }); + + await expect( + runGitCommand(["status"], { + acceptExitCodes: [0, 1], + cwd: process.cwd(), + }), + ).resolves.toMatchObject({ + exitCode: 1, + signal: null, + truncated: false, + }); + }); + + it("releases concurrency slots after timeouts so later commands can run", async () => { + const { runGitCommand } = await loadRunGitCommand(2); + + enqueueSpawnBehaviors({ delayMs: 5_000 }, { delayMs: 5_000 }); + + const firstBatch = await Promise.allSettled([ + runGitCommand(["status"], { cwd: process.cwd(), timeout: 100 }), + runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + timeout: 100, + }), + ]); + + expect(firstBatch[0].status).toBe("rejected"); + expect(firstBatch[1].status).toBe("rejected"); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + expect(fakeSpawnController.processes[1]?.killSignals).toEqual(["SIGKILL"]); + + enqueueSpawnBehaviors( + { delayMs: 0, stdoutData: "third" }, + { delayMs: 0, stdoutData: "fourth" }, + ); + + await expect( + Promise.all([ + runGitCommand(["status"], { cwd: process.cwd() }), + runGitCommand(["status"], { cwd: process.cwd() }), + ]), + ).resolves.toEqual([ + expect.objectContaining({ exitCode: 0, stdout: "third" }), + expect.objectContaining({ exitCode: 0, stdout: "fourth" }), + ]); + }); + + it("releases concurrency slots after truncation so later commands can run", async () => { + const { runGitCommand } = await loadRunGitCommand(2); + + enqueueSpawnBehaviors( + { delayMs: 5_000, stdoutData: "a".repeat(1_000) }, + { delayMs: 5_000, stdoutData: "b".repeat(1_000) }, + ); + + const firstBatch = await Promise.all([ + runGitCommand(["log", "--all", "--oneline"], { + cwd: process.cwd(), + maxOutputBytes: 100, + }), + runGitCommand(["log", "--all", "--oneline"], { + cwd: process.cwd(), + maxOutputBytes: 100, + }), + ]); + + expect(firstBatch).toEqual([ + expect.objectContaining({ truncated: true }), + expect.objectContaining({ truncated: true }), + ]); + expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]); + expect(fakeSpawnController.processes[1]?.killSignals).toEqual(["SIGKILL"]); + + enqueueSpawnBehaviors( + { delayMs: 0, stdoutData: "third" }, + { delayMs: 0, stdoutData: "fourth" }, + ); + + await expect( + Promise.all([ + runGitCommand(["status"], { cwd: process.cwd() }), + runGitCommand(["status"], { cwd: process.cwd() }), + ]), + ).resolves.toEqual([ + expect.objectContaining({ exitCode: 0, stdout: "third", truncated: false }), + expect.objectContaining({ exitCode: 0, stdout: "fourth", truncated: false }), + ]); + }); +}); diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts new file mode 100644 index 000000000..c0f48db7b --- /dev/null +++ b/packages/server/src/utils/run-git-command.ts @@ -0,0 +1,140 @@ +import pLimit from "p-limit"; +import { spawnProcess } from "./spawn.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB +const DEFAULT_STDERR_LIMIT = 2048; + +const gitConcurrency = parseInt(process.env.PASEO_GIT_CONCURRENCY ?? "8", 10) || 8; +const gitLimit = pLimit(gitConcurrency); + +export interface GitCommandOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + timeout?: number; + maxOutputBytes?: number; + acceptExitCodes?: number[]; +} + +export interface GitCommandResult { + stdout: string; + stderr: string; + truncated: boolean; + exitCode: number | null; + signal: NodeJS.Signals | null; +} + +export function runGitCommand( + args: string[], + options: GitCommandOptions, +): Promise { + return gitLimit( + () => + new Promise((resolve, reject) => { + const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const acceptExitCodes = options.acceptExitCodes ?? [0]; + const command = formatGitCommand(args); + + const child = spawnProcess("git", args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let settled = false; + let truncated = false; + let stdoutBytes = 0; + let stderrBytes = 0; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + callback(); + }; + + const timer = setTimeout(() => { + const error = new Error(`Git command timed out after ${timeout}ms: ${command}`); + child.kill("SIGKILL"); + settle(() => reject(error)); + }, timeout); + + child.stdout!.on("data", (chunk: Buffer | string) => { + if (settled || truncated) return; + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remainingBytes = maxOutputBytes - stdoutBytes; + + if (remainingBytes <= 0) { + truncated = true; + child.kill("SIGKILL"); + return; + } + + if (buffer.length > remainingBytes) { + stdoutChunks.push(buffer.subarray(0, remainingBytes)); + stdoutBytes += remainingBytes; + truncated = true; + child.kill("SIGKILL"); + return; + } + + stdoutChunks.push(buffer); + stdoutBytes += buffer.length; + }); + + child.stderr!.on("data", (chunk: Buffer | string) => { + if (settled || stderrBytes >= DEFAULT_STDERR_LIMIT) return; + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remainingBytes = DEFAULT_STDERR_LIMIT - stderrBytes; + + if (buffer.length > remainingBytes) { + stderrChunks.push(buffer.subarray(0, remainingBytes)); + stderrBytes += remainingBytes; + return; + } + + stderrChunks.push(buffer); + stderrBytes += buffer.length; + }); + + child.on("error", (error) => { + settle(() => reject(error)); + }); + + child.on("close", (exitCode, signal) => { + const result: GitCommandResult = { + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + truncated, + exitCode, + signal, + }; + + if (!truncated && !acceptExitCodes.includes(exitCode ?? -1)) { + const stderrPreview = result.stderr.trim() || "(no stderr)"; + const truncationNote = result.truncated ? " (stdout truncated)" : ""; + + settle(() => + reject( + new Error( + `Git command failed: ${command}${truncationNote} (exit code: ${String(exitCode)}, signal: ${signal ?? "none"})\n${stderrPreview}`, + ), + ), + ); + return; + } + + settle(() => resolve(result)); + }); + }), + ); +} + +function formatGitCommand(args: string[]): string { + return ["git", ...args].join(" "); +} diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 35ce3671a..38d74268c 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -12,6 +12,7 @@ import { writePaseoWorktreeMetadata, writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; +import { runGitCommand } from "./run-git-command.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { @@ -377,10 +378,13 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< } catch { // Fallback: best-effort resolve toplevel (will be the worktree root in typical cases) try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd: worktreePath, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd: worktreePath, + env: READ_ONLY_GIT_ENV, + }, + ); const topLevel = stdout.trim(); if (topLevel) { return normalizePathForOwnership(topLevel); @@ -438,8 +442,9 @@ export async function runWorktreeSetupCommands(options: { if (result.exitCode !== 0) { if (options.cleanupOnFailure) { try { - await execAsync(`git worktree remove "${options.worktreePath}" --force`, { + await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], { cwd: options.worktreePath, + timeout: 120_000, }); } catch { rmSync(options.worktreePath, { recursive: true, force: true }); @@ -457,7 +462,7 @@ export async function runWorktreeSetupCommands(options: { async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { try { - const { stdout } = await execAsync("git branch --show-current", { + const { stdout } = await runGitCommand(["branch", "--show-current"], { cwd: worktreePath, env: READ_ONLY_GIT_ENV, }); @@ -560,10 +565,13 @@ export async function runWorktreeTeardownCommands(options: { * This is where refs, objects, etc. are stored. */ export async function getGitCommonDir(cwd: string): Promise { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --git-common-dir", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const commonDir = stdout.trim(); if (!commonDir) { throw new Error("Not in a git repository"); @@ -793,7 +801,7 @@ export async function listPaseoWorktrees({ paseoHome?: string; }): Promise { const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, env: READ_ONLY_GIT_ENV, }); @@ -824,10 +832,13 @@ export async function resolvePaseoWorktreeRootForCwd( let worktreeRoot: string | null = null; try { - const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); const trimmed = stdout.trim(); worktreeRoot = trimmed.length > 0 ? trimmed : null; } catch { @@ -890,8 +901,9 @@ export async function deletePaseoWorktree({ worktreePath: resolvedWorktree, }); - await execAsync(`git worktree remove "${resolvedWorktree}" --force`, { + await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], { cwd, + timeout: 120_000, }); if (existsSync(resolvedWorktree)) { @@ -927,11 +939,11 @@ export async function createWorktree({ // Resolve the base branch - prefer origin/{branch}, then fall back to local let resolvedBaseBranch = normalizedBaseBranch; try { - await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd }); + await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd }); resolvedBaseBranch = `origin/${normalizedBaseBranch}`; } catch { try { - await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd }); + await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd }); } catch { throw new Error(`Base branch not found: ${normalizedBaseBranch}`); } @@ -946,7 +958,9 @@ export async function createWorktree({ // Check if branch already exists let branchExists = false; try { - await execAsync(`git show-ref --verify --quiet refs/heads/${branchName}`, { cwd }); + await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { + cwd, + }); branchExists = true; } catch { branchExists = false; @@ -963,7 +977,9 @@ export async function createWorktree({ let suffix = 1; while (true) { try { - await execAsync(`git show-ref --verify --quiet refs/heads/${newBranchName}`, { cwd }); + await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${newBranchName}`], { + cwd, + }); // Branch exists, try with suffix newBranchName = `${candidateBranch}-${suffix}`; suffix++; @@ -980,8 +996,10 @@ export async function createWorktree({ pathSuffix++; } - const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`; - await execAsync(command, { cwd }); + await runGitCommand(["worktree", "add", finalWorktreePath, "-b", newBranchName, base], { + cwd, + timeout: 120_000, + }); worktreePath = normalizePathForOwnership(finalWorktreePath); writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch }); From 0f7fa55a0f24bc1dbc7498cebe0475fa090ce0df Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 13:24:00 +0700 Subject: [PATCH 02/21] Centralize platform gating and fix iPad/tablet support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `packages/app/src/constants/platform.ts` with canonical gates (isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc Platform.OS checks across 69 files to use shared imports. Fix sidebar hover crash on native (onPointerEnter → onHoverIn), fix tooltip to use breakpoint instead of platform detection, make sidebar action buttons always visible on native where hover doesn't work, and document the platform gating decision matrix in CLAUDE.md. --- CLAUDE.md | 40 +++ packages/app/src/app/_layout.tsx | 13 +- .../workspace/[workspaceId]/_layout.tsx | 4 +- packages/app/src/app/pair-scan.tsx | 7 +- packages/app/src/attachments/store.ts | 4 +- .../src/components/adaptive-modal-sheet.tsx | 5 +- .../src/components/add-host-method-modal.tsx | 5 +- .../agent-form/agent-form-dropdowns.tsx | 7 +- .../app/src/components/agent-input-area.tsx | 11 +- .../app/src/components/agent-status-bar.tsx | 9 +- .../app/src/components/agent-stream-view.tsx | 3 +- .../components/combined-model-selector.tsx | 17 +- .../app/src/components/command-center.tsx | 5 +- .../desktop/titlebar-drag-region.tsx | 4 +- packages/app/src/components/diff-viewer.tsx | 7 +- .../app/src/components/explorer-sidebar.tsx | 11 +- .../app/src/components/file-drop-zone.tsx | 5 +- .../app/src/components/file-explorer-pane.tsx | 4 +- packages/app/src/components/file-pane.tsx | 4 +- packages/app/src/components/git-diff-pane.tsx | 10 +- .../headers/header-toggle-button.tsx | 12 +- packages/app/src/components/left-sidebar.tsx | 11 +- packages/app/src/components/message-input.tsx | 6 +- packages/app/src/components/message.tsx | 27 +- .../src/components/project-picker-modal.tsx | 5 +- .../app/src/components/question-form-card.tsx | 5 +- .../src/components/sidebar-workspace-list.tsx | 299 +++++++++--------- .../app/src/components/split-container.tsx | 5 +- packages/app/src/components/toast-host.tsx | 7 +- .../app/src/components/tool-call-details.tsx | 7 +- packages/app/src/components/ui/combobox.tsx | 17 +- .../app/src/components/ui/context-menu.tsx | 8 +- packages/app/src/components/ui/tooltip.tsx | 26 +- .../app/src/components/use-web-scrollbar.tsx | 8 +- .../src/components/web-desktop-scrollbar.tsx | 21 +- .../app/src/components/welcome-screen.tsx | 96 +++--- packages/app/src/constants/layout.ts | 46 +-- packages/app/src/constants/platform.ts | 49 +++ packages/app/src/contexts/session-context.tsx | 5 +- .../permissions/desktop-permissions.ts | 16 +- .../src/desktop/updates/desktop-updates.ts | 4 +- .../src/hooks/use-agent-attention-clear.ts | 5 +- .../app/src/hooks/use-agent-initialization.ts | 4 +- packages/app/src/hooks/use-app-visible.ts | 7 +- packages/app/src/hooks/use-client-activity.ts | 7 +- packages/app/src/hooks/use-favicon-status.ts | 14 +- packages/app/src/hooks/use-file-drop-zone.ts | 4 +- .../src/hooks/use-image-attachment-picker.ts | 3 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 4 +- .../src/hooks/use-push-token-registration.ts | 5 +- packages/app/src/panels/agent-panel.tsx | 5 +- .../src/screens/agent/draft-agent-screen.tsx | 5 +- packages/app/src/screens/settings-screen.tsx | 13 +- .../settings/keyboard-shortcuts-section.tsx | 7 +- .../app/src/screens/startup-splash-screen.tsx | 7 +- .../workspace/workspace-desktop-tabs-row.tsx | 26 +- .../workspace/workspace-draft-agent-tab.tsx | 5 +- .../workspace-open-in-editor-button.tsx | 8 +- .../screens/workspace/workspace-screen.tsx | 19 +- packages/app/src/stores/download-store.ts | 8 +- packages/app/src/stores/panel-store.ts | 12 +- packages/app/src/styles/markdown-styles.ts | 4 +- packages/app/src/utils/app-visibility.ts | 5 +- packages/app/src/utils/confirm-dialog.ts | 9 +- packages/app/src/utils/desktop-window.ts | 4 +- packages/app/src/utils/open-external-url.ts | 4 +- packages/app/src/utils/os-notifications.ts | 6 +- .../src/utils/scroll-jank-investigation.ts | 4 +- packages/app/src/utils/shortcut-platform.ts | 3 +- packages/app/src/utils/thinking-tone.ts | 4 +- 70 files changed, 551 insertions(+), 515 deletions(-) create mode 100644 packages/app/src/constants/platform.ts diff --git a/CLAUDE.md b/CLAUDE.md index dfb9b88d0..e76ec0aa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir - Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null). - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" +## Platform gating + +The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`. + +### The four gates + +| Gate | Type | When to use | +|---|---|---| +| `isWeb` | constant | DOM APIs — `document`, `window`, `
`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. | +| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. | +| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. | +| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. | + +### Decision matrix + +| I need to... | Use | +|---|---| +| Access DOM (`document`, `window`, `
`, `addEventListener`) | `if (isWeb)` | +| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` | +| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` | +| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` | +| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) | +| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) | + +### Rules + +- **Default is cross-platform.** Don't gate unless you have a specific reason. +- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead. + ``` + hooks/ + use-audio-recorder.web.ts ← uses Web Audio API + use-audio-recorder.native.ts ← uses expo-audio + ``` + Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically. +- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only. +- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS. +- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web. +- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks. +- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally. + ## Debugging diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 30fbb7df7..dd68ff244 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -81,6 +81,7 @@ import { parseWorkspaceOpenIntent, } from "@/utils/host-routes"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; +import { isWeb, isNative } from "@/constants/platform"; polyfillCrypto(); @@ -101,7 +102,7 @@ function PushNotificationRouter() { const lastHandledIdRef = useRef(null); useEffect(() => { - if (Platform.OS === "web") { + if (isWeb) { let removeDesktopNotificationListener: (() => void) | null = null; let cancelled = false; @@ -390,10 +391,10 @@ function AppContainer({ const screenW = UnistylesRuntime.screen.width; const screenH = UnistylesRuntime.screen.height; const isElectron = getIsElectronRuntime(); - const windowW = Platform.OS === "web" ? window.innerWidth : undefined; - const windowH = Platform.OS === "web" ? window.innerHeight : undefined; - const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined; - const ua = Platform.OS === "web" ? navigator.userAgent : undefined; + const windowW = isWeb ? window.innerWidth : undefined; + const windowH = isWeb ? window.innerHeight : undefined; + const dpr = isWeb ? window.devicePixelRatio : undefined; + const ua = isWeb ? navigator.userAgent : undefined; console.log( "[layout-debug]", @@ -577,7 +578,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { }, [settingsLoading, settings.theme]); useEffect(() => { - if (settingsLoading || Platform.OS !== "web") { + if (settingsLoading || isNative) { return; } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 27fd8411f..bd584f8a0 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from "react"; import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; -import { Platform } from "react-native"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; @@ -10,6 +9,7 @@ import { type WorkspaceOpenIntent, } from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { isWeb } from "@/constants/platform"; function getParamValue(value: string | string[] | undefined): string { if (typeof value === "string") { @@ -88,7 +88,7 @@ function HostWorkspaceLayoutContent() { // Expo Router's replace ignores query-param-only changes (findDivergentState // skips search params). Strip ?open from the browser URL directly so the // address bar reflects the clean workspace route. - if (Platform.OS === "web" && typeof window !== "undefined") { + if (isWeb && typeof window !== "undefined") { const url = new URL(window.location.href); if (url.searchParams.has("open")) { url.searchParams.delete("open"); diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index 1594e2f20..fe647aa50 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, Pressable, Text, View } from "react-native"; +import { Alert, Pressable, Text, View } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en import { connectToDaemon } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes"; +import { isWeb } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ container: { @@ -198,7 +199,7 @@ export default function PairScanScreen() { }, [router, source, sourceServerId, targetServerId]); useEffect(() => { - if (Platform.OS === "web") return; + if (isWeb) return; if (permission && permission.granted) return; void requestPermission().catch(() => undefined); }, [permission, requestPermission]); @@ -253,7 +254,7 @@ export default function PairScanScreen() { [daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], ); - if (Platform.OS === "web") { + if (isWeb) { return ( diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts index ea6fd2dc1..ce0837766 100644 --- a/packages/app/src/attachments/store.ts +++ b/packages/app/src/attachments/store.ts @@ -1,11 +1,11 @@ -import { Platform } from "react-native"; import { isElectronRuntime } from "@/desktop/host"; import type { AttachmentStore } from "@/attachments/types"; +import { isWeb } from "@/constants/platform"; let attachmentStorePromise: Promise | null = null; async function createAttachmentStore(): Promise { - if (Platform.OS === "web") { + if (isWeb) { if (isElectronRuntime()) { const { createDesktopAttachmentStore } = await import( "../desktop/attachments/desktop-attachment-store" diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index c5f3edbff..f8837fcfe 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,7 +1,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; -import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import type { TextInputProps } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -14,6 +14,7 @@ import { type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; +import { isWeb } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ desktopOverlay: { @@ -216,7 +217,7 @@ export function AdaptiveModalSheet({ ); // On web, use portal to overlay root for consistent stacking with toasts - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { if (!visible) return null; return createPortal(desktopContent, getOverlayRoot()); } diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index 35bc56255..e5364e06d 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -1,8 +1,9 @@ import { useCallback } from "react"; -import { Pressable, Text, View, Platform } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { AdaptiveModalSheet } from "./adaptive-modal-sheet"; +import { isNative } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ option: { @@ -78,7 +79,7 @@ export function AddHostMethodModal({ - {Platform.OS !== "web" ? ( + {isNative ? ( diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 6e2d750bb..5c5b596c8 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactElement, ReactNode } from "react"; -import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native"; +import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { @@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { baseColors } from "@/styles/theme"; +import { isNative } from "@/constants/platform"; const MODE_ICON_MAP: Record = { ShieldCheck, @@ -168,7 +169,7 @@ export function SelectField({ const handleKeyDown = useCallback( (event: unknown) => { - if (Platform.OS !== "web") return; + if (isNative) return; const key = getWebKey(event); if (key === "Enter" || key === " ") { preventWebDefault(event); @@ -430,7 +431,7 @@ export function FormSelectTrigger({ const handleKeyDown = useCallback( (event: unknown) => { - if (Platform.OS !== "web") return; + if (isNative) return; const key = getWebKey(event); if (key === "Enter" || key === " ") { preventWebDefault(event); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 6c57cac69..9bf29bc74 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1,4 +1,4 @@ -import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; +import { View, Pressable, Text, ActivityIndicator } from "react-native"; import { useState, useEffect, useRef, useCallback } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { submitAgentInput } from "@/components/agent-input-submit"; import { useAppSettings } from "@/hooks/use-settings"; +import { isWeb, isNative } from "@/constants/platform"; type QueuedMessage = { id: string; @@ -118,7 +119,7 @@ export function AgentInputArea({ }: AgentInputAreaProps) { markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`); const { theme } = useUnistyles(); - const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; + const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -156,7 +157,7 @@ export function AgentInputArea({ const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const isMobile = useIsCompactFormFactor(); - const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; + const isDesktopWebBreakpoint = isWeb && !isMobile; const messagePlaceholder = isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; @@ -217,7 +218,7 @@ export function AgentInputArea({ }, [addImages, onAddImages]); const focusInput = useCallback(() => { - if (Platform.OS !== "web") return; + if (isNative) return; focusWithRetries({ focus: () => messageInputRef.current?.focus(), isFocused: () => { @@ -430,7 +431,7 @@ export function AgentInputArea({ case "message-input.dictation-confirm": return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; case "message-input.focus": - if (Platform.OS !== "web") { + if (isNative) { messageInputRef.current?.focus(); return true; } diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 501963f7a..f18afc048 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; -import { View, Text, Platform, Pressable, Keyboard } from "react-native"; +import { View, Text, Pressable, Keyboard } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -51,6 +51,7 @@ import { getStatusSelectorHint, resolveAgentModelSelection, } from "@/components/agent-status-bar.utils"; +import { isWeb as platformIsWeb } from "@/constants/platform"; type StatusOption = { id: string; @@ -222,7 +223,6 @@ function ControlledStatusBar({ onModelSelectorOpen, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); - const isWeb = Platform.OS === "web"; const [prefsOpen, setPrefsOpen] = useState(false); const [openSelector, setOpenSelector] = useState(null); @@ -356,7 +356,7 @@ function ControlledStatusBar({ return ( - {isWeb ? ( + {platformIsWeb ? ( <> {providerOptions && providerOptions.length > 0 ? ( <> @@ -1077,7 +1077,6 @@ export function DraftAgentStatusBar({ onModelSelectorOpen, disabled = false, }: DraftAgentStatusBarProps) { - const isWeb = Platform.OS === "web"; const { preferences, updatePreferences } = useFormPreferences(); const mappedModeOptions = useMemo(() => { @@ -1105,7 +1104,7 @@ export function DraftAgentStatusBar({ const effectiveSelectedThinkingOption = selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; - if (isWeb) { + if (platformIsWeb) { return ( item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef { - if (autoFocus && Platform.OS === "web" && inputRef.current) { + if (autoFocus && platformIsWeb && inputRef.current) { const timer = setTimeout(() => { inputRef.current?.focus(); }, 50); @@ -363,7 +363,7 @@ function ProviderSearchInput({ (null); const [isOpen, setIsOpen] = useState(false); - const [isContentReady, setIsContentReady] = useState(isWeb); + const [isContentReady, setIsContentReady] = useState(platformIsWeb); const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); @@ -591,7 +590,7 @@ export function CombinedModelSelector({ }, [selectedModelLabel, selectedProviderLabel]); useEffect(() => { - if (isWeb) { + if (platformIsWeb) { return; } @@ -605,7 +604,7 @@ export function CombinedModelSelector({ }); return () => cancelAnimationFrame(frame); - }, [isOpen, isWeb]); + }, [isOpen, platformIsWeb]); return ( <> @@ -668,7 +667,7 @@ export function CombinedModelSelector({ ) : undefined diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 0854707f3..2f1bd0d83 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -1,4 +1,4 @@ -import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { memo, useEffect, useRef, type ReactNode } from "react"; import { Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { Shortcut } from "@/components/ui/shortcut"; +import { isNative } from "@/constants/platform"; function agentKey(agent: Pick): string { return `${agent.serverId}:${agent.id}`; @@ -90,7 +91,7 @@ export function CommandCenter() { } }, [activeIndex, open]); - if (Platform.OS !== "web" || !open) return null; + if (isNative || !open) return null; const actionItems = items.filter((item) => item.kind === "action"); const agentItems = items.filter((item) => item.kind === "agent"); diff --git a/packages/app/src/components/desktop/titlebar-drag-region.tsx b/packages/app/src/components/desktop/titlebar-drag-region.tsx index d51498a4b..a3067968d 100644 --- a/packages/app/src/components/desktop/titlebar-drag-region.tsx +++ b/packages/app/src/components/desktop/titlebar-drag-region.tsx @@ -1,5 +1,5 @@ -import { Platform } from "react-native"; import { getIsElectronRuntime } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; /** * VS Code-style titlebar drag region for Electron. @@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout"; * Place as FIRST child of any positioned container that should be draggable. */ export function TitlebarDragRegion() { - if (Platform.OS !== "web" || !getIsElectronRuntime()) { + if (isNative || !getIsElectronRuntime()) { return null; } diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 29a8d9fd9..7fe88e8df 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -1,12 +1,13 @@ import React from "react"; -import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; +import { View, Text, ScrollView as RNScrollView } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; import { getCodeInsets } from "./code-insets"; +import { isWeb } from "@/constants/platform"; -const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; +const ScrollView = isWeb ? RNScrollView : GHScrollView; interface DiffViewerProps { diffLines: DiffLine[]; @@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, - ...(Platform.OS === "web" + ...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal", diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 481aadb7a..99281e6a3 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -3,7 +3,6 @@ import { View, Text, Pressable, - Platform, useWindowDimensions, StyleSheet as RNStyleSheet, } from "react-native"; @@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { isWeb } from "@/constants/platform"; const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} @@ -252,7 +252,7 @@ export function ExplorerSidebar({ // Mobile: full-screen overlay with gesture. // On web, keep it interactive only while open so closed sidebars don't eat taps. - const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; + const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none"; // Navigation stacks can keep previous screens mounted; hide sidebars for unfocused // screens so only the active screen exposes explorer/terminal surfaces. @@ -309,12 +309,7 @@ export function ExplorerSidebar({ {/* Resize handle - absolutely positioned over left border */} - + state.sessions[serverId]?.client ?? null); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 5208a4de4..cd873cc28 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -6,7 +6,6 @@ import { ActivityIndicator, Pressable, FlatList, - Platform, type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, @@ -79,6 +78,7 @@ import { formatDiffGutterText, hasVisibleDiffTokens, } from "@/utils/diff-rendering"; +import { isWeb, isNative } from "@/constants/platform"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -99,7 +99,7 @@ type WrappedWebTextStyle = TextStyle & { }; function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { - if (Platform.OS !== "web") { + if (isNative) { return undefined; } return wrapLines @@ -453,7 +453,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ }} onPressOut={(event) => { if ( - Platform.OS !== "web" && + isNative && !pressHandledRef.current && layoutYRef.current === 0 && pressInRef.current @@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const { theme } = useUnistyles(); const toast = useToast(); const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; - const canUseSplitLayout = Platform.OS === "web" && !isMobile; + const showDesktopWebScrollbar = isWeb && !isMobile; + const canUseSplitLayout = isWeb && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index c6a8f1c66..93ebb21a2 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -1,16 +1,10 @@ import type { ReactElement, ReactNode } from "react"; -import { - Platform, - Text, - View, - type PressableProps, - type StyleProp, - type ViewStyle, -} from "react-native"; +import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; +import { isWeb } from "@/constants/platform"; interface HeaderToggleButtonState { hovered: boolean; @@ -44,7 +38,7 @@ export function HeaderToggleButton({ : undefined; const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded; const ariaExpandedProps = - Platform.OS === "web" && typeof expandedState === "boolean" + isWeb && typeof expandedState === "boolean" ? ({ "aria-expanded": expandedState } as any) : null; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 2ddebe1a4..7ec33e7d0 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -14,7 +14,6 @@ import { View, Pressable, Text, - Platform, useWindowDimensions, StyleSheet as RNStyleSheet, } from "react-native"; @@ -59,6 +58,7 @@ import { parseServerIdFromPathname, } from "@/utils/host-routes"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { isWeb } from "@/constants/platform"; const MIN_CHAT_WIDTH = 400; @@ -527,7 +527,7 @@ function MobileSidebar({ pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", })); - const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; + const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none"; return ( @@ -833,12 +833,7 @@ function DesktopSidebar({ {/* Resize handle - absolutely positioned over right border */} - + diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 649f6e2e1..80c9e2054 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -9,7 +9,6 @@ import { TextInputKeyPressEventData, TextInputSelectionChangeEventData, Image, - Platform, BackHandler, } from "react-native"; import { @@ -48,6 +47,7 @@ import { markScrollInvestigationEvent, markScrollInvestigationRender, } from "@/utils/scroll-jank-investigation"; +import { isWeb } from "@/constants/platform"; export type ImageAttachment = AttachmentMetadata; @@ -115,7 +115,7 @@ export interface MessageInputRef { const MIN_INPUT_HEIGHT = 30; const MAX_INPUT_HEIGHT = 160; -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { @@ -1290,7 +1290,7 @@ const styles = StyleSheet.create(((theme: any) => ({ rightButtonGroup: { flexDirection: "row", alignItems: "center", - gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], + gap: isWeb ? theme.spacing[2] : theme.spacing[1], }, attachButton: { width: 28, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 60a0a02dc..71c4531f8 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -7,7 +7,6 @@ import { type LayoutChangeEvent, StyleProp, ViewStyle, - Platform, } from "react-native"; import * as React from "react"; import { @@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import type { DaemonClient } from "@server/client/daemon-client"; +import { isWeb, isNative } from "@/constants/platform"; interface UserMessageProps { message: string; @@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5; type ScrollAxis = "x" | "y"; function ensureWebToolCallShimmerKeyframes() { - if (Platform.OS !== "web") { + if (isNative) { return; } if (typeof document === "undefined") { @@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({ isLastInGroup = true, disableOuterSpacing, }: UserMessageProps) { + const isCompact = useIsCompactFormFactor(); const [messageHovered, setMessageHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasText = message.trim().length > 0; const hasImages = images.length > 0; - const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); + const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered); return ( setMessageHovered(true) : undefined} - onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined} + onHoverIn={() => setMessageHovered(true)} + onHoverOut={() => setMessageHovered(false)} > {hasImages ? ( @@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontFamily: Fonts.mono, fontSize: 13, - userSelect: Platform.OS === "web" ? "text" : "auto", + userSelect: isWeb ? "text" : "auto", }, imageFrame: { width: "100%", @@ -657,7 +658,7 @@ function MarkdownLink({ children: ReactNode; }) { const [hovered, setHovered] = useState(false); - if (Platform.OS !== "web") { + if (isNative) { return ( onPress(href)} style={style}> {children} @@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({ return ( onHoverChange?.(true) : undefined} - onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined} + onHoverIn={() => onHoverChange?.(true)} + onHoverOut={() => onHoverChange?.(false)} style={[turnCopyButtonStylesheet.container, containerStyle]} accessibilityRole="button" accessibilityLabel={ @@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({ parsed && onInlinePathPress?.(parsed)} - selectable={Platform.OS === "web" ? undefined : false} + selectable={isWeb ? undefined : false} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} > {content} @@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({ 32, Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0), ); - const isWebShimmer = isLoading && Platform.OS === "web"; + const isWebShimmer = isLoading && isWeb; const shouldMeasureWebShimmer = isWebShimmer; - const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; + const shouldMeasureNativeShimmer = isLoading && isNative; const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanEndX = secondaryLabel @@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ }, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]); useEffect(() => { - if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { + if (isNative || !isExpanded || !hasDetailContent) { return; } diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index 0556f2aec..4bc39860a 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Folder } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQuery } from "@tanstack/react-query"; @@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run import { useOpenProject } from "@/hooks/use-open-project"; import { parseServerIdFromPathname } from "@/utils/host-routes"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; +import { isNative } from "@/constants/platform"; export function ProjectPickerModal() { const { theme } = useUnistyles(); @@ -122,7 +123,7 @@ export function ProjectPickerModal() { // Keyboard navigation useEffect(() => { - if (!open || Platform.OS !== "web") return; + if (!open || isNative) return; function handler(event: KeyboardEvent) { const key = event.key; diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index d6fa504a6..6609ac108 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,10 +1,11 @@ import { useState, useCallback } from "react"; -import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; +import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; +import { isWeb } from "@/constants/platform"; interface QuestionOption { label: string; @@ -60,7 +61,7 @@ interface QuestionFormCardProps { isResponding: boolean; } -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index fb08566dd..a32ee33b9 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { createNameId } from "mnemonic-id"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { openExternalUrl } from "@/utils/open-external-url"; +import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -199,8 +200,8 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) { hitSlop={4} onPressIn={handlePressIn} onPress={handlePress} - onPointerEnter={() => setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + onHoverIn={() => setIsHovered(true)} + onHoverOut={() => setIsHovered(false)} style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]} > @@ -543,7 +544,7 @@ function useLongPressDragInteraction(input: { input.drag(); }, DRAG_ARM_DELAY_MS); - if (!input.menuController || Platform.OS === "web") { + if (!input.menuController || platformIsWeb) { return; } @@ -793,7 +794,7 @@ function ProjectHeaderRow({ createWorktreeMutation.mutate()} - visible={isHovered || isMobileBreakpoint} + visible={isHovered || platformIsNative || isMobileBreakpoint} loading={createWorktreeMutation.isPending} showShortcutHint={isProjectActive} testID={`sidebar-project-new-worktree-${project.projectKey}`} @@ -801,8 +802,8 @@ function ProjectHeaderRow({ ) : null} {onRemoveProject ? ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> - [ - styles.projectRow, - isDragging && styles.projectRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.projectRowHovered, - pressed && styles.projectRowPressed, - ]} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-project-row-${project.projectKey}`} - > - {rowChildren} - - - ); - } - - return ( - setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> - [ styles.projectRow, isDragging && styles.projectRowDragging, @@ -879,6 +857,8 @@ function ProjectHeaderRow({ isHovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} + onHoverIn={() => setIsHovered(true)} + onHoverOut={() => setIsHovered(false)} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} @@ -886,8 +866,29 @@ function ProjectHeaderRow({ testID={`sidebar-project-row-${project.projectKey}`} > {rowChildren} - - + + ); + } + + return ( + [ + styles.projectRow, + isDragging && styles.projectRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.projectRowHovered, + pressed && styles.projectRowPressed, + ]} + onHoverIn={() => setIsHovered(true)} + onHoverOut={() => setIsHovered(false)} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-project-row-${project.projectKey}`} + > + {rowChildren} + ); } @@ -912,8 +913,8 @@ function WorkspaceRowInner({ archiveShortcutKeys, }: WorkspaceRowInnerProps) { const { theme } = useUnistyles(); + const isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); - const isTouchPlatform = Platform.OS !== "web"; const prHint = useWorkspacePrHint({ serverId: workspace.serverId, cwd: workspace.workspaceId, @@ -933,121 +934,118 @@ function WorkspaceRowInner({ }, [interaction.didLongPressRef, onPress]); return ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + [ + styles.workspaceRowContainer, + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ]} + onHoverIn={() => setIsHovered(true)} + onHoverOut={() => setIsHovered(false)} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > - [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-workspace-row-${workspace.workspaceKey}`} - > - - + + + - - - {workspace.name} - - - - {isCreating ? Creating... : null} - {onArchive && (isHovered || isTouchPlatform) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - - )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={archiveShortcutKeys ? : null} - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} - > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} - + {workspace.name} + - {prHint ? ( - - - - ) : null} - - + + {isCreating ? Creating... : null} + {onArchive && (isHovered || platformIsNative || isCompact) ? ( + + [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace actions" + testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} + > + {({ hovered }) => ( + + )} + + + {onCopyPath ? ( + } + onSelect={onCopyPath} + > + Copy path + + ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} + } + trailing={archiveShortcutKeys ? : null} + status={archiveStatus} + pendingLabel={archivePendingLabel} + onSelect={onArchive} + > + {archiveLabel ?? "Archive"} + + + + ) : workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + + + {prHint ? ( + + + + ) : null} + ); } @@ -1597,7 +1595,6 @@ export function SidebarWorkspaceList({ parentGestureRef, }: SidebarWorkspaceListProps) { const isMobile = useIsCompactFormFactor(); - const isNative = Platform.OS !== "web"; const pathname = usePathname(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); @@ -1831,7 +1828,7 @@ export function SidebarWorkspaceList({ drag={drag} isDragging={isActive} dragHandleProps={dragHandleProps} - useNestable={isNative} + useNestable={platformIsNative} creatingWorkspaceIds={creatingWorkspaceIds} /> ); @@ -1848,7 +1845,7 @@ export function SidebarWorkspaceList({ serverId, shortcutIndexByWorkspaceKey, showShortcutBadges, - isNative, + platformIsNative, creatingWorkspaceIds, ], ); @@ -1872,7 +1869,7 @@ export function SidebarWorkspaceList({ onDragEnd={handleProjectDragEnd} scrollEnabled={false} useDragHandle - nestable={isNative} + nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} containerStyle={styles.projectListContainer} /> @@ -1883,7 +1880,7 @@ export function SidebarWorkspaceList({ return ( - {isNative ? ( + {platformIsNative ? ( { - if (Platform.OS !== "web") { + if (isNative) { return; } diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 8b4916875..f2f23b235 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { @@ -234,8 +235,8 @@ export function ToastViewport({ ); - if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") { + if (placement === "app-shell" && isWeb && typeof document !== "undefined") { return createPortal(content, getOverlayRoot()); } diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 2e966327c..75c94aa2e 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,5 +1,5 @@ import React, { useMemo, ReactNode } from "react"; -import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; +import { View, Text, ScrollView as RNScrollView } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; @@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; +import { isWeb } from "@/constants/platform"; -const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; +const ScrollView = isWeb ? RNScrollView : GHScrollView; // ---- Content Component ---- @@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => { fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, - ...(Platform.OS === "web" + ...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal", diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7093d3b51..929dbb5e9 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -37,8 +37,9 @@ import { shouldShowCustomComboboxOption, } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options"; +import { isWeb } from "@/constants/platform"; -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; export type ComboboxOption = ComboboxOptionModel; @@ -107,6 +108,7 @@ export interface SearchInputProps { onChangeText: (text: string) => void; onSubmitEditing?: () => void; autoFocus?: boolean; + useBottomSheetInput?: boolean; } export function SearchInput({ @@ -115,10 +117,11 @@ export function SearchInput({ onChangeText, onSubmitEditing, autoFocus = false, + useBottomSheetInput = false, }: SearchInputProps): ReactElement { const { theme } = useUnistyles(); const inputRef = useRef(null); - const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; + const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput; useEffect(() => { if (autoFocus && IS_WEB && inputRef.current) { @@ -264,8 +267,7 @@ export function Combobox({ }: ComboboxProps): ReactElement { const isMobile = useIsCompactFormFactor(); const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; - const isDesktopAboveSearch = - !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; + const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; const { height: windowHeight } = useWindowDimensions(); const bottomSheetRef = useRef(null); const hasPresentedBottomSheetRef = useRef(false); @@ -322,8 +324,8 @@ export function Combobox({ const middleware = useMemo( () => [ - floatingOffset(Platform.OS === "web" ? 0 : 4), - ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), + floatingOffset(isWeb ? 0 : 4), + ...(isWeb ? [] : [flip({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ padding: collisionPadding, @@ -346,7 +348,7 @@ export function Combobox({ ); const { refs, floatingStyles, update } = useFloating({ - placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", + placement: isWeb ? desktopPlacement : "bottom-start", middleware, sameScrollView: false, elements: { @@ -626,6 +628,7 @@ export function Combobox({ onChangeText={setSearchQueryWithCallback} onSubmitEditing={handleSubmitSearch} autoFocus={!isMobile} + useBottomSheetInput={isMobile} /> ); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index fef7b5435..a799ba9f4 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { isWeb, isNative } from "@/constants/platform"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -254,8 +255,7 @@ export function ContextMenuTrigger({ >): ReactElement { const ctx = useContextMenuContext("ContextMenuTrigger"); - const shouldEnableOnThisPlatform = - enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile); + const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile); const openAtEvent = useCallback( (event: unknown) => { @@ -294,7 +294,7 @@ export function ContextMenuTrigger({ disabled={disabled} delayLongPress={longPressDelayMs} onLongPress={(event) => { - if (Platform.OS === "web") { + if (isWeb) { props.onLongPress?.(event); return; } @@ -303,7 +303,7 @@ export function ContextMenuTrigger({ }} // @ts-ignore - onContextMenu is web-only and not in RN types. onContextMenu={(event: unknown) => { - if (Platform.OS !== "web") { + if (isNative) { return; } const e: any = event; diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 5409a1a98..7214e5fb5 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; type Side = "top" | "bottom" | "left" | "right"; type Align = "start" | "center" | "end"; @@ -108,18 +110,6 @@ function measureElement(element: View): Promise { }); } -function isMobileTooltipEnvironment(): boolean { - if (Platform.OS !== "web") { - return true; - } - - if (typeof navigator === "undefined") { - return false; - } - - return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? ""); -} - function computePosition({ triggerRect, contentSize, @@ -227,8 +217,8 @@ export function Tooltip({ onOpenChange, }); - const isMobile = isMobileTooltipEnvironment(); - const enabled = isMobile ? enabledOnMobile : enabledOnDesktop; + const isCompact = useIsCompactFormFactor(); + const enabled = isCompact ? enabledOnMobile : enabledOnDesktop; const value = useMemo( () => ({ @@ -236,10 +226,10 @@ export function Tooltip({ setOpen: setIsOpen, triggerRef, enabled, - openOnPress: isMobile, + openOnPress: isCompact, delayDuration, }), - [isOpen, setIsOpen, enabled, isMobile, delayDuration], + [isOpen, setIsOpen, enabled, isCompact, delayDuration], ); return {children}; @@ -354,7 +344,7 @@ export function TooltipTrigger({ onFocus: handleFocus, onBlur: handleBlur, onPress: handlePress, - ...(Platform.OS === "web" + ...(isWeb ? ({ // RN Web's hover handling can vary across environments; pointer events are the most reliable. onPointerEnter: handleHoverIn, @@ -473,7 +463,7 @@ export function TooltipContent({ // On web, avoid React Native's implementation (it uses and can // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the // exact same positioning math as DropdownMenu, without hover feedback loops. - if (Platform.OS === "web") { + if (isWeb) { return ( diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx index b9a1f5f2c..469627700 100644 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ b/packages/app/src/components/use-web-scrollbar.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; import { - Platform, type FlatList, type LayoutChangeEvent, type NativeScrollEvent, @@ -12,6 +11,7 @@ import { useWebDesktopScrollbarMetrics, type ScrollbarMetrics, } from "./web-desktop-scrollbar"; +import { isWeb as platformIsWeb } from "@/constants/platform"; const METRICS_EPSILON = 0.5; const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; @@ -45,8 +45,7 @@ export function useWebElementScrollbar( contentRef?: RefObject; }, ): ReactNode { - const isWeb = Platform.OS === "web"; - const enabled = (options?.enabled ?? true) && isWeb; + const enabled = (options?.enabled ?? true) && platformIsWeb; const contentRef = options?.contentRef; const [metrics, setMetrics] = useState({ @@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar( scrollableRef: RefObject, options?: { enabled?: boolean }, ): WebScrollViewScrollbar { - const isWeb = Platform.OS === "web"; - const enabled = (options?.enabled ?? true) && isWeb; + const enabled = (options?.enabled ?? true) && platformIsWeb; const metricsHook = useWebDesktopScrollbarMetrics(); const onScrollToOffset = useCallback( diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index 48a2211ef..cc4a944a2 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PanResponder, - Platform, View, type LayoutChangeEvent, type NativeScrollEvent, @@ -12,6 +11,7 @@ import { computeScrollOffsetFromDragDelta, computeVerticalScrollbarGeometry, } from "./web-desktop-scrollbar.math"; +import { isWeb as platformIsWeb } from "@/constants/platform"; const METRICS_EPSILON = 0.5; const HANDLE_WIDTH_IDLE = 6; @@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({ maxScrollOffset: 0, }); const onScrollToOffsetRef = useRef(onScrollToOffset); - const isWeb = Platform.OS === "web"; const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); const normalizedOffset = inverted @@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({ ); const panResponder = useMemo(() => { - if (isWeb) { + if (platformIsWeb) { return null; } @@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({ setIsDragging(false); }, }); - }, [applyDragDelta, isWeb]); + }, [applyDragDelta, platformIsWeb]); const startWebDrag = useCallback( (event: any) => { - if (!isWeb) { + if (!platformIsWeb) { return; } const clientY = readClientY(event); @@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({ dragStartClientYRef.current = clientY; setIsDragging(true); }, - [isWeb], + [platformIsWeb], ); const handleGrabHoverIn = useCallback(() => { @@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({ }, []); useEffect(() => { - if (!isWeb || !isDragging) { + if (!platformIsWeb || !isDragging) { return; } @@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({ window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointercancel", stopDragging); }; - }, [applyDragDelta, isDragging, isWeb]); + }, [applyDragDelta, isDragging, platformIsWeb]); if (!enabled || !geometry.isVisible) { return null; @@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({ height: thumbRegionHeight, transform: [{ translateY: thumbRegionOffset }], }, - isWeb && + platformIsWeb && ({ cursor: handleCursor, touchAction: "none", @@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({ ]} pointerEvents={handleVisible ? "auto" : "none"} {...(panResponder?.panHandlers ?? {})} - {...(isWeb + {...(platformIsWeb ? ({ onPointerDown: startWebDrag, onPointerEnter: handleGrabHoverIn, @@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({ backgroundColor: handleColor, opacity: handleOpacity, }, - isWeb && + platformIsWeb && ({ transitionProperty: "opacity, width, background-color", transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 2f93ba584..3cf520db8 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; -import { Pressable, Text, View, Platform, ScrollView } from "react-native"; +import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; @@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { openExternalUrl } from "@/utils/open-external-url"; +import { isWeb, isNative } from "@/constants/platform"; type WelcomeAction = { key: "scan-qr" | "direct-connection" | "paste-pairing-link"; @@ -255,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { [router], ); - const actions: WelcomeAction[] = - Platform.OS === "web" - ? [ - { - key: "direct-connection", - label: "Direct connection", - testID: "welcome-direct-connection", - primary: true, - icon: Link2, - onPress: () => setIsDirectOpen(true), - }, - { - key: "paste-pairing-link", - label: "Paste pairing link", - testID: "welcome-paste-pairing-link", - primary: false, - icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), - }, - ] - : [ - { - key: "scan-qr", - label: "Scan QR code", - testID: "welcome-scan-qr", - primary: true, - icon: QrCode, - onPress: () => router.push("/pair-scan?source=onboarding"), - }, - { - key: "direct-connection", - label: "Direct connection", - testID: "welcome-direct-connection", - primary: false, - icon: Link2, - onPress: () => setIsDirectOpen(true), - }, - { - key: "paste-pairing-link", - label: "Paste pairing link", - testID: "welcome-paste-pairing-link", - primary: false, - icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), - }, - ]; + const actions: WelcomeAction[] = isWeb + ? [ + { + key: "direct-connection", + label: "Direct connection", + testID: "welcome-direct-connection", + primary: true, + icon: Link2, + onPress: () => setIsDirectOpen(true), + }, + { + key: "paste-pairing-link", + label: "Paste pairing link", + testID: "welcome-paste-pairing-link", + primary: false, + icon: ClipboardPaste, + onPress: () => setIsPasteLinkOpen(true), + }, + ] + : [ + { + key: "scan-qr", + label: "Scan QR code", + testID: "welcome-scan-qr", + primary: true, + icon: QrCode, + onPress: () => router.push("/pair-scan?source=onboarding"), + }, + { + key: "direct-connection", + label: "Direct connection", + testID: "welcome-direct-connection", + primary: false, + icon: Link2, + onPress: () => setIsDirectOpen(true), + }, + { + key: "paste-pairing-link", + label: "Paste pairing link", + testID: "welcome-paste-pairing-link", + primary: false, + icon: ClipboardPaste, + onPress: () => setIsPasteLinkOpen(true), + }, + ]; const showHostList = hosts.length > 0 && !anyOnlineServerId; @@ -321,7 +321,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} - {!showHostList && Platform.OS !== "web" && ( + {!showHostList && isNative && ( <> You need the Paseo desktop app or server running on your computer first. diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 4a02ad47e..a8b077862 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,6 +1,5 @@ -import { Platform } from "react-native"; import { useUnistyles } from "react-native-unistyles"; -import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; +import { isWeb } from "@/constants/platform"; export const FOOTER_HEIGHT = 75; @@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45; export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140; export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48; -// Check if running in the Electron desktop runtime (any OS) -function isElectronDesktopRuntime(): boolean { - if (Platform.OS !== "web") return false; - return isElectronRuntime(); -} - -// Check if running in the Electron desktop runtime on macOS -function isElectronDesktopRuntimeMac(): boolean { - if (Platform.OS !== "web") return false; - return isElectronRuntimeMac(); -} - -// Cached result - only cache true, keep checking if false (in case desktop globals load later) -let _isElectronRuntimeMacCached: boolean | null = null; -let _isElectronRuntimeCached: boolean | null = null; - -export function getIsElectronRuntimeMac(): boolean { - if (_isElectronRuntimeMacCached === true) { - return true; - } - const result = isElectronDesktopRuntimeMac(); - if (result) { - _isElectronRuntimeMacCached = true; - } - return result; -} - -export function getIsElectronRuntime(): boolean { - if (_isElectronRuntimeCached === true) { - return true; - } - const result = isElectronDesktopRuntime(); - if (result) { - _isElectronRuntimeCached = true; - } - return result; -} +export { + getIsElectron as getIsElectronRuntime, + getIsElectronMac as getIsElectronRuntimeMac, +} from "./platform"; /** * Reactive hook — re-renders the component when the breakpoint changes. @@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean { // Keep that capability distinct from desktop-width layout so touch tablets // can use the desktop shell without entering web-only code paths. export function supportsDesktopPaneSplits(): boolean { - return Platform.OS === "web"; + return isWeb; } diff --git a/packages/app/src/constants/platform.ts b/packages/app/src/constants/platform.ts new file mode 100644 index 000000000..97fb6d932 --- /dev/null +++ b/packages/app/src/constants/platform.ts @@ -0,0 +1,49 @@ +import { Platform } from "react-native"; +import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; + +// --------------------------------------------------------------------------- +// Runtime environment constants +// +// These are the ONLY platform gates in the app. See CLAUDE.md for the +// decision matrix on when to use each one. +// +// Default is cross-platform. Gate only when you must: +// isWeb → DOM APIs (document, window,
, addEventListener) +// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera) +// isElectron → Desktop wrapper features (file dialogs, titlebar, updates) +// +// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts. +// For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed. +// --------------------------------------------------------------------------- + +/** Browser or Electron — the JS runtime has access to the DOM. */ +export const isWeb = Platform.OS === "web"; + +/** iOS or Android — the JS runtime is React Native. */ +export const isNative = Platform.OS !== "web"; + +// --------------------------------------------------------------------------- +// Electron detection (cached — only caches `true`, keeps checking if false +// because the desktop bridge may load after initial module evaluation) +// --------------------------------------------------------------------------- + +let _isElectronCached: boolean | null = null; +let _isElectronMacCached: boolean | null = null; + +/** Running inside the Electron desktop wrapper (any OS). */ +export function getIsElectron(): boolean { + if (_isElectronCached === true) return true; + if (!isWeb) return false; + const result = isElectronRuntime(); + if (result) _isElectronCached = true; + return result; +} + +/** Running inside the Electron desktop wrapper on macOS. */ +export function getIsElectronMac(): boolean { + if (_isElectronMacCached === true) return true; + if (!isWeb) return false; + const result = isElectronRuntimeMac(); + if (result) _isElectronMacCached = true; + return result; +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 675caa210..32b3cd48d 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,6 +1,6 @@ import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { Buffer } from "buffer"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; @@ -52,6 +52,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; +import { isNative } from "@/constants/platform"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider (awayMs: number) => { scheduleAuthoritativeRevalidation(); - if (Platform.OS !== "web") { + if (isNative) { const session = useSessionStore.getState().sessions[serverId]; const agentId = session?.focusedAgentId; const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined; diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts index 6dd3d5974..9e10ca8ac 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.ts @@ -1,5 +1,5 @@ -import { Platform } from "react-native"; import { getDesktopHost } from "@/desktop/host"; +import { isWeb, isNative } from "@/constants/platform"; export type DesktopPermissionKind = "notifications" | "microphone"; @@ -45,7 +45,7 @@ type NavigatorLike = { }; export function shouldShowDesktopPermissionSection(): boolean { - return Platform.OS === "web" && getDesktopHost() !== null; + return isWeb && getDesktopHost() !== null; } function status(input: DesktopPermissionStatus): DesktopPermissionStatus { @@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean { } function getWebNotificationConstructor(): NotificationConstructorLike | null { - if (Platform.OS !== "web") { + if (isNative) { return null; } const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; @@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null { } function getNavigatorLike(): NavigatorLike | null { - if (Platform.OS !== "web") { + if (isNative) { return null; } const webNavigator = (globalThis as { navigator?: unknown }).navigator; @@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS } async function getNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop notification status is only available on web runtime.", @@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop microphone status is only available on web runtime.", @@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise } async function requestNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop notification requests are only available on web runtime.", @@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop microphone requests are only available on web runtime.", diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 36e97e9a1..482a5de81 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -1,6 +1,6 @@ -import { Platform } from "react-native"; import { isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import { isWeb } from "@/constants/platform"; export interface DesktopAppUpdateCheckResult { hasUpdate: boolean; @@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number { } export function shouldShowDesktopUpdateSection(): boolean { - return Platform.OS === "web" && isElectronRuntime(); + return isWeb && isElectronRuntime(); } export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult { diff --git a/packages/app/src/hooks/use-agent-attention-clear.ts b/packages/app/src/hooks/use-agent-attention-clear.ts index 6282df340..78a1ea5eb 100644 --- a/packages/app/src/hooks/use-agent-attention-clear.ts +++ b/packages/app/src/hooks/use-agent-attention-clear.ts @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import type { DaemonClient } from "@server/client/daemon-client"; import { shouldClearAgentAttention, type AgentAttentionClearTrigger, } from "@/utils/agent-attention"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; +import { isWeb } from "@/constants/platform"; type AttentionReason = "finished" | "error" | "permission" | null | undefined; @@ -70,7 +71,7 @@ export function useAgentAttentionClear({ const appStateSubscription = AppState.addEventListener("change", updateVisibility); - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { document.addEventListener("visibilitychange", updateVisibility); window.addEventListener("focus", updateVisibility); window.addEventListener("blur", updateVisibility); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 62ffa9a9d..dd1f9aafc 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import { Platform } from "react-native"; import { useSessionStore } from "@/stores/session-store"; import type { DaemonClient } from "@server/client/daemon-client"; import { @@ -10,13 +9,14 @@ import { rejectInitDeferred, } from "@/utils/agent-initialization"; import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy"; +import { isWeb } from "@/constants/platform"; const INIT_TIMEOUT_MS = 5 * 60_000; const NATIVE_INITIAL_TIMELINE_LIMIT = 200; const UNBOUNDED_TIMELINE_LIMIT = 0; function resolveInitialTimelineLimit(): number { - return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT; + return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT; } export const __private__ = { diff --git a/packages/app/src/hooks/use-app-visible.ts b/packages/app/src/hooks/use-app-visible.ts index 1d584121a..953df7f61 100644 --- a/packages/app/src/hooks/use-app-visible.ts +++ b/packages/app/src/hooks/use-app-visible.ts @@ -1,6 +1,7 @@ import { useEffect, useSyncExternalStore } from "react"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; +import { isWeb } from "@/constants/platform"; let current = getIsAppActivelyVisible(); const listeners = new Set<() => void>(); @@ -29,7 +30,7 @@ export function useAppVisible(): boolean { useEffect(() => { const appStateSubscription = AppState.addEventListener("change", notify); - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { document.addEventListener("visibilitychange", notify); window.addEventListener("focus", notify); window.addEventListener("blur", notify); @@ -37,7 +38,7 @@ export function useAppVisible(): boolean { return () => { appStateSubscription.remove(); - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { document.removeEventListener("visibilitychange", notify); window.removeEventListener("focus", notify); window.removeEventListener("blur", notify); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 6347d08a1..2855219fc 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback } from "react"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import type { DaemonClient } from "@server/client/daemon-client"; +import { isWeb, isNative } from "@/constants/platform"; const HEARTBEAT_INTERVAL_MS = 15_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; @@ -32,7 +33,7 @@ export function useClientActivity({ const prevFocusedAgentIdRef = useRef(focusedAgentId); const lastImmediateHeartbeatAtRef = useRef(0); - const deviceType = Platform.OS === "web" ? "web" : "mobile"; + const deviceType = isWeb ? "web" : "mobile"; const recordUserActivity = useCallback(() => { lastActivityAtRef.current = new Date(); @@ -96,7 +97,7 @@ export function useClientActivity({ // Track user activity on web for accurate staleness. useEffect(() => { - if (Platform.OS !== "web") return; + if (isNative) return; if (typeof document === "undefined") return; const handleUserActivity = () => { diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index 5818a495a..078b8903c 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from "react"; -import { Platform } from "react-native"; import { useShallow } from "zustand/shallow"; import { getIsElectronRuntimeMac } from "@/constants/layout"; import { useAggregatedAgents } from "./use-aggregated-agents"; @@ -9,6 +8,7 @@ import { deriveMacDockBadgeCountFromWorkspaceStatuses, type DesktopBadgeWorkspaceStatus, } from "@/utils/desktop-badge-state"; +import { isNative } from "@/constants/platform"; type FaviconStatus = "none" | "running" | "attention"; type ColorScheme = "dark" | "light"; @@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) { } function getSystemColorScheme(): ColorScheme { - if ( - Platform.OS !== "web" || - typeof window === "undefined" || - typeof window.matchMedia !== "function" - ) { + if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") { return "dark"; } return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } async function updateMacDockBadge(count?: number) { - if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return; + if (isNative || !getIsElectronRuntimeMac()) return; const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.(); if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") { @@ -119,7 +115,7 @@ export function useFaviconStatus() { // Listen for system color scheme changes useEffect(() => { - if (Platform.OS !== "web" || typeof window === "undefined") return; + if (isNative || typeof window === "undefined") return; const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handler = (e: MediaQueryListEvent) => { @@ -132,7 +128,7 @@ export function useFaviconStatus() { // Update favicon when agents or color scheme changes useEffect(() => { - if (Platform.OS !== "web") return; + if (isNative) return; const status = deriveFaviconStatus(agents); updateFavicon(status, colorScheme); diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index b9f5ada96..65ce91426 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -1,8 +1,8 @@ import { useState, useRef, useEffect } from "react"; -import { Platform } from "react-native"; import type { ImageAttachment } from "@/components/message-input"; import { getDesktopHost } from "@/desktop/host"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; +import { isWeb } from "@/constants/platform"; interface UseFileDropZoneOptions { onFilesDropped: (files: ImageAttachment[]) => void; @@ -14,7 +14,7 @@ interface UseFileDropZoneReturn { containerRef: React.RefObject; } -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; const IMAGE_MIME_BY_EXTENSION: Record = { ".png": "image/png", ".jpg": "image/jpeg", diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index 13009a44e..014ffa281 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -8,6 +8,7 @@ import { openImagePathsWithDesktopDialog, type PickedImageAttachmentInput, } from "@/hooks/image-attachment-picker"; +import { isWeb } from "@/constants/platform"; interface UseImageAttachmentPickerResult { pickImages: () => Promise; @@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { isPickingRef.current = true; try { - if (Platform.OS === "web" && isElectronRuntime()) { + if (isWeb && isElectronRuntime()) { const selectedPaths = await openImagePathsWithDesktopDialog(); if (selectedPaths.length === 0) { return null; diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 4ec95766f..cf7cf4312 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,5 +1,4 @@ import { useEffect, useMemo, useRef } from "react"; -import { Platform } from "react-native"; import { usePathname } from "expo-router"; import { getIsElectronRuntime } from "@/constants/layout"; import { useHosts } from "@/runtime/host-runtime"; @@ -26,6 +25,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { isNative } from "@/constants/platform"; export function useKeyboardShortcuts({ enabled, @@ -65,7 +65,7 @@ export function useKeyboardShortcuts({ useEffect(() => { if (!enabled) return; - if (Platform.OS !== "web") return; + if (isNative) return; if (isMobile) return; const isDesktopApp = getIsElectronRuntime(); diff --git a/packages/app/src/hooks/use-push-token-registration.ts b/packages/app/src/hooks/use-push-token-registration.ts index 61a686bf6..6b641f71d 100644 --- a/packages/app/src/hooks/use-push-token-registration.ts +++ b/packages/app/src/hooks/use-push-token-registration.ts @@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Notifications from "expo-notifications"; import Constants from "expo-constants"; import type { DaemonClient } from "@server/client/daemon-client"; +import { isWeb } from "@/constants/platform"; const STORAGE_PREFIX = "@paseo:expo-push-token:"; @@ -31,7 +32,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI const lastSentTokenRef = useRef(null); const registerIfPossible = useCallback(async () => { - if (Platform.OS === "web") return; + if (isWeb) return; if (!client.isConnected) return; const token = tokenRef.current; if (!token) return; @@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI }, [client]); useEffect(() => { - if (Platform.OS === "web") return; + if (isWeb) return; const storageKey = `${STORAGE_PREFIX}${serverId}`; let cancelled = false; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 8b7e97bf3..1c2325663 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, Text, View } from "react-native"; +import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; @@ -47,6 +47,7 @@ import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; +import { isNative } from "@/constants/platform"; function formatProviderLabel(provider: Agent["provider"]): string { if (!provider) { @@ -595,7 +596,7 @@ function AgentPanelBody({ if (!isConnected || !hasSession) { return; } - const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; + const shouldSyncOnEntry = needsAuthoritativeSync || isNative; if (!shouldSyncOnEntry) { return; } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 696cd3705..6e19d7db9 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { createNameId } from "mnemonic-id"; import type { ImageAttachment } from "@/components/message-input"; -import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native"; +import { View, Text, Pressable, ScrollView, Keyboard } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useIsFocused } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -57,6 +57,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; +import { isWeb } from "@/constants/platform"; const EMPTY_PENDING_PERMISSIONS = new Map(); const DRAFT_CAPABILITIES: AgentCapabilityFlags = { @@ -850,7 +851,7 @@ function DraftAgentScreenContent({ }, onBeforeSubmit: () => { void persistFormPreferences(); - if (Platform.OS === "web") { + if (isWeb) { (document.activeElement as HTMLElement | null)?.blur?.(); } Keyboard.dismiss(); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9c9e5ef54..a70f80cd7 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { MutableRefObject, ComponentType } from "react"; -import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native"; +import { View, Text, ScrollView, Alert, Pressable } from "react-native"; import { router, useLocalSearchParams } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -72,6 +72,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manife import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { StatusBadge } from "@/components/ui/status-badge"; +import { isWeb } from "@/constants/platform"; // --------------------------------------------------------------------------- // Section definitions @@ -1672,22 +1673,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) { - {Platform.OS === "web" ? ( + {isWeb ? ( {badgeText} ) : null} {connectionBadge ? ( - + {connectionBadge.icon} - {Platform.OS === "web" ? ( + {isWeb ? ( {connectionBadge.text} diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 9d760a311..dd3ba0d65 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { View, Text, Platform } from "react-native"; +import { View, Text } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { StyleSheet } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; @@ -20,6 +20,7 @@ import { import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { getIsElectronRuntime } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; function ShortcutSequence({ chord, @@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() { } useEffect(() => { - if (Platform.OS !== "web") return; + if (isNative) return; if (capturingBindingId === null) return; function handleKeyDown(event: KeyboardEvent) { @@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() { }; }, [setCapturingShortcut]); - if (Platform.OS !== "web") { + if (isNative) { return ( Shortcuts diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index a8e107aef..975c6a8ac 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native"; +import { ActivityIndicator, ScrollView, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { openExternalUrl } from "@/utils/open-external-url"; import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Fonts } from "@/constants/theme"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { isWeb } from "@/constants/platform"; type StartupSplashScreenProps = { bootstrapState?: { @@ -42,7 +43,7 @@ const styles = StyleSheet.create((theme) => ({ }, errorScrollView: { flex: 1, - ...(Platform.OS === "web" + ...(isWeb ? { overflowX: "auto", overflowY: "auto", @@ -144,7 +145,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, - ...(Platform.OS === "web" + ...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal", diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 76c1c7f6d..0afe60268 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { ActivityIndicator, - Platform, Pressable, ScrollView, Text, @@ -30,6 +29,7 @@ import { } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { SortableInlineList } from "@/components/sortable-inline-list"; +import { isNative, isWeb } from "@/constants/platform"; import { ContextMenu, ContextMenuContent, @@ -113,6 +113,7 @@ function useMiddleClickClose(onClose: () => void) { const ref = useRef(null); useEffect(() => { + if (isNative) return; const node = ref.current as unknown as HTMLElement | null; if (!node) return; @@ -174,17 +175,16 @@ function TabChip({ ); const [hovered, setHovered] = useState(false); const isHighlighted = isActive || hovered || isCloseHovered; - const closeButtonDragBlockers = - Platform.OS === "web" - ? ({ - onPointerDown: (event: { stopPropagation?: () => void }) => { - event.stopPropagation?.(); - }, - onMouseDown: (event: { stopPropagation?: () => void }) => { - event.stopPropagation?.(); - }, - } as const) - : undefined; + const closeButtonDragBlockers = isWeb + ? ({ + onPointerDown: (event: { stopPropagation?: () => void }) => { + event.stopPropagation?.(); + }, + onMouseDown: (event: { stopPropagation?: () => void }) => { + event.stopPropagation?.(); + }, + } as const) + : undefined; return ( @@ -199,7 +199,7 @@ function TabChip({ enabledOnMobile={false} style={({ hovered, pressed }) => [ styles.tab, - Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const), + isWeb && isDragging && ({ cursor: "grabbing" } as const), { minWidth: resolvedTabWidth, width: resolvedTabWidth, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 84a559cea..a646b755c 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Keyboard, Platform, ScrollView, Text, View } from "react-native"; +import { Keyboard, ScrollView, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { AgentInputArea } from "@/components/agent-input-area"; import { FileDropZone } from "@/components/file-drop-zone"; @@ -19,6 +19,7 @@ import type { AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; import type { AgentSnapshotPayload } from "@server/shared/messages"; +import { isWeb } from "@/constants/platform"; const EMPTY_PENDING_PERMISSIONS = new Map(); const DRAFT_CAPABILITIES: AgentCapabilityFlags = { @@ -155,7 +156,7 @@ export function WorkspaceDraftAgentTab({ }, onBeforeSubmit: () => { void persistFormPreferences(); - if (Platform.OS === "web") { + if (isWeb) { (document.activeElement as HTMLElement | null)?.blur?.(); } Keyboard.dismiss(); diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 6204f280f..836c2469f 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; -import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Check, ChevronDown } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -15,6 +15,7 @@ import { useToast } from "@/contexts/toast-context"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { isAbsolutePath } from "@/utils/path"; +import { isWeb } from "@/constants/platform"; interface WorkspaceOpenInEditorButtonProps { serverId: string; @@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const shouldLoadEditors = - Platform.OS === "web" && - Boolean(client && isConnected) && - cwd.trim().length > 0 && - isAbsolutePath(cwd); + isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd); const availableEditorsQuery = useQuery({ queryKey: ["available-editors", serverId], diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index af6897336..c16090e9f 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,15 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; -import { - ActivityIndicator, - BackHandler, - Keyboard, - Platform, - Pressable, - Text, - View, -} from "react-native"; +import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import { @@ -118,6 +110,7 @@ import { } from "@/screens/workspace/workspace-bulk-close"; import { findAdjacentPane } from "@/utils/split-navigation"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; +import { isWeb, isNative } from "@/constants/platform"; const TERMINALS_QUERY_STALE_TIME = 5_000; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; @@ -252,7 +245,7 @@ function WorkspaceDocumentTitleEffect({ titleState: "ready" | "loading"; }) { useEffect(() => { - if (Platform.OS !== "web" || typeof document === "undefined") { + if (isNative || typeof document === "undefined") { return; } const resolvedLabel = label.trim(); @@ -810,7 +803,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }); useEffect(() => { - if (Platform.OS === "web" || !isExplorerOpen) { + if (isWeb || !isExplorerOpen) { return; } @@ -1709,7 +1702,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits; useEffect(() => { - if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) { + if (isNative || typeof document === "undefined" || activeTabDescriptor) { return; } document.title = "Workspace"; @@ -1933,7 +1926,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return ( - {Platform.OS === "web" && activeTabDescriptor ? ( + {isWeb && activeTabDescriptor ? ( ()((set, get) => ({ const downloadUrl = buildDownloadUrl( downloadTarget.baseUrl, tokenResponse.token, - Platform.OS === "web" ? downloadTarget.authCredentials : null, + isWeb ? downloadTarget.authCredentials : null, ); - if (Platform.OS === "web") { + if (isWeb) { triggerBrowserDownload(downloadUrl, resolvedFileName); get().completeDownload(id); return; @@ -153,7 +153,7 @@ export const useDownloadStore = create()((set, get) => ({ } } catch (error) { const message = error instanceof Error ? error.message : "Failed to download file."; - if (Platform.OS === "web") { + if (isWeb) { console.warn("[DownloadStore] Download failed:", message); get().failDownload(id, message); return; diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index cf1551d2e..f68513832 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -1,7 +1,6 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { Platform } from "react-native"; import { buildExplorerCheckoutKey, coerceExplorerTabForCheckout, @@ -9,6 +8,7 @@ import { resolveExplorerTabForCheckout, type ExplorerTab, } from "./explorer-tab-memory"; +import { isWeb } from "@/constants/platform"; export type { ExplorerTab } from "./explorer-tab-memory"; /** @@ -128,7 +128,7 @@ function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | }); } -const DEFAULT_DESKTOP_OPEN = Platform.OS === "web"; +const DEFAULT_DESKTOP_OPEN = isWeb; export const usePanelStore = create()( persist( @@ -318,11 +318,7 @@ export const usePanelStore = create()( const state = persistedState as Partial & Record; if (version < 2) { - if ( - Platform.OS === "web" && - typeof state.explorerWidth === "number" && - state.explorerWidth === 400 - ) { + if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; } @@ -337,7 +333,7 @@ export const usePanelStore = create()( if (version < 3) { if ( - Platform.OS === "web" && + isWeb && typeof state.explorerWidth === "number" && (state.explorerWidth === 400 || state.explorerWidth === 520) ) { diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index af7c0fd0f..81563eecb 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -1,8 +1,8 @@ -import { Platform } from "react-native"; import type { Theme } from "./theme"; import { Fonts } from "@/constants/theme"; +import { isWeb } from "@/constants/platform"; -const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {}; +const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {}; /** * Creates comprehensive markdown styles for react-native-markdown-display. diff --git a/packages/app/src/utils/app-visibility.ts b/packages/app/src/utils/app-visibility.ts index 6b29dd1ec..896d70eb1 100644 --- a/packages/app/src/utils/app-visibility.ts +++ b/packages/app/src/utils/app-visibility.ts @@ -1,11 +1,12 @@ -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; +import { isNative } from "@/constants/platform"; export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean { if (appState !== "active") { return false; } - if (Platform.OS !== "web") { + if (isNative) { return true; } diff --git a/packages/app/src/utils/confirm-dialog.ts b/packages/app/src/utils/confirm-dialog.ts index 8806c9e36..a04c6e16b 100644 --- a/packages/app/src/utils/confirm-dialog.ts +++ b/packages/app/src/utils/confirm-dialog.ts @@ -1,5 +1,6 @@ -import { Alert, Platform } from "react-native"; +import { Alert } from "react-native"; import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host"; +import { isNative } from "@/constants/platform"; export interface ConfirmDialogInput { title: string; @@ -49,7 +50,7 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise { - if (Platform.OS !== "web") { + if (isNative) { return showNativeConfirmDialog(input); } diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts index 6d22d812c..e2b92289e 100644 --- a/packages/app/src/utils/desktop-window.ts +++ b/packages/app/src/utils/desktop-window.ts @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import { Platform } from "react-native"; import { getIsElectronRuntimeMac, getIsElectronRuntime, @@ -10,6 +9,7 @@ import { } from "@/constants/layout"; import { getDesktopWindow } from "@/desktop/electron/window"; import { usePanelStore } from "@/stores/panel-store"; +import { isNative } from "@/constants/platform"; type RawWindowControlsPadding = { left: number; @@ -23,7 +23,7 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding { const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { - if (Platform.OS !== "web" || !getIsElectronRuntime()) return; + if (isNative || !getIsElectronRuntime()) return; let disposed = false; let cleanup: (() => void) | undefined; diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts index 2de4c5f5a..64986ad68 100644 --- a/packages/app/src/utils/open-external-url.ts +++ b/packages/app/src/utils/open-external-url.ts @@ -1,9 +1,9 @@ import * as Linking from "expo-linking"; -import { Platform } from "react-native"; import { getDesktopHost } from "@/desktop/host"; +import { isWeb } from "@/constants/platform"; export async function openExternalUrl(url: string): Promise { - if (Platform.OS === "web") { + if (isWeb) { const opener = getDesktopHost()?.opener?.openUrl; if (typeof opener === "function") { await opener(url); diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index c2644a688..33765e4a1 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -1,7 +1,7 @@ import { Asset } from "expo-asset"; -import { Platform } from "react-native"; import { getDesktopHost } from "@/desktop/host"; import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing"; +import { isNative } from "@/constants/platform"; type OsNotificationPayload = { title: string; @@ -80,7 +80,7 @@ async function ensureNotificationPermission(): Promise { } export async function ensureOsNotificationPermission(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return false; } return await ensureNotificationPermission(); @@ -154,7 +154,7 @@ function attachWebClickHandler( export async function sendOsNotification(payload: OsNotificationPayload): Promise { // Mobile/native notifications should be remote push only. - if (Platform.OS !== "web") { + if (isNative) { return false; } diff --git a/packages/app/src/utils/scroll-jank-investigation.ts b/packages/app/src/utils/scroll-jank-investigation.ts index 768e21bb2..50d66765b 100644 --- a/packages/app/src/utils/scroll-jank-investigation.ts +++ b/packages/app/src/utils/scroll-jank-investigation.ts @@ -1,4 +1,4 @@ -import { Platform } from "react-native"; +import { isWeb } from "@/constants/platform"; type ListenerStats = { adds: number; @@ -92,7 +92,7 @@ const SOURCE_LABEL = "[ScrollJankInvestigation]"; function shouldInstall(): boolean { const runtime = globalThis as ScrollInvestigationGlobal; const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); - return Platform.OS === "web" && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__; + return isWeb && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__; } function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean { diff --git a/packages/app/src/utils/shortcut-platform.ts b/packages/app/src/utils/shortcut-platform.ts index 11a187f37..40678364a 100644 --- a/packages/app/src/utils/shortcut-platform.ts +++ b/packages/app/src/utils/shortcut-platform.ts @@ -1,9 +1,10 @@ import { Platform } from "react-native"; import { getIsElectronRuntimeMac } from "@/constants/layout"; import type { ShortcutOs } from "@/utils/format-shortcut"; +import { isNative } from "@/constants/platform"; export function getShortcutOs(): ShortcutOs { - if (Platform.OS !== "web") { + if (isNative) { return Platform.OS === "ios" ? "mac" : "non-mac"; } if (getIsElectronRuntimeMac()) return "mac"; diff --git a/packages/app/src/utils/thinking-tone.ts b/packages/app/src/utils/thinking-tone.ts index 8b1efe378..87297c77b 100644 --- a/packages/app/src/utils/thinking-tone.ts +++ b/packages/app/src/utils/thinking-tone.ts @@ -1,6 +1,6 @@ import { Asset } from "expo-asset"; import { File } from "expo-file-system"; -import { Platform } from "react-native"; +import { isWeb } from "@/constants/platform"; export { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav"; export const THINKING_TONE_REPEAT_GAP_MS = 350; @@ -11,7 +11,7 @@ async function readThinkingToneArrayBuffer(): Promise { const toneModule = require("../../assets/audio/thinking-tone.wav"); const asset = Asset.fromModule(toneModule); - if (Platform.OS === "web") { + if (isWeb) { const response = await fetch(asset.uri); if (!response.ok) { throw new Error(`Failed to fetch thinking tone asset: ${response.status}`); From 42aaa4e4187667ebe88c0697d9e7fff558e861d8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 13:36:38 +0700 Subject: [PATCH 03/21] fix: format sidebar-workspace-list line wrapping --- packages/app/src/components/sidebar-workspace-list.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index a32ee33b9..ec4daa8a4 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -802,7 +802,10 @@ function ProjectHeaderRow({ ) : null} {onRemoveProject ? ( From 8008ea69dd227f7b98c89d3d4bdef285ccb28244 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 13:39:35 +0700 Subject: [PATCH 04/21] fix: restore wrapper View for sidebar hover stability The wrapper View with onPointerEnter/onPointerLeave is needed to keep hover state stable when the mouse moves from the row to the kebab dropdown (which opens in an overlay outside the Pressable). Without it, the Pressable fires onHoverOut and the kebab disappears. On native, isNative makes controls always-visible so this is web-only behavior which is correct (onPointerEnter works on web). --- .../src/components/sidebar-workspace-list.tsx | 273 +++++++++--------- 1 file changed, 138 insertions(+), 135 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index ec4daa8a4..0e88ce3d8 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -851,8 +851,31 @@ function ProjectHeaderRow({ if (menuController) { return ( - setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> + [ + styles.projectRow, + isDragging && styles.projectRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.projectRowHovered, + pressed && styles.projectRowPressed, + ]} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-project-row-${project.projectKey}`} + > + {rowChildren} + + + ); + } + + return ( + setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> + [ styles.projectRow, isDragging && styles.projectRowDragging, @@ -860,8 +883,6 @@ function ProjectHeaderRow({ isHovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} - onHoverIn={() => setIsHovered(true)} - onHoverOut={() => setIsHovered(false)} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} @@ -869,29 +890,8 @@ function ProjectHeaderRow({ testID={`sidebar-project-row-${project.projectKey}`} > {rowChildren} - - ); - } - - return ( - [ - styles.projectRow, - isDragging && styles.projectRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.projectRowHovered, - pressed && styles.projectRowPressed, - ]} - onHoverIn={() => setIsHovered(true)} - onHoverOut={() => setIsHovered(false)} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-project-row-${project.projectKey}`} - > - {rowChildren} - + + ); } @@ -937,118 +937,121 @@ function WorkspaceRowInner({ }, [interaction.didLongPressRef, onPress]); return ( - [ - styles.workspaceRowContainer, - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} - onHoverIn={() => setIsHovered(true)} - onHoverOut={() => setIsHovered(false)} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} > - - - - [ + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ]} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + > + + - {workspace.name} - - - - {isCreating ? Creating... : null} - {onArchive && (isHovered || platformIsNative || isCompact) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - - )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={archiveShortcutKeys ? : null} - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} + + + {workspace.name} + + + + {isCreating ? Creating... : null} + {onArchive && (isHovered || platformIsNative || isCompact) ? ( + + [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace actions" + testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} + {({ hovered }) => ( + + )} + + + {onCopyPath ? ( + } + onSelect={onCopyPath} + > + Copy path + + ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} + } + trailing={archiveShortcutKeys ? : null} + status={archiveStatus} + pendingLabel={archivePendingLabel} + onSelect={onArchive} + > + {archiveLabel ?? "Archive"} + + + + ) : workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + - - {prHint ? ( - - - - ) : null} - + {prHint ? ( + + + + ) : null} + + ); } From b3d540129575d275fa99fc7123013499ebaa5baa Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 14:49:27 +0700 Subject: [PATCH 05/21] fix(orchestrate): PR delivery requires all CI checks green The orchestrate skill now keeps the heartbeat alive after PR creation and monitors CI until all checks pass, fixing failures automatically. Also requires full PR URLs in all user-facing messages. --- skills/paseo-orchestrate/SKILL.md | 50 +++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/skills/paseo-orchestrate/SKILL.md b/skills/paseo-orchestrate/SKILL.md index 8f564735b..992550d4b 100644 --- a/skills/paseo-orchestrate/SKILL.md +++ b/skills/paseo-orchestrate/SKILL.md @@ -187,8 +187,8 @@ Do the following steps in order: - If in worktree mode and any agent is NOT in the worktree, archive it and relaunch with the correct cwd. 7. If ALL acceptance criteria are met: - - Delete this schedule. - Proceed to delivery. + - Do NOT delete this schedule yet — if the user requests a PR, the heartbeat transitions to CI monitoring mode. Only delete it once CI is fully green (or if the user declines a PR). ``` ### Phase 7: Implement @@ -233,13 +233,12 @@ Archive all QA agents when done. ### Phase 11: Deliver -1. Delete the heartbeat schedule -2. Archive any remaining agents -3. **If in worktree mode:** +1. Archive any remaining implementation/QA agents +2. **If in worktree mode:** - Report the worktree path and branch name - Ask: "The work is in worktree `` on branch `orchestrate/`. Should I merge it into your current branch, create a PR, or leave the worktree for you to review?" - Do NOT remove the worktree automatically — the user decides what to do with it -4. **If NOT in worktree mode:** +3. **If NOT in worktree mode:** - Report to the user: - What was done (high-level) - What files changed @@ -248,6 +247,47 @@ Archive all QA agents when done. Wait for the user's instruction. +**When the user asks for a PR, the job is NOT done when the PR is created.** The objective is: PR created AND all CI checks passing. After creating the PR: + +1. Keep the heartbeat schedule running — do NOT delete it yet. +2. Update the heartbeat prompt to include CI monitoring (see below). +3. Monitor CI status via `gh pr checks --watch` or `gh pr checks `. +4. If any check fails: + - Read the failure logs (`gh run view --log-failed`). + - Launch a fix agent targeting the failure. + - Push the fix. CI will re-run automatically. + - Continue monitoring. +5. Only when ALL checks are green: + - Delete the heartbeat schedule. + - Report to the user with the full PR URL (e.g. `https://github.com/owner/repo/pull/N`), not just a number. + +#### Post-PR heartbeat prompt + +After creating the PR, update the heartbeat schedule prompt to: + +``` +HEARTBEAT — CI monitoring for PR #. + +Do the following steps in order: + +1. Check CI status: + gh pr checks + +2. If all checks passed: + - Delete this schedule. + - Tell the user the PR is ready with the full PR URL (use `gh pr view --json url -q .url` to get it). + +3. If any check failed: + - Get the failed run logs: gh run view --log-failed + - Diagnose the failure. + - Launch a fix agent to address it (background: true, notifyOnFinish: true). + - After the fix agent completes, push the fix. + - Continue monitoring on next heartbeat. + +4. If checks are still running: + - Do nothing. Wait for the next heartbeat. +``` + --- ## Role Reference From 8aa1833e2b7a33d92da79d022232974b772db29b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 14:55:59 +0700 Subject: [PATCH 06/21] fix(server): await git worktree creation before responding to client Move createAgentWorktree() from the fire-and-forget background function into handleCreatePaseoWorktreeRequest so the worktree directory exists on disk before the client receives the response and navigates. Fixes ENOENT race when the app queries checkout status on a path that hasn't been created yet. Setup commands (npm install, etc.) remain background. --- packages/server/src/server/session.ts | 9 +++--- .../server/src/server/worktree-session.ts | 28 +++++++++---------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 944d708e5..bcbe0e340 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -173,7 +173,7 @@ import { ScheduleService } from "./schedule/service.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, - createPaseoWorktreeInBackground as createWorktreeInBackgroundSession, + runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, @@ -6157,20 +6157,19 @@ export class Session { registerPendingWorktreeWorkspace: (options) => this.registerPendingWorktreeWorkspace(options), sessionLogger: this.sessionLogger, - createPaseoWorktreeInBackground: (options) => this.createPaseoWorktreeInBackground(options), + runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options), }, request, ); } - private async createPaseoWorktreeInBackground(options: { + private async runWorktreeSetupInBackground(options: { requestCwd: string; repoRoot: string; - baseBranch: string; slug: string; worktreePath: string; }): Promise { - return createWorktreeInBackgroundSession( + return runWorktreeSetupInBackgroundSession( { paseoHome: this.paseoHome, emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 637a95f4c..3ce9af075 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -109,10 +109,9 @@ type HandleCreatePaseoWorktreeRequestDependencies = { branchName: string; }) => Promise; sessionLogger: Logger; - createPaseoWorktreeInBackground: (options: { + runWorktreeSetupInBackground: (options: { requestCwd: string; repoRoot: string; - baseBranch: string; slug: string; worktreePath: string; }) => Promise; @@ -575,6 +574,15 @@ export async function handleCreatePaseoWorktreeRequest( worktreePath, branchName: normalizedSlug, }); + + await createAgentWorktree({ + cwd: repoRoot, + branchName: normalizedSlug, + baseBranch, + worktreeSlug: normalizedSlug, + paseoHome: dependencies.paseoHome, + }); + const descriptor = await dependencies.describeWorkspaceRecord(workspace); dependencies.emit({ type: "create_paseo_worktree_response", @@ -586,10 +594,9 @@ export async function handleCreatePaseoWorktreeRequest( }, }); - void dependencies.createPaseoWorktreeInBackground({ + void dependencies.runWorktreeSetupInBackground({ requestCwd: request.cwd, repoRoot, - baseBranch, slug: normalizedSlug, worktreePath, }); @@ -611,12 +618,11 @@ export async function handleCreatePaseoWorktreeRequest( } } -export async function createPaseoWorktreeInBackground( +export async function runWorktreeSetupInBackground( dependencies: CreatePaseoWorktreeInBackgroundDependencies, options: { requestCwd: string; repoRoot: string; - baseBranch: string; slug: string; worktreePath: string; }, @@ -624,14 +630,6 @@ export async function createPaseoWorktreeInBackground( let setupTerminalId: string | null = null; try { - await createAgentWorktree({ - cwd: options.repoRoot, - branchName: options.slug, - baseBranch: options.baseBranch, - worktreeSlug: options.slug, - paseoHome: dependencies.paseoHome, - }); - const setupCommands = getWorktreeSetupCommands(options.worktreePath); if (setupCommands.length > 0 && dependencies.terminalManager) { const runtimeEnv = await resolveWorktreeRuntimeEnv({ @@ -667,7 +665,7 @@ export async function createPaseoWorktreeInBackground( worktreePath: options.worktreePath, setupTerminalId, }, - "Background worktree creation failed", + "Background worktree setup failed", ); } finally { await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath); From f0c59ec534d03e58faf956ac629488c81ceb4d2c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 15:16:23 +0700 Subject: [PATCH 07/21] feat: add Cmd+, keyboard shortcut to toggle settings (#319) Add settings.toggle action with Cmd+, (Mac) / Ctrl+, (non-Mac) bindings. When on a settings route, pressing the shortcut navigates back; otherwise it pushes to the host settings route. Also adds comma to the shortcut key map so the combo string parses correctly. --- .../app/src/hooks/use-keyboard-shortcuts.ts | 14 +++++++++- packages/app/src/keyboard/actions.ts | 1 + .../app/src/keyboard/keyboard-shortcuts.ts | 26 +++++++++++++++++++ packages/app/src/keyboard/shortcut-string.ts | 1 + 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index cf7cf4312..9031d2b0c 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,10 +1,11 @@ import { useEffect, useMemo, useRef } from "react"; -import { usePathname } from "expo-router"; +import { usePathname, useRouter } from "expo-router"; import { getIsElectronRuntime } from "@/constants/layout"; import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { + buildHostSettingsRoute, parseHostAgentRouteFromPathname, parseServerIdFromPathname, parseHostWorkspaceRouteFromPathname, @@ -47,6 +48,7 @@ export function useKeyboardShortcuts({ cycleTheme?: () => void; }) { const pathname = usePathname(); + const router = useRouter(); const hosts = useHosts(); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const { overrides } = useKeyboardShortcutOverrides(); @@ -244,6 +246,16 @@ export function useKeyboardShortcuts({ case "sidebar.toggle.left": toggleAgentList(); return true; + case "settings.toggle": + if (pathname.endsWith("/settings")) { + router.back(); + return true; + } + if (!activeServerId) { + return false; + } + router.push(buildHostSettingsRoute(activeServerId)); + return true; case "sidebar.toggle.both": if (toggleBothSidebars) { toggleBothSidebars(); diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index dd0816712..32916f8ed 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -37,6 +37,7 @@ export type KeyboardActionId = | "sidebar.toggle.left" | "sidebar.toggle.right" | "sidebar.toggle.both" + | "settings.toggle" | "command-center.toggle" | "shortcuts.dialog.toggle" | "workspace.terminal.new" diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 7e6e265c1..b356c8473 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -712,6 +712,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- Settings toggle --- + { + id: "settings-toggle-cmd-comma-mac", + action: "settings.toggle", + combo: "Cmd+,", + when: { mac: true, commandCenter: false }, + help: { + id: "toggle-settings", + section: "panels", + label: "Toggle settings", + keys: ["mod", ","], + }, + }, + { + id: "settings-toggle-ctrl-comma-non-mac", + action: "settings.toggle", + combo: "Ctrl+,", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "toggle-settings", + section: "panels", + label: "Toggle settings", + keys: ["mod", ","], + }, + }, + // --- Focus mode --- { id: "view-toggle-focus-cmd-shift-f-mac", diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts index 0d5e8e669..bef1bbf39 100644 --- a/packages/app/src/keyboard/shortcut-string.ts +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -31,6 +31,7 @@ KEY_MAP["Digit"] = { code: "Digit" }; KEY_MAP["\\"] = { code: "Backslash" }; KEY_MAP["["] = { code: "BracketLeft", key: "[" }; KEY_MAP["]"] = { code: "BracketRight", key: "]" }; +KEY_MAP[","] = { code: "Comma", key: "," }; KEY_MAP["."] = { code: "Period", key: "." }; KEY_MAP["`"] = { code: "Backquote", key: "`" }; KEY_MAP["/"] = { code: "Slash" }; From bf76915884932c36e96487dadc926828da494f6e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 15:20:59 +0700 Subject: [PATCH 08/21] fix: centralize Windows exec handling and async migrations (#318) * Add Windows exec foundation helpers * server: centralize windows exec handling phase 2 * migrate cli daemon status to execCommand * refactor: complete phase 4 async exec migrations * test: cover windows exec helpers * fix: use beforeEach for context.skip in test setup --- .../src/commands/daemon/runtime-toolchain.ts | 78 +- packages/cli/src/commands/daemon/status.ts | 17 +- packages/server/scripts/test-mcp-inject.ts | 8 +- .../src/poc-commands/commands-poc.test.ts | 147 +- ...ude-agent.event-stream.integration.test.ts | 553 ++++---- .../claude-agent-commands.real.e2e.test.ts | 18 +- .../claude-agent.integration.test.ts | 187 ++- .../claude-agent.max-effort.real.e2e.test.ts | 70 +- .../server/agent/providers/claude-agent.ts | 24 +- .../providers/claude-sdk-behavior.test.ts | 163 +-- .../agent/providers/codex-app-server-agent.ts | 6 +- .../codex-plan-mode.real.e2e.test.ts | 90 +- .../agent/providers/diagnostic-utils.ts | 12 +- .../opencode-agent-commands.real.e2e.test.ts | 20 +- ...code-agent.error-handling.real.e2e.test.ts | 276 ++-- .../server/agent/providers/opencode-agent.ts | 5 +- ...pencode-assistant-message.real.e2e.test.ts | 112 +- .../opencode-reasoning-dedup.real.e2e.test.ts | 106 +- .../src/server/daemon-e2e/agent-configs.ts | 63 +- ...de-autonomous-wake-simple.real.e2e.test.ts | 182 +-- .../claude-autonomous-wake.real.e2e.test.ts | 1248 ++++++++--------- .../model-resolution-on-init.real.e2e.test.ts | 92 +- ...-runtime-reconcile-claude.real.e2e.test.ts | 92 +- .../opencode-custom-agents.real.e2e.test.ts | 102 +- ...ncode-initial-prompt-wait.real.e2e.test.ts | 146 +- .../opencode-invalid-model.real.e2e.test.ts | 62 +- ...encode-plan-and-questions.real.e2e.test.ts | 174 +-- .../opencode-send-interrupt.real.e2e.test.ts | 246 ++-- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 112 +- ...ser-message-dedupe-claude.real.e2e.test.ts | 96 +- ...d-during-tool-call-claude.real.e2e.test.ts | 208 +-- ...hile-running-stuck-claude.real.e2e.test.ts | 200 +-- .../send-while-running-stuck.real.e2e.test.ts | 194 +-- .../ui-action-stress.real.e2e.test.ts | 150 +- packages/server/src/server/exports.ts | 4 +- packages/server/src/server/loop-service.ts | 7 +- packages/server/src/server/session.ts | 22 +- packages/server/src/tasks/cli.ts | 42 +- packages/server/src/utils/checkout-git.ts | 9 +- packages/server/src/utils/executable.test.ts | 255 +++- packages/server/src/utils/executable.ts | 93 +- packages/server/src/utils/spawn.test.ts | 54 + packages/server/src/utils/spawn.ts | 76 +- packages/server/src/utils/worktree.ts | 8 +- 44 files changed, 3048 insertions(+), 2781 deletions(-) create mode 100644 packages/server/src/utils/spawn.test.ts diff --git a/packages/cli/src/commands/daemon/runtime-toolchain.ts b/packages/cli/src/commands/daemon/runtime-toolchain.ts index 3b41d020f..1c679d76b 100644 --- a/packages/cli/src/commands/daemon/runtime-toolchain.ts +++ b/packages/cli/src/commands/daemon/runtime-toolchain.ts @@ -1,5 +1,5 @@ -import { spawnSync } from "node:child_process"; import { platform } from "node:os"; +import { execCommand } from "@getpaseo/server"; export interface NodePathFromPidResult { nodePath: string | null; @@ -13,63 +13,37 @@ function normalizeError(error: unknown): string { return String(error); } -function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult { - const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.error) { - return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` }; +async function resolveNodePathFromPidUnix(pid: number): Promise { + try { + const { stdout } = await execCommand("ps", ["-o", "comm=", "-p", String(pid)]); + const resolved = stdout.trim(); + return resolved + ? { nodePath: resolved } + : { nodePath: null, error: "ps returned an empty command path" }; + } catch (error) { + return { nodePath: null, error: `ps failed: ${normalizeError(error)}` }; } - - if ((result.status ?? 1) !== 0) { - const details = result.stderr?.trim(); - return { - nodePath: null, - error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`, - }; - } - - const resolved = result.stdout.trim(); - return resolved - ? { nodePath: resolved } - : { nodePath: null, error: "ps returned an empty command path" }; } -function runProcessProbe( +async function runProcessProbe( command: string, args: string[], -): { +): Promise<{ resolved: string | null; error?: string; -} { - const result = spawnSync(command, args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.error) { - return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` }; +}> { + try { + const { stdout } = await execCommand(command, args); + const resolved = stdout.trim(); + return resolved + ? { resolved } + : { resolved: null, error: `${command} returned no executable path` }; + } catch (error) { + return { resolved: null, error: `${command} failed: ${normalizeError(error)}` }; } - - if ((result.status ?? 1) !== 0) { - const details = result.stderr?.trim(); - return { - resolved: null, - error: details - ? `${command} failed: ${details}` - : `${command} exited with code ${result.status ?? 1}`, - }; - } - - const resolved = result.stdout.trim(); - return resolved - ? { resolved } - : { resolved: null, error: `${command} returned no executable path` }; } -function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { +async function resolveNodePathFromPidWindows(pid: number): Promise { const probes: Array<{ label: string; command: string; @@ -103,7 +77,7 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { const errors: string[] = []; for (const probe of probes) { - const result = runProcessProbe(probe.command, probe.args); + const result = await runProcessProbe(probe.command, probe.args); if (result.resolved) { const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved; if (resolved) { @@ -123,8 +97,8 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { }; } -export function resolveNodePathFromPid(pid: number): NodePathFromPidResult { +export async function resolveNodePathFromPid(pid: number): Promise { return platform() === "win32" - ? resolveNodePathFromPidWindows(pid) - : resolveNodePathFromPidUnix(pid); + ? await resolveNodePathFromPidWindows(pid) + : await resolveNodePathFromPidUnix(pid); } diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 49a582873..fa2b0f0fa 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,10 +1,11 @@ import type { Command } from "commander"; -import { execFile } from "node:child_process"; import { createRequire } from "node:module"; -import { promisify } from "node:util"; -import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server"; - -const execFileAsync = promisify(execFile); +import { + getOrCreateServerId, + findExecutable, + applyProviderEnv, + execCommand, +} from "@getpaseo/server"; import { tryConnectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; @@ -178,11 +179,9 @@ async function checkProviderBinary( } const env = applyProviderEnv(process.env); try { - const { stdout } = await execFileAsync(binaryPath, ["--version"], { - encoding: "utf8", + const { stdout } = await execCommand(binaryPath, ["--version"], { timeout: 5000, env, - windowsHide: true, }); return { path: binaryPath, version: stdout.trim() || null }; } catch { @@ -224,7 +223,7 @@ export async function runStatusCommand( if (!state.running) { daemonNode = "-"; } else if (state.pidInfo?.pid) { - const fromPid = resolveNodePathFromPid(state.pidInfo.pid); + const fromPid = await resolveNodePathFromPid(state.pidInfo.pid); daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; } else { daemonNode = "unknown (no PID available)"; diff --git a/packages/server/scripts/test-mcp-inject.ts b/packages/server/scripts/test-mcp-inject.ts index 49e9bb666..beccc1bd2 100644 --- a/packages/server/scripts/test-mcp-inject.ts +++ b/packages/server/scripts/test-mcp-inject.ts @@ -123,11 +123,13 @@ async function verifyInjectedMcpForProvider( } async function main(): Promise { - if (!isProviderAvailable("claude")) { + const claudeAvailable = await isProviderAvailable("claude"); + if (!claudeAvailable) { throw new Error( "Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.", ); } + const codexAvailable = await isProviderAvailable("codex"); const logger = pino({ level: "silent" }); const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-")); @@ -136,7 +138,7 @@ async function main(): Promise { const daemon = await createTestPaseoDaemon({ agentClients: { claude: new ClaudeAgentClient({ logger }), - ...(isProviderAvailable("codex") ? { codex: new CodexAppServerAgentClient(logger) } : {}), + ...(codexAvailable ? { codex: new CodexAppServerAgentClient(logger) } : {}), }, logger, }); @@ -159,7 +161,7 @@ async function main(): Promise { results.push(claudeResult); console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`); - if (isProviderAvailable("codex")) { + if (codexAvailable) { const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd); createdAgentIds.push(codexResult.agentId); results.push(codexResult); diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts index ec06a4ab8..70a3629b4 100644 --- a/packages/server/src/poc-commands/commands-poc.test.ts +++ b/packages/server/src/poc-commands/commands-poc.test.ts @@ -15,13 +15,12 @@ * This pattern is used in claude-agent.ts listModels(). */ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; -import { isCommandAvailableSync } from "../utils/executable.js"; +import { isCommandAvailable } from "../utils/executable.js"; const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; -const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; // Pattern from claude-agent.ts listModels(): // Use an empty async generator when you just need control methods @@ -30,88 +29,92 @@ function createEmptyPrompt(): AsyncGenerator { } describe("Claude Agent SDK Commands POC", () => { + let canRunClaudeIntegration = false; + + beforeAll(async () => { + canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; + }); + + beforeEach((context) => { + if (!canRunClaudeIntegration) { + context.skip(); + } + }); + describe("supportedCommands() API", () => { - test.runIf(canRunClaudeIntegration)( - "should return an array of SlashCommand objects", - async () => { - // Use the pattern from claude-agent.ts: - // Create a query with empty prompt generator for control methods - const emptyPrompt = createEmptyPrompt(); + test("should return an array of SlashCommand objects", async () => { + // Use the pattern from claude-agent.ts: + // Create a query with empty prompt generator for control methods + const emptyPrompt = createEmptyPrompt(); - const claudeQuery = query({ - prompt: emptyPrompt, - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], // Required to load skills - }, - }); + const claudeQuery = query({ + prompt: emptyPrompt, + options: { + cwd: process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + settingSources: ["user", "project"], // Required to load skills + }, + }); - try { - // supportedCommands() is a control method - works without iterating - const commands = await claudeQuery.supportedCommands(); + try { + // supportedCommands() is a control method - works without iterating + const commands = await claudeQuery.supportedCommands(); - // Should be an array - expect(Array.isArray(commands)).toBe(true); + // Should be an array + expect(Array.isArray(commands)).toBe(true); - // Verify structure - if (commands.length > 0) { - const firstCommand = commands[0]; - expect(typeof firstCommand.name).toBe("string"); - expect(typeof firstCommand.description).toBe("string"); - expect(typeof firstCommand.argumentHint).toBe("string"); - expect(firstCommand.name.startsWith("/")).toBe(false); - } - } finally { - if (typeof claudeQuery.return === "function") { - try { - await claudeQuery.return(); - } catch { - // ignore shutdown errors - } + // Verify structure + if (commands.length > 0) { + const firstCommand = commands[0]; + expect(typeof firstCommand.name).toBe("string"); + expect(typeof firstCommand.description).toBe("string"); + expect(typeof firstCommand.argumentHint).toBe("string"); + expect(firstCommand.name.startsWith("/")).toBe(false); + } + } finally { + if (typeof claudeQuery.return === "function") { + try { + await claudeQuery.return(); + } catch { + // ignore shutdown errors } } - }, - 30000, - ); + } + }, 30000); - test.runIf(canRunClaudeIntegration)( - "should have valid SlashCommand structure for all commands", - async () => { - const emptyPrompt = createEmptyPrompt(); + test("should have valid SlashCommand structure for all commands", async () => { + const emptyPrompt = createEmptyPrompt(); - const claudeQuery = query({ - prompt: emptyPrompt, - options: { - cwd: process.cwd(), - permissionMode: "plan", - settingSources: ["user", "project"], - }, - }); + const claudeQuery = query({ + prompt: emptyPrompt, + options: { + cwd: process.cwd(), + permissionMode: "plan", + settingSources: ["user", "project"], + }, + }); - try { - const commands = await claudeQuery.supportedCommands(); + try { + const commands = await claudeQuery.supportedCommands(); - expect(commands.length).toBeGreaterThan(0); + expect(commands.length).toBeGreaterThan(0); - // Verify all commands have valid structure - for (const cmd of commands) { - expect(cmd).toHaveProperty("name"); - expect(cmd).toHaveProperty("description"); - expect(cmd).toHaveProperty("argumentHint"); - expect(typeof cmd.name).toBe("string"); - expect(typeof cmd.description).toBe("string"); - expect(typeof cmd.argumentHint).toBe("string"); - expect(cmd.name.length).toBeGreaterThan(0); - expect(cmd.name.startsWith("/")).toBe(false); - } - } finally { - await claudeQuery.return?.(); + // Verify all commands have valid structure + for (const cmd of commands) { + expect(cmd).toHaveProperty("name"); + expect(cmd).toHaveProperty("description"); + expect(cmd).toHaveProperty("argumentHint"); + expect(typeof cmd.name).toBe("string"); + expect(typeof cmd.description).toBe("string"); + expect(typeof cmd.argumentHint).toBe("string"); + expect(cmd.name.length).toBeGreaterThan(0); + expect(cmd.name.startsWith("/")).toBe(false); } - }, - 30000, - ); + } finally { + await claudeQuery.return?.(); + } + }, 30000); }); describe("Command Execution", () => { diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts index 11bd1923d..2820cdcf7 100644 --- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts +++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts @@ -10,14 +10,14 @@ * CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment. * They are skipped automatically when credentials are unavailable. */ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../../utils/executable.js"; +import { isCommandAvailable } from "../../../../utils/executable.js"; import { ClaudeAgentClient } from "../claude-agent.js"; // --------------------------------------------------------------------------- @@ -28,7 +28,6 @@ const logger = pino({ level: "silent" }); const client = new ClaudeAgentClient({ logger }); const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; -const canRun = isCommandAvailableSync("claude") && hasClaudeCredentials; function tmpCwd(prefix: string): string { return mkdtempSync(path.join(tmpdir(), prefix)); @@ -194,332 +193,308 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[ // --------------------------------------------------------------------------- describe("Agent event stream redesign — integration", () => { - test.skipIf(!canRun)( - "Test 1: Basic foreground turn", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); + let canRun = false; - try { - const { turnId, events } = await startTurnAndCollectEvents( - handle.session, - "respond with just the word hello", - ); + beforeAll(async () => { + canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; + }); - const turnStarted = events.find( + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("Test 1: Basic foreground turn", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents( + handle.session, + "respond with just the word hello", + ); + + const turnStarted = events.find( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, + ); + expect(turnStarted).toBeDefined(); + + const terminal = events.find( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + expect(terminal).toBeDefined(); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("Test 2: No duplicate user_messages — THE BUG", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1); + + // No turn_started after terminal for the same turnId + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const staleTurnStarted = events + .slice(terminalIdx + 1) + .filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId); + expect(staleTurnStarted.length).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("Test 3: Lifecycle doesn't get stuck in running", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const afterTerminal = events.slice(terminalIdx + 1); + + // No subsequent turn_started for same turnId + expect( + afterTerminal.filter( (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, - ); - expect(turnStarted).toBeDefined(); + ).length, + ).toBe(0); - const terminal = events.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - expect(terminal).toBeDefined(); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); + // Any turn_started after terminal must have a different turnId + for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { + expect((ts as EventWithTurnId).turnId).not.toBe(turnId); } - }, - 60_000, - ); - test.skipIf(!canRun)( - "Test 2: No duplicate user_messages — THE BUG", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { - extraMs: 3_000, - }); + test("Test 4: Autonomous run", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); + const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1); + try { + const { turnId: fgTurnId, events } = await startTurnAndCollectEvents( + handle.session, + [ + "Use the Task tool to start a background sub-agent.", + "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", + "Do not wait for task completion.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + { + extraMs: 10_000, + timeoutMs: 60_000, + }, + ); - // No turn_started after terminal for the same turnId - const terminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - const staleTurnStarted = events - .slice(terminalIdx + 1) - .filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId); - expect(staleTurnStarted.length).toBe(0); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.skipIf(!canRun)( - "Test 3: Lifecycle doesn't get stuck in running", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); - - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { - extraMs: 3_000, - }); - - const terminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - const afterTerminal = events.slice(terminalIdx + 1); - - // No subsequent turn_started for same turnId - expect( - afterTerminal.filter( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, - ).length, - ).toBe(0); - - // Any turn_started after terminal must have a different turnId - for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { - expect((ts as EventWithTurnId).turnId).not.toBe(turnId); - } - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.skipIf(!canRun)( - "Test 4: Autonomous run", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); - const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - - try { - const { turnId: fgTurnId, events } = await startTurnAndCollectEvents( - handle.session, - [ - "Use the Task tool to start a background sub-agent.", - "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", - "Do not wait for task completion.", - "Reply immediately with exactly: SPAWNED", - `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - { - extraMs: 10_000, - timeoutMs: 60_000, - }, - ); - - const fgTerminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId, - ); - const afterForeground = events.slice(fgTerminalIdx + 1); - - // Autonomous turn_started with a different turnId - const autoStarts = afterForeground.filter( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, - ) as EventWithTurnId[]; - if (autoStarts.length === 0) { - assertInvariants(events, [fgTurnId]); - return; - } - - const autoTurnId = autoStarts[0]!.turnId; - expect(fgTurnId).not.toBe(autoTurnId); - - // Autonomous turn reaches terminal - expect( - afterForeground.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId, - ), - ).toBeDefined(); + const fgTerminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId, + ); + const afterForeground = events.slice(fgTerminalIdx + 1); + // Autonomous turn_started with a different turnId + const autoStarts = afterForeground.filter( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, + ) as EventWithTurnId[]; + if (autoStarts.length === 0) { assertInvariants(events, [fgTurnId]); - } finally { - await cleanupSession(handle); + return; } - }, - 90_000, - ); - test.skipIf(!canRun)( - "Test 5: Interruption", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); + const autoTurnId = autoStarts[0]!.turnId; + expect(fgTurnId).not.toBe(autoTurnId); - try { - let turnId: string | null = null; - const events = await new Promise((resolve, reject) => { - const collected: AgentStreamEvent[] = []; - let interrupted = false; + // Autonomous turn reaches terminal + expect( + afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId), + ).toBeDefined(); - const timeout = setTimeout(() => { + assertInvariants(events, [fgTurnId]); + } finally { + await cleanupSession(handle); + } + }, 90_000); + + test("Test 5: Interruption", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); + + try { + let turnId: string | null = null; + const events = await new Promise((resolve, reject) => { + const collected: AgentStreamEvent[] = []; + let interrupted = false; + + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out after 45000ms waiting for terminal event")); + }, 45_000); + + const unsubscribe = handle.session.subscribe((event) => { + collected.push(event); + if (!turnId && event.type === "turn_started" && hasTurnId(event)) { + turnId = event.turnId; + } + + // Once we see turn_started, fire the interrupt + if ( + !interrupted && + turnId && + event.type === "turn_started" && + hasTurnId(event) && + event.turnId === turnId + ) { + interrupted = true; + handle.session.interrupt().catch(() => undefined); + } + + // Resolve when we get a terminal event for this turn + if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) { + clearTimeout(timeout); unsubscribe(); - reject(new Error("Timed out after 45000ms waiting for terminal event")); - }, 45_000); + resolve(collected); + } + }); - const unsubscribe = handle.session.subscribe((event) => { - collected.push(event); - if (!turnId && event.type === "turn_started" && hasTurnId(event)) { - turnId = event.turnId; - } - - // Once we see turn_started, fire the interrupt - if ( - !interrupted && - turnId && - event.type === "turn_started" && - hasTurnId(event) && - event.turnId === turnId - ) { - interrupted = true; - handle.session.interrupt().catch(() => undefined); - } - - // Resolve when we get a terminal event for this turn - if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) { + void handle.session + .startTurn("write a very long essay about the history of computing") + .then((result) => { + if (turnId && turnId !== result.turnId) { clearTimeout(timeout); unsubscribe(); - resolve(collected); + reject( + new Error( + `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`, + ), + ); + return; } + turnId = result.turnId; + }) + .catch((error) => { + clearTimeout(timeout); + unsubscribe(); + reject(error); }); + }); - void handle.session - .startTurn("write a very long essay about the history of computing") - .then((result) => { - if (turnId && turnId !== result.turnId) { - clearTimeout(timeout); - unsubscribe(); - reject( - new Error( - `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`, - ), - ); - return; - } - turnId = result.turnId; - }) - .catch((error) => { - clearTimeout(timeout); - unsubscribe(); - reject(error); - }); - }); + expect(turnId).toBeDefined(); - expect(turnId).toBeDefined(); + // turn_canceled or turn_failed arrives for that turnId + const terminal = events.find( + (e) => + (e.type === "turn_canceled" || e.type === "turn_failed") && + hasTurnId(e) && + e.turnId === turnId, + ); + expect(terminal).toBeDefined(); - // turn_canceled or turn_failed arrives for that turnId - const terminal = events.find( - (e) => - (e.type === "turn_canceled" || e.type === "turn_failed") && - hasTurnId(e) && - e.turnId === turnId, - ); - expect(terminal).toBeDefined(); + // No further events for that turnId after terminal + const terminalIdx = events.indexOf(terminal!); + expect( + events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length, + ).toBe(0); - // No further events for that turnId after terminal - const terminalIdx = events.indexOf(terminal!); - expect( - events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length, - ).toBe(0); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + test("Test 6: Sequential foreground turns", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); - test.skipIf(!canRun)( - "Test 6: Sequential foreground turns", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); + try { + const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents( + handle.session, + "say first", + ); - try { - const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents( - handle.session, - "say first", - ); + const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents( + handle.session, + "say second", + ); - const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents( - handle.session, - "say second", - ); + const allEvents = [...events1, ...events2]; - const allEvents = [...events1, ...events2]; + expect(turnId1).not.toBe(turnId2); - expect(turnId1).not.toBe(turnId2); + // No events from turn 1 after turn 2 starts + const turn2StartIdx = allEvents.findIndex( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2, + ); + expect( + allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1) + .length, + ).toBe(0); - // No events from turn 1 after turn 2 starts - const turn2StartIdx = allEvents.findIndex( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2, - ); - expect( - allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1) - .length, - ).toBe(0); + assertInvariants(allEvents, [turnId1, turnId2]); + } finally { + await cleanupSession(handle); + } + }, 90_000); - assertInvariants(allEvents, [turnId1, turnId2]); - } finally { - await cleanupSession(handle); - } - }, - 90_000, - ); + test("Test 7: Fast-fail", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); - test.skipIf(!canRun)( - "Test 7: Fast-fail", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", { + extraMs: 3_000, + }); - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", { - extraMs: 3_000, - }); + // At most one turn_started + expect( + events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId) + .length, + ).toBeLessThanOrEqual(1); - // At most one turn_started - expect( - events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId) - .length, - ).toBeLessThanOrEqual(1); + // Terminal present + const terminal = events.find( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + expect(terminal).toBeDefined(); - // Terminal present - const terminal = events.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - expect(terminal).toBeDefined(); + // No stale turn_started after terminal + const terminalIdx = events.indexOf(terminal!); + expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0); - // No stale turn_started after terminal - const terminalIdx = events.indexOf(terminal!); - expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe( - 0, - ); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + test("Test 8: User message dedup by text", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); - test.skipIf(!canRun)( - "Test 8: User message dedup by text", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", { + extraMs: 3_000, + }); - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", { - extraMs: 3_000, - }); + expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1); - expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); }); diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts index 6bdbe3a14..8feb0c95e 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts @@ -1,14 +1,26 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentSlashCommand } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; // Real-Claude contract coverage: validates slash command shape from a live Claude CLI session. describe("claude agent commands contract (real)", () => { + let canRun = false; + + beforeAll(async () => { + canRun = await isCommandAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + test("lists slash commands with the expected contract", async () => { - expect(isCommandAvailableSync("claude")).toBe(true); + expect(await isCommandAvailable("claude")).toBe(true); const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }), diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index 74d25c9fe..264d09960 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, beforeAll } from "vitest"; +import { describe, expect, test, beforeAll, beforeEach } from "vitest"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -6,7 +6,7 @@ import pino from "pino"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -174,111 +174,106 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P } describe("ClaudeAgentSession integration", () => { - const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; + let canRunClaudeIntegration = false; - beforeAll(() => { + beforeAll(async () => { + canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; if (canRunClaudeIntegration) { - expect(isCommandAvailableSync("claude")).toBe(true); + expect(await isCommandAvailable("claude")).toBe(true); } }); - test.runIf(canRunClaudeIntegration)( - "streams a basic response turn end-to-end", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-basic-response-", + beforeEach((context) => { + if (!canRunClaudeIntegration) { + context.skip(); + } + }); + + test("streams a basic response turn end-to-end", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-basic-response-", + }); + + try { + const events = await collectUntilTerminal( + streamSession(handle.session, "Respond with exactly: HELLO_WORLD"), + ); + + expect(events[0]).toMatchObject({ + type: "turn_started", + provider: "claude", }); - - try { - const events = await collectUntilTerminal( - streamSession(handle.session, "Respond with exactly: HELLO_WORLD"), - ); - - expect(events[0]).toMatchObject({ - type: "turn_started", - provider: "claude", - }); - expect( - events.some( - (event) => - event.type === "timeline" && - event.item.type === "assistant_message" && - compactText(event.item.text).includes("hello_world"), - ), - ).toBe(true); - expect(events.at(-1)).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.runIf(canRunClaudeIntegration)( - "keeps bypassPermissions available after a thinking-option restart", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-bypass-restart-", - modeId: "bypassPermissions", + expect( + events.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + compactText(event.item.text).includes("hello_world"), + ), + ).toBe(true); + expect(events.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "claude", }); + } finally { + await cleanupSession(handle); + } + }, 60_000); - try { - await handle.session.setMode("acceptEdits"); - await handle.session.setThinkingOption("high"); - await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined(); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + test("keeps bypassPermissions available after a thinking-option restart", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-bypass-restart-", + modeId: "bypassPermissions", + }); - test.runIf(canRunClaudeIntegration)( - "supportedModels returns the current abstract Claude SDK model shape", - async () => { - const claudeQuery = query({ - prompt: createEmptyPrompt(), - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], - }, - }); + try { + await handle.session.setMode("acceptEdits"); + await handle.session.setThinkingOption("high"); + await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined(); + } finally { + await cleanupSession(handle); + } + }, 60_000); - try { - const models = await claudeQuery.supportedModels(); + test("supportedModels returns the current abstract Claude SDK model shape", async () => { + const claudeQuery = query({ + prompt: createEmptyPrompt(), + options: { + cwd: process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + settingSources: ["user", "project"], + }, + }); - expect(models.length).toBeGreaterThanOrEqual(3); - expect(models).toContainEqual( - expect.objectContaining({ - value: "default", - displayName: "Default (recommended)", - supportedEffortLevels: ["low", "medium", "high", "max"], - }), - ); - expect(models).toContainEqual( - expect.objectContaining({ - value: "haiku", - displayName: "Haiku", - description: expect.stringContaining("Haiku 4.5"), - }), - ); - expect( - models.some( - (model) => - model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"), - ), - ).toBe(true); - } finally { - await claudeQuery.return?.(); - } - }, - 60_000, - ); + try { + const models = await claudeQuery.supportedModels(); + + expect(models.length).toBeGreaterThanOrEqual(3); + expect(models).toContainEqual( + expect.objectContaining({ + value: "default", + displayName: "Default (recommended)", + supportedEffortLevels: ["low", "medium", "high", "max"], + }), + ); + expect(models).toContainEqual( + expect.objectContaining({ + value: "haiku", + displayName: "Haiku", + description: expect.stringContaining("Haiku 4.5"), + }), + ); + expect( + models.some( + (model) => + model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"), + ), + ).toBe(true); + } finally { + await claudeQuery.return?.(); + } + }, 60_000); test.runIf(canRunClaudeIntegration)( "runs a real Bash tool call and completes it", diff --git a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts index 47f0ea9fc..1cf02a848 100644 --- a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -29,35 +29,43 @@ async function collectUntilTerminal(session: AgentSession): Promise { - test.runIf(isCommandAvailableSync("claude") && hasClaudeCredentials)( - "surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", - async () => { - const client = new ClaudeAgentClient({ - logger: pino({ level: "silent" }), - }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - modeId: "bypassPermissions", - model: "claude-opus-4-6", - thinkingOptionId: "max", - }); + let canRun = false; - try { - const events = await collectUntilTerminal(session); - const failure = events.find( - (event): event is Extract => - event.type === "turn_failed", - ); + beforeAll(async () => { + canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; + }); - expect(failure).toBeDefined(); - expect(failure?.error).toContain("Claude Code process exited with code 1"); - expect(failure?.code).toBe("1"); - expect(failure?.diagnostic).toContain('Effort level "max" is not available'); - } finally { - await session.close().catch(() => undefined); - } - }, - 30_000, - ); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", async () => { + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "bypassPermissions", + model: "claude-opus-4-6", + thinkingOptionId: "max", + }); + + try { + const events = await collectUntilTerminal(session); + const failure = events.find( + (event): event is Extract => + event.type === "turn_failed", + ); + + expect(failure).toBeDefined(); + expect(failure?.error).toContain("Claude Code process exited with code 1"); + expect(failure?.code).toBe("1"); + expect(failure?.diagnostic).toContain('Effort level "max" is not available'); + } finally { + await session.close().catch(() => undefined); + } + }, 30_000); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 935583581..897e5a432 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,5 +1,4 @@ -import { execFile, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { promisify } from "node:util"; +import { type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -73,12 +72,11 @@ import type { PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { findExecutable } from "../../../utils/executable.js"; -import { spawnProcess } from "../../../utils/spawn.js"; +import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { execCommand, spawnProcess } from "../../../utils/spawn.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; const fsPromises = promises; -const execFileAsync = promisify(execFile); const CLAUDE_SETTING_SOURCES: NonNullable = ["user", "project"]; type TurnState = "idle" | "foreground" | "autonomous"; @@ -1130,7 +1128,7 @@ export class ClaudeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return fs.existsSync(command.argv[0]); + return executableExists(command.argv[0]) !== null; } return true; } @@ -1186,14 +1184,10 @@ async function resolveClaudeVersion( try { if (command?.mode === "replace") { - const { stdout } = await execFileAsync( + const { stdout } = await execCommand( command.argv[0]!, [...command.argv.slice(1), "--version"], - { - encoding: "utf8", - timeout: 5_000, - windowsHide: true, - }, + { timeout: 5_000 }, ); return stdout.trim() || null; } @@ -1203,11 +1197,7 @@ async function resolveClaudeVersion( return null; } - const { stdout } = await execFileAsync(executable, ["--version"], { - encoding: "utf8", - timeout: 5_000, - windowsHide: true, - }); + const { stdout } = await execCommand(executable, ["--version"], { timeout: 5_000 }); return stdout.trim() || null; } catch { return null; diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts index df2f2bcfe..4665c6bd1 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts @@ -4,9 +4,9 @@ import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; -import { findExecutable, isCommandAvailableSync } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; class Pushable implements AsyncIterable { private queue: T[] = []; @@ -57,100 +57,103 @@ const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; describe("Claude SDK direct behavior", () => { - const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; + let canRunClaudeIntegration = false; - beforeAll(() => { + beforeAll(async () => { + canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; if (canRunClaudeIntegration) { - expect(isCommandAvailableSync("claude")).toBe(true); + expect(await isCommandAvailable("claude")).toBe(true); } }); - test.runIf(canRunClaudeIntegration)( - "shows what happens after interrupt()", - async () => { - const cwd = tmpCwd(); - const input = new Pushable(); - const claudeBinary = await findExecutable("claude"); + beforeEach((context) => { + if (!canRunClaudeIntegration) { + context.skip(); + } + }); - // Use same options as claude-agent.ts - const q = query({ - prompt: input, - options: { - cwd, - includePartialMessages: true, - permissionMode: "bypassPermissions", - ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), - systemPrompt: { - type: "preset", - preset: "claude_code", - }, - settingSources: ["user", "project"], + test("shows what happens after interrupt()", async () => { + const cwd = tmpCwd(); + const input = new Pushable(); + const claudeBinary = await findExecutable("claude"); + + // Use same options as claude-agent.ts + const q = query({ + prompt: input, + options: { + cwd, + includePartialMessages: true, + permissionMode: "bypassPermissions", + ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), + systemPrompt: { + type: "preset", + preset: "claude_code", }, + settingSources: ["user", "project"], + }, + }); + + try { + // Send first message + input.push({ + type: "user", + message: { role: "user", content: "Say exactly: MESSAGE_ONE" }, + parent_tool_use_id: null, + session_id: "", }); - try { - // Send first message - input.push({ - type: "user", - message: { role: "user", content: "Say exactly: MESSAGE_ONE" }, - parent_tool_use_id: null, - session_id: "", - }); + // Collect events until we see assistant, then interrupt + const msg1Events: SDKMessage[] = []; + for await (const event of q) { + msg1Events.push(event); - // Collect events until we see assistant, then interrupt - const msg1Events: SDKMessage[] = []; - for await (const event of q) { - msg1Events.push(event); - - if (event.type === "assistant") { - // Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in) - input.push({ - type: "user", - message: { role: "user", content: "Say exactly: MESSAGE_TWO" }, - parent_tool_use_id: null, - session_id: "", - }); - await q.interrupt(); - break; - } - if (event.type === "result") { - break; - } + if (event.type === "assistant") { + // Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in) + input.push({ + type: "user", + message: { role: "user", content: "Say exactly: MESSAGE_TWO" }, + parent_tool_use_id: null, + session_id: "", + }); + await q.interrupt(); + break; } - - // MSG2 was already pushed before interrupt - const msg2Events: SDKMessage[] = []; - for await (const event of q) { - msg2Events.push(event); - - if (event.type === "result") { - break; - } + if (event.type === "result") { + break; } + } - // Analyze response - let responseText = ""; - for (const event of msg2Events) { - if (event.type === "assistant" && "message" in event && event.message?.content) { - const content = event.message.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - responseText += block.text; - } + // MSG2 was already pushed before interrupt + const msg2Events: SDKMessage[] = []; + for await (const event of q) { + msg2Events.push(event); + + if (event.type === "result") { + break; + } + } + + // Analyze response + let responseText = ""; + for (const event of msg2Events) { + if (event.type === "assistant" && "message" in event && event.message?.content) { + const content = event.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + responseText += block.text; } } } } - - const sawResult = msg2Events.some((event) => event.type === "result"); - // The SDK may short-circuit after interrupt without a result event. - expect(sawResult || responseText.length === 0).toBe(true); - } finally { - input.end(); - rmSync(cwd, { recursive: true, force: true }); } - }, - 120000, - ); + + const sawResult = msg2Events.some((event) => event.type === "result"); + // The SDK may short-circuit after interrupt without a result event. + expect(sawResult || responseText.length === 0).toBe(true); + } finally { + input.end(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 120000); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 13c32633f..ae793a462 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -31,7 +31,7 @@ import type { Logger } from "pino"; import { execSync } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync, Dirent } from "node:fs"; +import { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -47,7 +47,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { findExecutable } from "../../../utils/executable.js"; +import { executableExists, findExecutable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; @@ -4203,7 +4203,7 @@ export class CodexAppServerAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return existsSync(command.argv[0]); + return executableExists(command.argv[0]) !== null; } return true; } diff --git a/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts index 1846281b6..dd87d7137 100644 --- a/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -12,53 +12,61 @@ function tmpCwd(): string { } describe("Codex app-server provider (real) plan mode", () => { - test.runIf(isProviderAvailable("codex"))( - "maps gpt-5.4 markdown plans to a plan tool call instead of todo items", - async () => { - const cwd = tmpCwd(); - const client = new CodexAppServerAgentClient(createTestLogger()); + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("codex"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => { + const cwd = tmpCwd(); + const client = new CodexAppServerAgentClient(createTestLogger()); + + try { + const session = await client.createSession({ + provider: "codex", + cwd, + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "medium", + }); try { - const session = await client.createSession({ - provider: "codex", - cwd, - modeId: "auto", - model: "gpt-5.4", - thinkingOptionId: "medium", - }); + await session.setFeature?.("plan_mode", true); - try { - await session.setFeature?.("plan_mode", true); + const result = await session.run( + "You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.", + ); - const result = await session.run( - "You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.", - ); + expect(result.timeline).not.toContainEqual( + expect.objectContaining({ + type: "todo", + }), + ); - expect(result.timeline).not.toContainEqual( - expect.objectContaining({ - type: "todo", - }), - ); + const planCall = result.timeline.find( + (item) => item.type === "tool_call" && item.detail.type === "plan", + ); - const planCall = result.timeline.find( - (item) => item.type === "tool_call" && item.detail.type === "plan", - ); - - expect(planCall).toBeDefined(); - if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") { - throw new Error("Expected a plan tool call"); - } - - expect(planCall.detail.text).toContain("Login"); - expect(planCall.detail.text).toContain("- "); - expect(result.finalText).toBe(planCall.detail.text); - } finally { - await session.close(); + expect(planCall).toBeDefined(); + if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") { + throw new Error("Expected a plan tool call"); } + + expect(planCall.detail.text).toContain("Login"); + expect(planCall.detail.text).toContain("- "); + expect(result.finalText).toBe(planCall.detail.text); } finally { - rmSync(cwd, { recursive: true, force: true }); + await session.close(); } - }, - 240_000, - ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 240_000); }); diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts index ed12c63ee..a21f54bea 100644 --- a/packages/server/src/server/agent/providers/diagnostic-utils.ts +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -1,9 +1,5 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; - -const execFileAsync = promisify(execFile); +import { execCommand } from "../../../utils/spawn.js"; type DiagnosticEntry = { label: string; @@ -49,11 +45,7 @@ export function toDiagnosticErrorMessage(error: unknown): string { export async function resolveBinaryVersion(binaryPath: string): Promise { try { - const { stdout } = await execFileAsync(binaryPath, ["--version"], { - encoding: "utf8", - timeout: 5_000, - windowsHide: true, - }); + const { stdout } = await execCommand(binaryPath, ["--version"], { timeout: 5_000 }); return stdout.trim() || "unknown"; } catch { return "unknown"; diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts index 3ee10d777..c54264978 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts @@ -1,13 +1,25 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentSlashCommand } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; describe("opencode agent commands contract (real)", () => { + let canRun = false; + + beforeAll(async () => { + canRun = await isCommandAvailable("opencode"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + test("lists slash commands with the expected contract", async () => { - expect(isCommandAvailableSync("opencode")).toBe(true); + expect(await isCommandAvailable("opencode")).toBe(true); const client = new OpenCodeAgentClient(pino({ level: "silent" })); const session = await client.createSession({ @@ -37,7 +49,7 @@ describe("opencode agent commands contract (real)", () => { }, 60_000); test("executes a slash command without arguments", async () => { - expect(isCommandAvailableSync("opencode")).toBe(true); + expect(await isCommandAvailable("opencode")).toBe(true); const client = new OpenCodeAgentClient(pino({ level: "silent" })); const session = await client.createSession({ diff --git a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts index 0f944b0c2..3874568c2 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -21,166 +21,158 @@ function isTerminalEvent(event: AgentStreamEvent): boolean { * when models are invalid, auth fails, or provider API calls fail. */ describe("opencode agent error handling (real)", () => { - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces error for the exact opencode/ path used by paseo run", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); + let canRun = false; - try { - await session.setModel("opencode/adklasldkdas"); + beforeAll(async () => { + canRun = await isCommandAvailable("opencode"); + }); - const events: AgentStreamEvent[] = []; - for await (const event of streamSession(session, "hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(terminal!.type).toBe("turn_failed"); - } finally { - await session.close().catch(() => undefined); - } - }, - 45_000, - ); + test("surfaces error for the exact opencode/ path used by paseo run", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces error for unknown provider model (fast path)", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); - - try { - await session.setModel("bogus-provider/totally-fake-model-12345"); - - const events: AgentStreamEvent[] = []; - for await (const event of streamSession(session, "Say hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } - - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(terminal!.type).toBe("turn_failed"); - } finally { - await session.close().catch(() => undefined); - } - }, - 30_000, - ); - - test.runIf(isCommandAvailableSync("opencode"))( - "sequential sessions: second session works after first errors", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - - // Session 1: bogus model, will error quickly - const s1 = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); - await s1.setModel("bogus-provider/fake-model-12345"); - for await (const event of streamSession(s1, "Say hello")) { - if (isTerminalEvent(event)) break; - } - await s1.close(); - - // Session 2: different bogus model, should also work - const s2 = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); - await s2.setModel("bogus-provider/fake-model-67890"); + try { + await session.setModel("opencode/adklasldkdas"); const events: AgentStreamEvent[] = []; - for await (const event of streamSession(s2, "Say hello")) { + for await (const event of streamSession(session, "hello")) { events.push(event); if (isTerminalEvent(event)) break; } - await s2.close().catch(() => undefined); const terminal = events.find(isTerminalEvent); expect(terminal).toBeDefined(); expect(terminal!.type).toBe("turn_failed"); - }, - 30_000, - ); + } finally { + await session.close().catch(() => undefined); + } + }, 45_000); - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces error for known provider with nonexistent model (retry path)", - async () => { - // When the provider is recognized (anthropic) but the model doesn't exist, - // OpenCode retries before surfacing the error. This must not hang. - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); + test("surfaces error for unknown provider model (fast path)", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); - try { - await session.setModel("anthropic/claude-nonexistent-99"); + try { + await session.setModel("bogus-provider/totally-fake-model-12345"); - const events: AgentStreamEvent[] = []; - const start = Date.now(); - for await (const event of streamSession(session, "Say hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } - const elapsed = Date.now() - start; - - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(elapsed).toBeLessThan(30_000); - console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`); - } finally { - await session.close().catch(() => undefined); + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; } - }, - 45_000, - ); - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + } finally { + await session.close().catch(() => undefined); + } + }, 30_000); - try { - await session.setModel("zai/glm-5.1"); + test("sequential sessions: second session works after first errors", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const events: AgentStreamEvent[] = []; - for await (const event of streamSession(session, "Say hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } + // Session 1: bogus model, will error quickly + const s1 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s1.setModel("bogus-provider/fake-model-12345"); + for await (const event of streamSession(s1, "Say hello")) { + if (isTerminalEvent(event)) break; + } + await s1.close(); - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(terminal!.type).toBe("turn_failed"); - expect((terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase()).toMatch( - /insufficient balance|resource package|recharge/, - ); - } finally { - await session.close().catch(() => undefined); + // Session 2: different bogus model, should also work + const s2 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s2.setModel("bogus-provider/fake-model-67890"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(s2, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + await s2.close().catch(() => undefined); + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + }, 30_000); + + test("surfaces error for known provider with nonexistent model (retry path)", async () => { + // When the provider is recognized (anthropic) but the model doesn't exist, + // OpenCode retries before surfacing the error. This must not hang. + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("anthropic/claude-nonexistent-99"); + + const events: AgentStreamEvent[] = []; + const start = Date.now(); + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; } - }, - 45_000, - ); + const elapsed = Date.now() - start; + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(elapsed).toBeLessThan(30_000); + console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`); + } finally { + await session.close().catch(() => undefined); + } + }, 45_000); + + test("surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("zai/glm-5.1"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + expect((terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase()).toMatch( + /insufficient balance|resource package|recharge/, + ); + } finally { + await session.close().catch(() => undefined); + } + }, 45_000); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6675b45ee..9d2076cd1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,5 +1,4 @@ import type { ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, @@ -44,7 +43,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { findExecutable } from "../../../utils/executable.js"; +import { executableExists, findExecutable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { @@ -917,7 +916,7 @@ export class OpenCodeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return existsSync(command.argv[0]); + return executableExists(command.argv[0]) !== null; } return true; } diff --git a/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts index f13fedf11..cdb79a40e 100644 --- a/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -9,62 +9,66 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; describe("OpenCode assistant message", () => { - test.runIf(isProviderAvailable("opencode"))( - "assistant_message appears in live stream with opencode/big-pickle", - async () => { - const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-")); - const logger = pino({ level: "silent" }); - const client = new OpenCodeAgentClient(logger); + let canRun = false; - try { - const session = await client.createSession({ - provider: "opencode", - cwd, - model: "opencode/big-pickle", - modeId: "build", - }); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const result = await session.run("Say hello back in one sentence."); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const assistantItems = result.timeline.filter((item) => item.type === "assistant_message"); - expect(assistantItems.length).toBeGreaterThan(0); - expect(result.finalText.length).toBeGreaterThan(0); - } finally { - rmSync(cwd, { recursive: true, force: true }); + test("assistant_message appears in live stream with opencode/big-pickle", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); + + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "opencode/big-pickle", + modeId: "build", + }); + + const result = await session.run("Say hello back in one sentence."); + + const assistantItems = result.timeline.filter((item) => item.type === "assistant_message"); + expect(assistantItems.length).toBeGreaterThan(0); + expect(result.finalText.length).toBeGreaterThan(0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); + + test("streamHistory returns assistant_message after a completed turn", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); + + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + }); + + const result = await session.run("Say hello back in one sentence."); + expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true); + + const historyEvents: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + historyEvents.push(event); } - }, - 60_000, - ); - test.runIf(isProviderAvailable("opencode"))( - "streamHistory returns assistant_message after a completed turn", - async () => { - const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-")); - const logger = pino({ level: "silent" }); - const client = new OpenCodeAgentClient(logger); - - try { - const session = await client.createSession({ - provider: "opencode", - cwd, - }); - - const result = await session.run("Say hello back in one sentence."); - expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true); - - const historyEvents: AgentStreamEvent[] = []; - for await (const event of session.streamHistory()) { - historyEvents.push(event); - } - - const historyAssistant = historyEvents.filter( - (e) => e.type === "timeline" && e.item.type === "assistant_message", - ); - expect(historyAssistant.length).toBeGreaterThan(0); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - 60_000, - ); + const historyAssistant = historyEvents.filter( + (e) => e.type === "timeline" && e.item.type === "assistant_message", + ); + expect(historyAssistant.length).toBeGreaterThan(0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts index b99030046..02cf2c224 100644 --- a/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -9,60 +9,68 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; describe("OpenCode reasoning dedup", () => { - test.runIf(isProviderAvailable("opencode"))( - "reasoning content is not duplicated as assistant_message", - async () => { - const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-")); - const logger = pino({ level: "silent" }); - const client = new OpenCodeAgentClient(logger); + let canRun = false; - try { - const session = await client.createSession({ - provider: "opencode", - cwd, - model: "opencode/gpt-5-nano", - modeId: "build", - }); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const streamedEvents: AgentStreamEvent[] = []; - session.subscribe((event) => { - streamedEvents.push(event); - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const result = await session.run("What is 2+2? Think step by step."); + test("reasoning content is not duplicated as assistant_message", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); - const reasoningTexts: string[] = []; - const assistantTexts: string[] = []; + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "opencode/gpt-5-nano", + modeId: "build", + }); - for (const event of streamedEvents) { - if (event.type === "timeline") { - if (event.item.type === "reasoning") { - reasoningTexts.push(event.item.text); - } else if (event.item.type === "assistant_message") { - assistantTexts.push(event.item.text); - } + const streamedEvents: AgentStreamEvent[] = []; + session.subscribe((event) => { + streamedEvents.push(event); + }); + + const result = await session.run("What is 2+2? Think step by step."); + + const reasoningTexts: string[] = []; + const assistantTexts: string[] = []; + + for (const event of streamedEvents) { + if (event.type === "timeline") { + if (event.item.type === "reasoning") { + reasoningTexts.push(event.item.text); + } else if (event.item.type === "assistant_message") { + assistantTexts.push(event.item.text); } } - - const fullReasoningText = reasoningTexts.join(""); - const fullAssistantText = assistantTexts.join(""); - - // The model should produce reasoning - expect(reasoningTexts.length).toBeGreaterThan(0); - expect(fullReasoningText.length).toBeGreaterThan(0); - - // The assistant text should be the response, not the reasoning - expect(assistantTexts.length).toBeGreaterThan(0); - - // Reasoning text must NOT appear in the assistant text - const reasoningPrefix = fullReasoningText.slice(0, 50); - if (reasoningPrefix.length > 10) { - expect(fullAssistantText).not.toContain(reasoningPrefix); - } - } finally { - rmSync(cwd, { recursive: true, force: true }); } - }, - 120_000, - ); + + const fullReasoningText = reasoningTexts.join(""); + const fullAssistantText = assistantTexts.join(""); + + // The model should produce reasoning + expect(reasoningTexts.length).toBeGreaterThan(0); + expect(fullReasoningText.length).toBeGreaterThan(0); + + // The assistant text should be the response, not the reasoning + expect(assistantTexts.length).toBeGreaterThan(0); + + // Reasoning text must NOT appear in the assistant text + const reasoningPrefix = fullReasoningText.slice(0, 50); + if (reasoningPrefix.length > 10) { + expect(fullAssistantText).not.toContain(reasoningPrefix); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 120_000); }); diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index ca66c17ae..a8e1d0245 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -7,9 +7,8 @@ import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; import dotenv from "dotenv"; -import { isCommandAvailableSync } from "../../utils/executable.js"; +import { isCommandAvailable } from "../../utils/executable.js"; -// Load .env.test eagerly so isProviderAvailable() has credentials at collection time. const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true }); @@ -64,6 +63,7 @@ export const agentConfigs = { } as const satisfies Record; export type AgentProvider = keyof typeof agentConfigs; +const providerAvailabilityCache = new Map>(); /** * Get test config for creating an agent with full permissions (no prompts). @@ -101,31 +101,42 @@ export function getAskModeConfig(provider: AgentProvider) { * This MUST be a function (not a const) so process.env is read at call time, * after dotenv has injected the test credentials. */ -export function isProviderAvailable(provider: AgentProvider): boolean { - switch (provider) { - case "claude": - return ( - isCommandAvailableSync("claude") && - (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) - ); - case "codex": - return ( - isCommandAvailableSync("codex") && - (existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY)) - ); - case "copilot": - return isCommandAvailableSync("copilot"); - case "opencode": - return isCommandAvailableSync("opencode"); - case "pi": - return ( - isCommandAvailableSync(process.env.PI_ACP_PI_COMMAND ?? "pi") && - (Boolean(process.env.OPENAI_API_KEY) || - Boolean(process.env.ANTHROPIC_API_KEY) || - Boolean(process.env.OPENROUTER_API_KEY) || - existsSync(join(homedir(), ".pi", "agent", "auth.json"))) - ); +export function isProviderAvailable(provider: AgentProvider): Promise { + const cached = providerAvailabilityCache.get(provider); + if (cached) { + return cached; } + + const availability = (async (): Promise => { + switch (provider) { + case "claude": + return ( + (await isCommandAvailable("claude")) && + (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) + ); + case "codex": + return ( + (await isCommandAvailable("codex")) && + (existsSync(join(homedir(), ".codex", "auth.json")) || + Boolean(process.env.OPENAI_API_KEY)) + ); + case "copilot": + return await isCommandAvailable("copilot"); + case "opencode": + return await isCommandAvailable("opencode"); + case "pi": + return ( + (await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) && + (Boolean(process.env.OPENAI_API_KEY) || + Boolean(process.env.ANTHROPIC_API_KEY) || + Boolean(process.env.OPENROUTER_API_KEY) || + existsSync(join(homedir(), ".pi", "agent", "auth.json"))) + ); + } + })(); + + providerAvailabilityCache.set(provider, availability); + return availability; } /** diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts index 6dd2fe622..465cd59a8 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -18,98 +18,106 @@ function compactText(value: string): string { } describe("daemon E2E (real claude) - autonomous wake simple", () => { - test.runIf(isProviderAvailable("claude"))( - "hello + background sleep returns idle, then wakes once on completion", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("hello + background sleep returns idle, then wakes once on completion", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-simple-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-simple-real" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-simple-real", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-simple-real", - ...getFullAccessConfig("claude"), - }); + const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`; + await client.sendMessage( + agent.id, + [ + "Hello.", + "Use the Bash tool with run_in_background.", + "Run exactly: sleep 5", + "Do not wait for the task result.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + ); - const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`; - await client.sendMessage( - agent.id, - [ - "Hello.", - "Use the Bash tool with run_in_background.", - "Run exactly: sleep 5", - "Do not wait for the task result.", - "Reply immediately with exactly: SPAWNED", - `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - ); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); + const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const idleAssistantText = timelineAtIdle.entries + .filter( + ( + entry, + ): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); + expect(compactText(idleAssistantText)).toContain("spawned"); + expect( + timelineAtIdle.entries.some( + (entry) => entry.item.type === "tool_call" && entry.item.name === "Bash", + ), + ).toBe(true); - const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const idleAssistantText = timelineAtIdle.entries - .filter( - ( - entry, - ): entry is typeof entry & { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => entry.item.text) - .join("\n"); - expect(compactText(idleAssistantText)).toContain("spawned"); - expect( - timelineAtIdle.entries.some( - (entry) => entry.item.type === "tool_call" && entry.item.name === "Bash", - ), - ).toBe(true); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); + const autonomousFinish = await client.waitForFinish(agent.id, 120_000); + expect(autonomousFinish.status).toBe("idle"); - const autonomousFinish = await client.waitForFinish(agent.id, 120_000); - expect(autonomousFinish.status).toBe("idle"); - - const finalTimeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const finalAssistantText = finalTimeline.entries - .filter( - ( - entry, - ): entry is typeof entry & { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => entry.item.text) - .join("\n"); - expect(compactText(finalAssistantText)).toContain(autonomousWakeToken.toLowerCase()); - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); + const finalTimeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const finalAssistantText = finalTimeline.entries + .filter( + ( + entry, + ): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); + expect(compactText(finalAssistantText)).toContain(autonomousWakeToken.toLowerCase()); + } finally { + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); }); diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index 1f6379e8d..b6675c5a4 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -353,452 +353,339 @@ function summarizeTimelineEntry(entry: { } describe("daemon E2E (real claude) - autonomous wake from background task", () => { - test.runIf(isProviderAvailable("claude"))( - "A: background sleep returns idle, then wakes autonomously and appends timeline activity", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("A: background sleep returns idle, then wakes autonomously and appends timeline activity", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-abc-a" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-a" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-abc-a", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-abc-a", - ...getFullAccessConfig("claude"), - }); + const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; + await client.sendMessage( + agent.id, + [ + BACKGROUND_TASK_SLEEP_5_PROMPT, + `When you later receive the task completion notification, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + ); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - await client.sendMessage( - agent.id, - [ - BACKGROUND_TASK_SLEEP_5_PROMPT, - `When you later receive the task completion notification, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - ); - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); + const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); - const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + + const autonomousFinish = await client.waitForFinish(agent.id, 120_000); + expect(autonomousFinish.status).toBe("idle"); + + const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( + timelineAtIdle.entries.length, + ); + let sawTimelineGrowth = timelineAfterWake.entries.length > timelineAtIdle.entries.length; + const growthDeadline = Date.now() + 20_000; + while (!sawTimelineGrowth && Date.now() < growthDeadline) { + await sleep(250); + const nextTimeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, projection: "canonical", }); + sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length; + } + expect(sawTimelineGrowth).toBe(true); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); + test("B: immediate HELLO before task notification returns promptly without deadlock", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-abc-b" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-abc-b", + ...getFullAccessConfig("claude"), + }); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const kickoffFinish = await client.waitForFinish(agent.id, 240_000); + expect(kickoffFinish.status).toBe("idle"); + + await client.sendMessage(agent.id, "say exactly HELLO"); + const helloFinish = await client.waitForFinish(agent.id, 180_000); + expect(helloFinish.status).toBe("idle"); + expect((helloFinish.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); + + // The background task may complete before, during, or after the HELLO + // turn. When the task_notification races with HELLO, the notification + // is handled during the foreground turn and there is no separate + // autonomous running edge afterwards. Wait for a possible autonomous + // wake; if none arrives within the expected sleep window, verify the + // agent settled to idle (notification was already processed). + const autonomousWake = await client + .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) + .catch(() => null); + + if (autonomousWake) { const autonomousFinish = await client.waitForFinish(agent.id, 120_000); expect(autonomousFinish.status).toBe("idle"); - - const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( - timelineAtIdle.entries.length, - ); - let sawTimelineGrowth = timelineAfterWake.entries.length > timelineAtIdle.entries.length; - const growthDeadline = Date.now() + 20_000; - while (!sawTimelineGrowth && Date.now() < growthDeadline) { - await sleep(250); - const nextTimeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length; - } - expect(sawTimelineGrowth).toBe(true); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + } else { + const current = await client.fetchAgent(agent.id); + expect(current.agent.status).toBe("idle"); } - }, - 420_000, - ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); - test.runIf(isProviderAvailable("claude"))( - "B: immediate HELLO before task notification returns promptly without deadlock", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + test("C: interrupt during overlap returns quickly and does not leave agent stuck running", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-abc-c" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-b" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-abc-c", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-abc-b", - ...getFullAccessConfig("claude"), - }); + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const firstKickoff = await client.waitForFinish(agent.id, 240_000); + expect(firstKickoff.status).toBe("idle"); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + const firstAutonomousFinish = await client.waitForFinish(agent.id, 120_000); + expect(firstAutonomousFinish.status).toBe("idle"); - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const kickoffFinish = await client.waitForFinish(agent.id, 240_000); - expect(kickoffFinish.status).toBe("idle"); + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); + const secondKickoff = await client.waitForFinish(agent.id, 240_000); + expect(secondKickoff.status).toBe("idle"); - await client.sendMessage(agent.id, "say exactly HELLO"); - const helloFinish = await client.waitForFinish(agent.id, 180_000); - expect(helloFinish.status).toBe("idle"); - expect((helloFinish.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); + await client.sendMessage(agent.id, "say exactly HELLO"); + const helloFinish = await client.waitForFinish(agent.id, 180_000); + expect(helloFinish.status).toBe("idle"); - // The background task may complete before, during, or after the HELLO - // turn. When the task_notification races with HELLO, the notification - // is handled during the foreground turn and there is no separate - // autonomous running edge afterwards. Wait for a possible autonomous - // wake; if none arrives within the expected sleep window, verify the - // agent settled to idle (notification was already processed). - const autonomousWake = await client - .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) - .catch(() => null); + // The second background task may complete before, during, or after + // HELLO. When it races with HELLO, the notification is handled during + // the foreground turn and there is no separate autonomous running edge. + const autonomousWake = await client + .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) + .catch(() => null); - if (autonomousWake) { - const autonomousFinish = await client.waitForFinish(agent.id, 120_000); - expect(autonomousFinish.status).toBe("idle"); - } else { - const current = await client.fetchAgent(agent.id); - expect(current.agent.status).toBe("idle"); - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + if (autonomousWake) { + const startedAt = Date.now(); + await client.cancelAgent(agent.id); + const interruptDurationMs = Date.now() - startedAt; + expect(interruptDurationMs).toBeLessThan(10_000); + + const settled = await client.waitForFinish(agent.id, 20_000); + expect(settled.status).toBe("idle"); } - }, - 420_000, - ); - test.runIf(isProviderAvailable("claude"))( - "C: interrupt during overlap returns quickly and does not leave agent stuck running", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const finalResult = await client.fetchAgent(agent.id); + expect(finalResult?.agent.status).toBe("idle"); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); + + test("returns to running after background sleep completes without a second prompt", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-wake-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-c" }, - }); - - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-abc-c", - ...getFullAccessConfig("claude"), - }); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const firstKickoff = await client.waitForFinish(agent.id, 240_000); - expect(firstKickoff.status).toBe("idle"); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - const firstAutonomousFinish = await client.waitForFinish(agent.id, 120_000); - expect(firstAutonomousFinish.status).toBe("idle"); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); - const secondKickoff = await client.waitForFinish(agent.id, 240_000); - expect(secondKickoff.status).toBe("idle"); - - await client.sendMessage(agent.id, "say exactly HELLO"); - const helloFinish = await client.waitForFinish(agent.id, 180_000); - expect(helloFinish.status).toBe("idle"); - - // The second background task may complete before, during, or after - // HELLO. When it races with HELLO, the notification is handled during - // the foreground turn and there is no separate autonomous running edge. - const autonomousWake = await client - .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) - .catch(() => null); - - if (autonomousWake) { - const startedAt = Date.now(); - await client.cancelAgent(agent.id); - const interruptDurationMs = Date.now() - startedAt; - expect(interruptDurationMs).toBeLessThan(10_000); - - const settled = await client.waitForFinish(agent.id, 20_000); - expect(settled.status).toBe("idle"); - } - - const finalResult = await client.fetchAgent(agent.id); - expect(finalResult?.agent.status).toBe("idle"); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 600_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "returns to running after background sleep completes without a second prompt", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-wake-real", + ...getFullAccessConfig("claude"), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-wake-real" }, - }); + await client.sendMessage( + agent.id, + [ + "Use a background task to run exactly: sleep 5", + "Do not wait for it to finish.", + "After launching it, reply with exactly: SPAWNED", + ].join(" "), + ); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-wake-real", - ...getFullAccessConfig("claude"), - }); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - await client.sendMessage( - agent.id, - [ - "Use a background task to run exactly: sleep 5", - "Do not wait for it to finish.", - "After launching it, reply with exactly: SPAWNED", - ].join(" "), - ); - - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); - - const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry); - // Required by reproduction request: log timeline at idle edge before autonomous wake. - // eslint-disable-next-line no-console - console.log("TIMELINE_BEFORE_AUTONOMOUS_WAKE\n" + summarized.join("\n")); - expect(summarized.some((line) => line.includes("SPAWNED"))).toBe(true); - - // No new user prompt here: we expect autonomous transition caused by - // background task completion notification. - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "accepts a new prompt after background sleep finishes and replies HELLO", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry); + // Required by reproduction request: log timeline at idle edge before autonomous wake. + // eslint-disable-next-line no-console + console.log("TIMELINE_BEFORE_AUTONOMOUS_WAKE\n" + summarized.join("\n")); + expect(summarized.some((line) => line.includes("SPAWNED"))).toBe(true); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-followup-real" }, - }); + // No new user prompt here: we expect autonomous transition caused by + // background task completion notification. + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-followup-real", - ...getFullAccessConfig("claude"), - }); + test("accepts a new prompt after background sleep finishes and replies HELLO", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - await client.sendMessage( - agent.id, - [ - "Use a background task to run exactly: sleep 5", - "Do not wait for it to finish.", - "After launching it, reply with exactly: SPAWNED", - ].join(" "), - ); - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); - - await new Promise((resolve) => setTimeout(resolve, 6_000)); - - await client.sendMessage(agent.id, "say exactly HELLO"); - const secondFinish = await client.waitForFinish(agent.id, 240_000); - expect(secondFinish.status).toBe("idle"); - expect((secondFinish.lastMessage ?? "").toUpperCase()).toContain("HELLO"); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "repro: do-it-again + immediate hello can hang after autonomous wake under churn", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-followup-real" }, }); - const wsUrl = `ws://127.0.0.1:${daemon.port}/ws`; - const client = new DaemonClient({ url: wsUrl }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-hang-repro-real" }, - }); - - const agent = await client.createAgent({ - cwd, - title: "claude-hang-repro-real", - ...getFullAccessConfig("claude"), - }); - - for (let cycle = 0; cycle < 20; cycle += 1) { - const noise = runPreHelloNoise({ wsUrl, durationMs: 9_000 }); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const firstKickoff = await client.waitForFinish(agent.id, 180_000); - expect(firstKickoff.status).toBe("idle"); - - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - const firstCompletion = await client.waitForFinish(agent.id, 90_000); - expect(firstCompletion.status).toBe("idle"); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); - const secondKickoff = await client.waitForFinish(agent.id, 180_000); - expect(secondKickoff.status).toBe("idle"); - - await client.sendMessage(agent.id, "say exactly HELLO"); - const helloReply = await client.waitForFinish(agent.id, 180_000); - expect(helloReply.status).toBe("idle"); - expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); - - const runningSnapshot = await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - - const timelineAtWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - - // eslint-disable-next-line no-console - console.log( - JSON.stringify({ - cycle, - wakeUpdatedAt: runningSnapshot.updatedAt, - entriesAtWake: timelineAtWake.entries.length, - }), - ); - - await sleep(6_000); - const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( - timelineAtWake.entries.length, - ); - - let secondCompletion; - try { - secondCompletion = await client.waitForFinish(agent.id, 20_000); - } catch { - const atTimeoutResult = await client.fetchAgent(agent.id); - // eslint-disable-next-line no-console - console.log( - JSON.stringify({ - cycle, - phase: "second_completion_timeout", - statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", - }), - ); - secondCompletion = await client.waitForFinish(agent.id, 30_000); - } - expect(secondCompletion.status).toBe("idle"); - - await noise; - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 900_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "repro: second background sleep completion after HELLO should settle back to idle", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-followup-real", + ...getFullAccessConfig("claude"), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-background-repro-real" }, - }); + await client.sendMessage( + agent.id, + [ + "Use a background task to run exactly: sleep 5", + "Do not wait for it to finish.", + "After launching it, reply with exactly: SPAWNED", + ].join(" "), + ); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - const agent = await client.createAgent({ - cwd, - title: "claude-background-repro-real", - ...getFullAccessConfig("claude"), - }); + await new Promise((resolve) => setTimeout(resolve, 6_000)); + + await client.sendMessage(agent.id, "say exactly HELLO"); + const secondFinish = await client.waitForFinish(agent.id, 240_000); + expect(secondFinish.status).toBe("idle"); + expect((secondFinish.lastMessage ?? "").toUpperCase()).toContain("HELLO"); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); + + test("repro: do-it-again + immediate hello can hang after autonomous wake under churn", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const wsUrl = `ws://127.0.0.1:${daemon.port}/ws`; + const client = new DaemonClient({ url: wsUrl }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-hang-repro-real" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "claude-hang-repro-real", + ...getFullAccessConfig("claude"), + }); + + for (let cycle = 0; cycle < 20; cycle += 1) { + const noise = runPreHelloNoise({ wsUrl, durationMs: 9_000 }); await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); const firstKickoff = await client.waitForFinish(agent.id, 180_000); @@ -809,7 +696,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = (snapshot) => snapshot.status === "running", 30_000, ); - const firstCompletion = await client.waitForFinish(agent.id, 60_000); + const firstCompletion = await client.waitForFinish(agent.id, 90_000); expect(firstCompletion.status).toBe("idle"); await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); @@ -821,14 +708,37 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = expect(helloReply.status).toBe("idle"); expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); - await client.waitForAgentUpsert( + const runningSnapshot = await client.waitForAgentUpsert( agent.id, (snapshot) => snapshot.status === "running", 30_000, ); - // This is the failure point seen in production: agent flips to running - // on task completion and never settles back to idle. + const timelineAtWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + // eslint-disable-next-line no-console + console.log( + JSON.stringify({ + cycle, + wakeUpdatedAt: runningSnapshot.updatedAt, + entriesAtWake: timelineAtWake.entries.length, + }), + ); + + await sleep(6_000); + const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( + timelineAtWake.entries.length, + ); + let secondCompletion; try { secondCompletion = await client.waitForFinish(agent.id, 20_000); @@ -837,256 +747,322 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = // eslint-disable-next-line no-console console.log( JSON.stringify({ - phase: "second_background_completion_timeout", + cycle, + phase: "second_completion_timeout", statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", }), ); secondCompletion = await client.waitForFinish(agent.id, 30_000); } expect(secondCompletion.status).toBe("idle"); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + + await noise; } - }, - 600_000, - ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 900_000); - test.runIf(isProviderAvailable("claude"))( - "stress: immediate HELLO before task notification should not leave autonomous run stuck", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + test("repro: second background sleep completion after HELLO should settle back to idle", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-background-repro-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const agent = await client.createAgent({ + cwd, + title: "claude-background-repro-real", + ...getFullAccessConfig("claude"), + }); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const firstKickoff = await client.waitForFinish(agent.id, 180_000); + expect(firstKickoff.status).toBe("idle"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + const firstCompletion = await client.waitForFinish(agent.id, 60_000); + expect(firstCompletion.status).toBe("idle"); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); + const secondKickoff = await client.waitForFinish(agent.id, 180_000); + expect(secondKickoff.status).toBe("idle"); + + await client.sendMessage(agent.id, "say exactly HELLO"); + const helloReply = await client.waitForFinish(agent.id, 180_000); + expect(helloReply.status).toBe("idle"); + expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + + // This is the failure point seen in production: agent flips to running + // on task completion and never settles back to idle. + let secondCompletion; try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-race-stress-real" }, - }); - - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-race-stress-real", - ...getFullAccessConfig("claude"), - }); - - for (let cycle = 0; cycle < 20; cycle += 1) { - const helloToken = `HELLO_CYCLE_${cycle}`; - - await client.sendMessage( - agent.id, - [ - "Use the Task tool (not a Bash background process).", - "Start a background task that runs exactly: sleep 3", - "Do not wait for the task result.", - "Immediately reply with exactly: SPAWNED", - ].join(" "), - ); - - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); - - // Race path under test: immediately send the next user prompt before - // the background-task completion notification wakes Claude again. - await client.sendMessage(agent.id, `say exactly ${helloToken}`); - const secondFinish = await client.waitForFinish(agent.id, 240_000); - expect(secondFinish.status).toBe("idle"); - expect(secondFinish.lastMessage?.trim()).toBe(helloToken); - - const wakeSnapshot = await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 20_000, - ); - - const timelineAtWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - - // eslint-disable-next-line no-console - console.log( - JSON.stringify({ - cycle, - autonomousWakeUpdatedAt: wakeSnapshot.updatedAt, - timelineEntriesAtWake: timelineAtWake.entries.length, - }), - ); - - await sleep(3_000); - const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( - timelineAtWake.entries.length, - ); - - await client.cancelAgent(agent.id); - const afterCancel = await client.waitForFinish(agent.id, 10_000); - expect(afterCancel.status).toBe("idle"); - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + secondCompletion = await client.waitForFinish(agent.id, 20_000); + } catch { + const atTimeoutResult = await client.fetchAgent(agent.id); + // eslint-disable-next-line no-console + console.log( + JSON.stringify({ + phase: "second_background_completion_timeout", + statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", + }), + ); + secondCompletion = await client.waitForFinish(agent.id, 30_000); } - }, - 900_000, - ); + expect(secondCompletion.status).toBe("idle"); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); - test.runIf(isProviderAvailable("claude"))( - "repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + test("stress: immediate HELLO before task notification should not leave autonomous run stuck", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-race-stress-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-transcript-parity-race" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-race-stress-real", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-transcript-parity-race", - ...getFullAccessConfig("claude"), - }); + for (let cycle = 0; cycle < 20; cycle += 1) { + const helloToken = `HELLO_CYCLE_${cycle}`; - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const firstKickoff = await client.waitForFinish(agent.id, 180_000); - expect(firstKickoff.status).toBe("idle"); + await client.sendMessage( + agent.id, + [ + "Use the Task tool (not a Bash background process).", + "Start a background task that runs exactly: sleep 3", + "Do not wait for the task result.", + "Immediately reply with exactly: SPAWNED", + ].join(" "), + ); - await client.waitForAgentUpsert( + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); + + // Race path under test: immediately send the next user prompt before + // the background-task completion notification wakes Claude again. + await client.sendMessage(agent.id, `say exactly ${helloToken}`); + const secondFinish = await client.waitForFinish(agent.id, 240_000); + expect(secondFinish.status).toBe("idle"); + expect(secondFinish.lastMessage?.trim()).toBe(helloToken); + + const wakeSnapshot = await client.waitForAgentUpsert( agent.id, (snapshot) => snapshot.status === "running", - 30_000, + 20_000, ); - const firstAutonomousCompletion = await client.waitForFinish(agent.id, 60_000); - expect(firstAutonomousCompletion.status).toBe("idle"); - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); - const secondKickoff = await client.waitForFinish(agent.id, 180_000); - expect(secondKickoff.status).toBe("idle"); + const timelineAtWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, + // eslint-disable-next-line no-console + console.log( + JSON.stringify({ + cycle, + autonomousWakeUpdatedAt: wakeSnapshot.updatedAt, + timelineEntriesAtWake: timelineAtWake.entries.length, + }), ); - await sleep(500); - await client.sendMessage(agent.id, "say exactly HELLO"); - let waitError: Error | null = null; + await sleep(3_000); + const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( + timelineAtWake.entries.length, + ); + + await client.cancelAgent(agent.id); + const afterCancel = await client.waitForFinish(agent.id, 10_000); + expect(afterCancel.status).toBe("idle"); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 900_000); + + test("repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-transcript-parity-race" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "claude-transcript-parity-race", + ...getFullAccessConfig("claude"), + }); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const firstKickoff = await client.waitForFinish(agent.id, 180_000); + expect(firstKickoff.status).toBe("idle"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + const firstAutonomousCompletion = await client.waitForFinish(agent.id, 60_000); + expect(firstAutonomousCompletion.status).toBe("idle"); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); + const secondKickoff = await client.waitForFinish(agent.id, 180_000); + expect(secondKickoff.status).toBe("idle"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + await sleep(500); + await client.sendMessage(agent.id, "say exactly HELLO"); + + let waitError: Error | null = null; + try { + await client.waitForFinish(agent.id, 20_000); + } catch (error) { + waitError = + error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed")); + } + + let afterWaitResult = await client.fetchAgent(agent.id); + let cancelRecovered = true; + if (afterWaitResult?.agent.status === "running") { + await client.cancelAgent(agent.id); try { - await client.waitForFinish(agent.id, 20_000); - } catch (error) { - waitError = - error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed")); + const afterCancel = await client.waitForFinish(agent.id, 15_000); + cancelRecovered = afterCancel.status === "idle"; + } catch { + cancelRecovered = false; } - - let afterWaitResult = await client.fetchAgent(agent.id); - let cancelRecovered = true; - if (afterWaitResult?.agent.status === "running") { - await client.cancelAgent(agent.id); - try { - const afterCancel = await client.waitForFinish(agent.id, 15_000); - cancelRecovered = afterCancel.status === "idle"; - } catch { - cancelRecovered = false; - } - afterWaitResult = await client.fetchAgent(agent.id); - } - - expect(waitError).toBeNull(); - expect(cancelRecovered).toBe(true); - - const afterWait = afterWaitResult?.agent ?? null; - const sessionId = - afterWait?.persistence?.sessionId ?? afterWait?.runtimeInfo?.sessionId ?? null; - expect(typeof sessionId === "string" && sessionId.length > 0).toBe(true); - const transcriptCwd = afterWait?.cwd ?? cwd; - const transcriptPath = resolveClaudeTranscriptPath({ - cwd: transcriptCwd, - sessionId: sessionId as string, - }); - - let evidence: TranscriptRaceEvidence | null = null; - const transcriptDeadline = Date.now() + 20_000; - while (Date.now() < transcriptDeadline) { - const lines = readTranscriptLines(transcriptPath); - evidence = extractTranscriptRaceEvidence(lines); - if (evidence) { - break; - } - await sleep(250); - } - if (!evidence) { - const transcriptDump = summarizeTranscriptTail(readTranscriptLines(transcriptPath)); - throw new Error( - [ - "Failed to extract transcript race evidence (hello assistant and notification-turn assistant).", - `transcriptPath=${transcriptPath}`, - `waitError=${waitError ? waitError.message : "null"}`, - `afterWaitStatus=${afterWait?.status ?? "unknown"}`, - `cancelRecovered=${cancelRecovered}`, - "transcriptTail:", - transcriptDump, - ].join("\n"), - ); - } - - const helloAssistant = compactText(evidence.helloAssistantText); - const notificationAssistant = compactText(evidence.notificationOutcomeAssistantText); - let assistantTexts: string[] = []; - let assistantTextCombined = ""; - const timelineDeadline = Date.now() + 20_000; - while (Date.now() < timelineDeadline) { - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - assistantTexts = timeline.entries - .filter( - ( - entry, - ): entry is { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => compactText(entry.item.text)); - assistantTextCombined = assistantTexts.join(""); - if ( - assistantTextCombined.includes(helloAssistant) && - assistantTextCombined.includes(notificationAssistant) - ) { - break; - } - await sleep(250); - } - - expect(assistantTextCombined.includes(helloAssistant)).toBe(true); - expect(assistantTextCombined.includes(notificationAssistant)).toBe(true); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + afterWaitResult = await client.fetchAgent(agent.id); } - }, - 600_000, - ); + + expect(waitError).toBeNull(); + expect(cancelRecovered).toBe(true); + + const afterWait = afterWaitResult?.agent ?? null; + const sessionId = + afterWait?.persistence?.sessionId ?? afterWait?.runtimeInfo?.sessionId ?? null; + expect(typeof sessionId === "string" && sessionId.length > 0).toBe(true); + const transcriptCwd = afterWait?.cwd ?? cwd; + const transcriptPath = resolveClaudeTranscriptPath({ + cwd: transcriptCwd, + sessionId: sessionId as string, + }); + + let evidence: TranscriptRaceEvidence | null = null; + const transcriptDeadline = Date.now() + 20_000; + while (Date.now() < transcriptDeadline) { + const lines = readTranscriptLines(transcriptPath); + evidence = extractTranscriptRaceEvidence(lines); + if (evidence) { + break; + } + await sleep(250); + } + if (!evidence) { + const transcriptDump = summarizeTranscriptTail(readTranscriptLines(transcriptPath)); + throw new Error( + [ + "Failed to extract transcript race evidence (hello assistant and notification-turn assistant).", + `transcriptPath=${transcriptPath}`, + `waitError=${waitError ? waitError.message : "null"}`, + `afterWaitStatus=${afterWait?.status ?? "unknown"}`, + `cancelRecovered=${cancelRecovered}`, + "transcriptTail:", + transcriptDump, + ].join("\n"), + ); + } + + const helloAssistant = compactText(evidence.helloAssistantText); + const notificationAssistant = compactText(evidence.notificationOutcomeAssistantText); + let assistantTexts: string[] = []; + let assistantTextCombined = ""; + const timelineDeadline = Date.now() + 20_000; + while (Date.now() < timelineDeadline) { + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + assistantTexts = timeline.entries + .filter( + ( + entry, + ): entry is { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => compactText(entry.item.text)); + assistantTextCombined = assistantTexts.join(""); + if ( + assistantTextCombined.includes(helloAssistant) && + assistantTextCombined.includes(notificationAssistant) + ) { + break; + } + await sleep(250); + } + + expect(assistantTextCombined.includes(helloAssistant)).toBe(true); + expect(assistantTextCombined.includes(notificationAssistant)).toBe(true); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); }); diff --git a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts index 5668a904f..1c76df462 100644 --- a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -14,52 +14,60 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - model resolution on init", () => { - test.runIf(isProviderAvailable("claude"))( - "runtimeInfo.model is set as soon as the agent starts running, not after turn completes", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("runtimeInfo.model is set as soon as the agent starts running, not after turn completes", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "model-init-test" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "model-init-test" }, - }); + const modelsResult = await client.listProviderModels("claude", { cwd }); + const catalogModelIds = new Set(modelsResult.models.map((m) => m.id)); - const modelsResult = await client.listProviderModels("claude", { cwd }); - const catalogModelIds = new Set(modelsResult.models.map((m) => m.id)); + const agent = await client.createAgent({ + provider: "claude", + cwd, + model: "haiku", + title: "model-init-test", + }); - const agent = await client.createAgent({ - provider: "claude", - cwd, - model: "haiku", - title: "model-init-test", - }); + await client.sendMessage(agent.id, "Reply with exactly: OK"); - await client.sendMessage(agent.id, "Reply with exactly: OK"); + const snapshot = await client.waitForAgentUpsert( + agent.id, + (s) => s.runtimeInfo?.model != null, + 60_000, + ); - const snapshot = await client.waitForAgentUpsert( - agent.id, - (s) => s.runtimeInfo?.model != null, - 60_000, - ); + expect(snapshot.runtimeInfo?.model).toBeTruthy(); + expect(catalogModelIds.has(snapshot.runtimeInfo!.model!)).toBe(true); + expect(snapshot.status).toBe("running"); - expect(snapshot.runtimeInfo?.model).toBeTruthy(); - expect(catalogModelIds.has(snapshot.runtimeInfo!.model!)).toBe(true); - expect(snapshot.status).toBe("running"); - - await client.waitForFinish(agent.id, 60_000); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 120_000, - ); + await client.waitForFinish(agent.id, 60_000); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 120_000); }); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index 42cbcdcde..9a5e5f7a5 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -14,50 +14,58 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - runtime model reconciliation", () => { - test.runIf(isProviderAvailable("claude"))( - "normalizes runtime model to a model ID exposed by the provider catalog", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("normalizes runtime model to a model ID exposed by the provider catalog", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, - }); + const modelsResult = await client.listProviderModels("claude", { cwd }); + expect(modelsResult.error).toBeNull(); + expect(modelsResult.models.length).toBeGreaterThan(0); + const modelIds = new Set(modelsResult.models.map((model) => model.id)); - const modelsResult = await client.listProviderModels("claude", { cwd }); - expect(modelsResult.error).toBeNull(); - expect(modelsResult.models.length).toBeGreaterThan(0); - const modelIds = new Set(modelsResult.models.map((model) => model.id)); + const agent = await client.createAgent({ + provider: "claude", + cwd, + title: "claude-runtime-model-reconcile", + }); - const agent = await client.createAgent({ - provider: "claude", - cwd, - title: "claude-runtime-model-reconcile", - }); + await client.sendMessage(agent.id, "Reply with exactly: OK"); + const finish = await client.waitForFinish(agent.id, 180_000); + expect(finish.status).toBe("idle"); - await client.sendMessage(agent.id, "Reply with exactly: OK"); - const finish = await client.waitForFinish(agent.id, 180_000); - expect(finish.status).toBe("idle"); - - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot).not.toBeNull(); - const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null; - expect(typeof runtimeModelId).toBe("string"); - expect(runtimeModelId).not.toBe(""); - expect(modelIds.has(runtimeModelId as string)).toBe(true); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 300_000, - ); + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot).not.toBeNull(); + const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null; + expect(typeof runtimeModelId).toBe("string"); + expect(runtimeModelId).not.toBe(""); + expect(modelIds.has(runtimeModelId as string)).toBe(true); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts index 190d8e189..b0d569647 100644 --- a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -29,55 +29,63 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - custom agent discovery", () => { - test.runIf(isProviderAvailable("opencode"))( - "custom agents defined in opencode.json appear in availableModes", - async () => { - const cwd = tmpCwd(); - writeFileSync( - path.join(cwd, "opencode.json"), - JSON.stringify({ - agent: { - "paseo-e2e-custom": { - description: "Custom agent for Paseo daemon E2E test", - mode: "primary", - }, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("custom agents defined in opencode.json appear in availableModes", async () => { + const cwd = tmpCwd(); + writeFileSync( + path.join(cwd, "opencode.json"), + JSON.stringify({ + agent: { + "paseo-e2e-custom": { + description: "Custom agent for Paseo daemon E2E test", + mode: "primary", }, - }), + }, + }), + ); + + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode custom agent discovery", + }); + + // Wait for modes to be populated (they arrive after session init) + const snapshot = await client.waitForAgentUpsert( + agent.id, + (s) => s.availableModes.length > 0, + 30_000, ); - const { client, daemon } = await createHarness(); + expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true); + expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true); - try { - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode custom agent discovery", - }); + const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom"); + expect(custom).toBeDefined(); + expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test"); - // Wait for modes to be populated (they arrive after session init) - const snapshot = await client.waitForAgentUpsert( - agent.id, - (s) => s.availableModes.length > 0, - 30_000, - ); - - expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true); - expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true); - - const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom"); - expect(custom).toBeDefined(); - expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test"); - - // System agents should not leak through - expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false); - expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false); - expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 60_000, - ); + // System agents should not leak through + expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index e3d000f9f..879cb61ee 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -29,85 +29,89 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - initial prompt wait", () => { - test.runIf(isProviderAvailable("opencode"))( - "waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + let canRun = false; - try { - const models = await client.listProviderModels("opencode"); - expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode initial prompt wait regression", - model: "opencode/big-pickle", - initialPrompt: "Reply with exactly: BIG_PICKLE_OK", - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const finish = await client.waitForFinish(agent.id, 60_000); - expect(finish.status).toBe("idle"); - expect(finish.lastMessage).toContain("BIG_PICKLE_OK"); + test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot.agent?.status).toBe("idle"); + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "projected", - }); - const assistantMessages = timeline.entries.filter( - (entry) => entry.item.type === "assistant_message", - ); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode initial prompt wait regression", + model: "opencode/big-pickle", + initialPrompt: "Reply with exactly: BIG_PICKLE_OK", + }); - expect(assistantMessages.length).toBeGreaterThan(0); - expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe( - true, - ); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 90_000, - ); + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("idle"); + expect(finish.lastMessage).toContain("BIG_PICKLE_OK"); - test.runIf(isProviderAvailable("opencode"))( - "waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("idle"); - try { - const models = await client.listProviderModels("opencode"); - expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const assistantMessages = timeline.entries.filter( + (entry) => entry.item.type === "assistant_message", + ); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode zai fatal retry regression", - model: "zai/glm-5.1", - initialPrompt: "Reply with exactly: GLM_51_OK", - }); + expect(assistantMessages.length).toBeGreaterThan(0); + expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe( + true, + ); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 90_000); - const finish = await client.waitForFinish(agent.id, 60_000); - expect(finish.status).toBe("error"); - expect((finish.error ?? "").toLowerCase()).toMatch( - /insufficient balance|resource package|recharge/, - ); + test("waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot.agent?.status).toBe("error"); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 90_000, - ); + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode zai fatal retry regression", + model: "zai/glm-5.1", + initialPrompt: "Reply with exactly: GLM_51_OK", + }); + + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect((finish.error ?? "").toLowerCase()).toMatch( + /insufficient balance|resource package|recharge/, + ); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 90_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts index 903665674..6d0af170c 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -29,33 +29,41 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - invalid model handling", () => { - test.runIf(isProviderAvailable("opencode"))( - "initial prompt with a nonexistent OpenCode model fails instead of hanging forever", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + let canRun = false; - try { - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode invalid model regression", - model: "opencode/adklasldkdas", - initialPrompt: "hello", - }); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const finish = await client.waitForFinish(agent.id, 30_000); - expect(finish.status).toBe("error"); - expect(finish.error).toBeTruthy(); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot.agent?.status).toBe("error"); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 60_000, - ); + test("initial prompt with a nonexistent OpenCode model fails instead of hanging forever", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode invalid model regression", + model: "opencode/adklasldkdas", + initialPrompt: "hello", + }); + + const finish = await client.waitForFinish(agent.id, 30_000); + expect(finish.status).toBe("error"); + expect(finish.error).toBeTruthy(); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts index 1f7f47ba9..d12bb8d19 100644 --- a/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -39,102 +39,106 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => { - test.runIf(isProviderAvailable("opencode"))( - "surfaces clarifying questions as pending permissions", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + let canRun = false; - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode question regression", - model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]), - modeId: "plan", - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - await client.sendMessage( - agent.id, - [ - "Use the question tool/feature to ask me exactly one clarifying question.", - "Ask this exact question: What kind of project should the plan cover?", - "Wait for my answer before doing anything else.", - ].join(" "), - ); + test("surfaces clarifying questions as pending permissions", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); - const snapshotWithQuestion = await client.waitForAgentUpsert( - agent.id, - (snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question", - 30_000, - ); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode question regression", + model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]), + modeId: "plan", + }); - const permission = snapshotWithQuestion.pendingPermissions?.[0]; - expect(permission).toBeTruthy(); - expect(permission?.kind).toBe("question"); - expect(Array.isArray(permission?.input?.questions)).toBe(true); + await client.sendMessage( + agent.id, + [ + "Use the question tool/feature to ask me exactly one clarifying question.", + "Ask this exact question: What kind of project should the plan cover?", + "Wait for my answer before doing anything else.", + ].join(" "), + ); - const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined; - expect(firstQuestion?.header).toBeTruthy(); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 180_000, - ); + const snapshotWithQuestion = await client.waitForAgentUpsert( + agent.id, + (snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question", + 30_000, + ); - test.runIf(isProviderAvailable("opencode"))( - "plan mode stays read-only through the daemon path", - async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "plan-mode-output.txt"); - const { client, daemon } = await createHarness(); + expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0); - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + const permission = snapshotWithQuestion.pendingPermissions?.[0]; + expect(permission).toBeTruthy(); + expect(permission?.kind).toBe("question"); + expect(Array.isArray(permission?.input?.questions)).toBe(true); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode plan mode regression", - model: pickOpenCodeModel(modelList.models), - modeId: "plan", - }); + const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined; + expect(firstQuestion?.header).toBeTruthy(); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 180_000); - await client.sendMessage( - agent.id, - "Create a file named plan-mode-output.txt in the current directory containing exactly hello.", - ); + test("plan mode stays read-only through the daemon path", async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "plan-mode-output.txt"); + const { client, daemon } = await createHarness(); - const state = await client.waitForFinish(agent.id, 180_000); - expect(state.status).toBe("idle"); - expect(existsSync(filePath)).toBe(false); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "projected", - }); - const assistantText = timeline.entries - .filter((entry) => entry.item.type === "assistant_message") - .map((entry) => entry.item.text) - .join(" "); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode plan mode regression", + model: pickOpenCodeModel(modelList.models), + modeId: "plan", + }); - expect(assistantText.toLowerCase()).toContain("plan"); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 240_000, - ); + await client.sendMessage( + agent.id, + "Create a file named plan-mode-output.txt in the current directory containing exactly hello.", + ); + + const state = await client.waitForFinish(agent.id, 180_000); + expect(state.status).toBe("idle"); + expect(existsSync(filePath)).toBe(false); + + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => entry.item.text) + .join(" "); + + expect(assistantText.toLowerCase()).toContain("plan"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 240_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts index f0539ba1a..01bbbe7bd 100644 --- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -281,142 +281,146 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - send while working and interrupt", () => { - test.runIf(isProviderAvailable("opencode"))( - "send_message while sleep tool call is running starts a clean replacement turn", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); - const collector = createMessageCollector(client); - const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK"; + let canRun = false; - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode send while working", - model: pickOpenCodeModel(modelList.models), - modeId: "default", - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - await client.sendMessage( - agent.id, - [ - "Use the Bash tool.", - "Run exactly: sleep 60", - "Do not run it in the background.", - "Do not do anything after starting the command.", - ].join(" "), - ); + test("send_message while sleep tool call is running starts a clean replacement turn", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + const collector = createMessageCollector(client); + const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK"; - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 90_000, - ); - await waitForRunningBashToolCall(client, collector, agent.id); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - collector.clear(); - await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode send while working", + model: pickOpenCodeModel(modelList.models), + modeId: "default", + }); - const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); - expect(finish.status).toBe("idle"); + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not run it in the background.", + "Do not do anything after starting the command.", + ].join(" "), + ); - const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id); - expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - expect(postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe( - false, - ); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 90_000, + ); + await waitForRunningBashToolCall(client, collector, agent.id); - const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 }); - const assistantTexts = getTimelineAssistantTexts(timeline); - expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); - expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); - } finally { - collector.unsubscribe(); - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 360_000, - ); + collector.clear(); + await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); - test.runIf(isProviderAvailable("opencode"))( - "explicit interrupt during sleep tool call still allows the next turn to complete", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); - const collector = createMessageCollector(client); - const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK"; + const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); + expect(finish.status).toBe("idle"); - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id); + expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe( + false, + ); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode explicit interrupt", - model: pickOpenCodeModel(modelList.models), - modeId: "default", - }); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 }); + const assistantTexts = getTimelineAssistantTexts(timeline); + expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); + expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); + } finally { + collector.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 360_000); - await client.sendMessage( - agent.id, - [ - "Use the Bash tool.", - "Run exactly: sleep 60", - "Do not run it in the background.", - "Do not do anything after starting the command.", - ].join(" "), - ); + test("explicit interrupt during sleep tool call still allows the next turn to complete", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + const collector = createMessageCollector(client); + const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK"; - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 90_000, - ); - await waitForRunningBashToolCall(client, collector, agent.id); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - await client.cancelAgent(agent.id); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "idle" || snapshot.status === "error", - 90_000, - ); - const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000); - expect(interruptedToolCall.status).toBe("failed"); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode explicit interrupt", + model: pickOpenCodeModel(modelList.models), + modeId: "default", + }); - collector.clear(); - await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not run it in the background.", + "Do not do anything after starting the command.", + ].join(" "), + ); - const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); - expect(finish.status).toBe("idle"); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 90_000, + ); + await waitForRunningBashToolCall(client, collector, agent.id); - const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id); - expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe( - false, - ); - expect( - postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)), - ).toBe(false); + await client.cancelAgent(agent.id); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "idle" || snapshot.status === "error", + 90_000, + ); + const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000); + expect(interruptedToolCall.status).toBe("failed"); - const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 }); - const assistantTexts = getTimelineAssistantTexts(timeline); - expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); - expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); - } finally { - collector.unsubscribe(); - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 360_000, - ); + collector.clear(); + await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + + const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); + expect(finish.status).toBe("idle"); + + const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id); + expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe( + false, + ); + expect(postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe( + false, + ); + + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 }); + const assistantTexts = getTimelineAssistantTexts(timeline); + expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); + expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); + } finally { + collector.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 360_000); }); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index a0f5ac8b1..236a7c0c0 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -16,61 +16,69 @@ function tmpCwd(): string { } describe("daemon E2E (real pi)", () => { - test.runIf(isProviderAvailable("pi"))( - "smoke test with thinking option configured separately from modes", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { pi: new PiACPAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("pi"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("smoke test with thinking option configured separately from modes", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { pi: new PiACPAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "pi-real-smoke" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "pi-real-smoke" }, - }); + const agent = await client.createAgent({ + cwd, + title: "pi-real-smoke", + provider: "pi", + thinkingOptionId: "medium", + }); - const agent = await client.createAgent({ - cwd, - title: "pi-real-smoke", - provider: "pi", - thinkingOptionId: "medium", - }); + await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE"); - await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE"); + const finish = await client.waitForFinish(agent.id, 240_000); + expect(finish.status).toBe("idle"); + expect(finish.final?.persistence).toBeTruthy(); + expect(finish.final?.persistence?.provider).toBe("pi"); + expect(finish.final?.persistence?.sessionId).toBeTruthy(); - const finish = await client.waitForFinish(agent.id, 240_000); - expect(finish.status).toBe("idle"); - expect(finish.final?.persistence).toBeTruthy(); - expect(finish.final?.persistence?.provider).toBe("pi"); - expect(finish.final?.persistence?.sessionId).toBeTruthy(); + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const assistantText = timeline.entries + .filter( + ( + entry, + ): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const assistantText = timeline.entries - .filter( - ( - entry, - ): entry is typeof entry & { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => entry.item.text) - .join("\n"); - - expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE"); - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); + expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE"); + } finally { + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); }); diff --git a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts index 7c5627db1..02aa75e14 100644 --- a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -14,54 +14,62 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - rewind user message dedupe", () => { - test.runIf(isProviderAvailable("claude"))( - "emits /rewind user message once in persisted timeline", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("emits /rewind user message once in persisted timeline", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "rewind-user-message-dedupe" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "rewind-user-message-dedupe" }, - }); + const agent = await client.createAgent({ + cwd, + title: "rewind-user-message-dedupe-real-claude", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "rewind-user-message-dedupe-real-claude", - ...getFullAccessConfig("claude"), - }); + await client.sendMessage(agent.id, "Reply with exactly: READY"); + const initialResult = await client.waitForFinish(agent.id, 180_000); + expect(initialResult.status).toBe("idle"); - await client.sendMessage(agent.id, "Reply with exactly: READY"); - const initialResult = await client.waitForFinish(agent.id, 180_000); - expect(initialResult.status).toBe("idle"); + await client.sendMessage(agent.id, "/rewind"); + const rewindResult = await client.waitForFinish(agent.id, 180_000); + expect(rewindResult.status).not.toBe("timeout"); - await client.sendMessage(agent.id, "/rewind"); - const rewindResult = await client.waitForFinish(agent.id, 180_000); - expect(rewindResult.status).not.toBe("timeout"); + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); + const rewindUserMessages = timeline.entries.filter( + (entry) => entry.item.type === "user_message" && entry.item.text.trim() === "/rewind", + ); - const rewindUserMessages = timeline.entries.filter( - (entry) => entry.item.type === "user_message" && entry.item.text.trim() === "/rewind", - ); - - expect(rewindUserMessages).toHaveLength(1); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 300_000, - ); + expect(rewindUserMessages).toHaveLength(1); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 5b427c590..4f66d02f9 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -168,111 +168,119 @@ async function waitForRunningToolCall( } describe("daemon E2E (real claude) - send message during tool call", () => { - test.runIf(isProviderAvailable("claude"))( - "sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + + const agent = await client.createAgent({ + cwd, + title: "tool-interrupt-repro", + ...getFullAccessConfig("claude"), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const collector = createMessageCollector(client); - try { - await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + // Step 1: Ask Claude to run sleep 60 in the foreground + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not use a background task.", + "Do not do anything after starting the command.", + ].join(" "), + ); - const agent = await client.createAgent({ - cwd, - title: "tool-interrupt-repro", - ...getFullAccessConfig("claude"), + // Step 2: Wait for the agent to be running + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000, + ); + + // Step 3: Wait for a tool call to appear as "running" in the stream + await waitForRunningToolCall(client, collector, agent.id); + + collector.clear(); + + // Step 4: Send a second message while the tool call is still running + await client.sendMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED"); + + // Step 5: Wait for the agent to finish — this is the critical assertion. + // If the bug is present, the agent will stop and never start a new turn. + const finish = await client.waitForFinish(agent.id, 120_000); + const postSendMessages = [...collector.messages]; + const postSendAssistantTexts = getAssistantTexts(postSendMessages, agent.id); + const postSendStatuses = getAgentStatuses(postSendMessages, agent.id); + const statusesBeforeFirstAssistant = getStatusesBeforeFirstAssistant( + postSendMessages, + agent.id, + ); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + + if (finish.status !== "idle") { + const snapshot = await client.fetchAgent(agent.id); + throw new Error( + `Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`, + ); + } + + // Replacement should create exactly one new turn. A second turn_started here + // means the reply got displaced onto a later autonomous wake. + expect(countTurnStarted(postSendMessages, agent.id)).toBe(1); + + // The replacement path should not surface as agent error state. + expect(postSendStatuses).not.toContain("error"); + + // The agent should not flash idle before the replacement produces visible output. + expect(statusesBeforeFirstAssistant).not.toContain("idle"); + expect(statusesBeforeFirstAssistant).not.toContain("error"); + + // Step 6: Verify the agent actually responded to our second message + const assistantTexts = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => { + const item = entry.item as Extract; + return item.text; }); - const collector = createMessageCollector(client); + // No system error messages should leak into the timeline + const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]")); + expect(hasSystemError).toBe(false); + expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - // Step 1: Ask Claude to run sleep 60 in the foreground - await client.sendMessage( - agent.id, - [ - "Use the Bash tool.", - "Run exactly: sleep 60", - "Do not use a background task.", - "Do not do anything after starting the command.", - ].join(" "), - ); + const responded = assistantTexts.some((text) => + text.toUpperCase().includes("INTERRUPT_RECEIVED"), + ); + expect(responded).toBe(true); - // Step 2: Wait for the agent to be running - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 60_000, - ); - - // Step 3: Wait for a tool call to appear as "running" in the stream - await waitForRunningToolCall(client, collector, agent.id); - - collector.clear(); - - // Step 4: Send a second message while the tool call is still running - await client.sendMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED"); - - // Step 5: Wait for the agent to finish — this is the critical assertion. - // If the bug is present, the agent will stop and never start a new turn. - const finish = await client.waitForFinish(agent.id, 120_000); - const postSendMessages = [...collector.messages]; - const postSendAssistantTexts = getAssistantTexts(postSendMessages, agent.id); - const postSendStatuses = getAgentStatuses(postSendMessages, agent.id); - const statusesBeforeFirstAssistant = getStatusesBeforeFirstAssistant( - postSendMessages, - agent.id, - ); - const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); - - if (finish.status !== "idle") { - const snapshot = await client.fetchAgent(agent.id); - throw new Error( - `Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`, - ); - } - - // Replacement should create exactly one new turn. A second turn_started here - // means the reply got displaced onto a later autonomous wake. - expect(countTurnStarted(postSendMessages, agent.id)).toBe(1); - - // The replacement path should not surface as agent error state. - expect(postSendStatuses).not.toContain("error"); - - // The agent should not flash idle before the replacement produces visible output. - expect(statusesBeforeFirstAssistant).not.toContain("idle"); - expect(statusesBeforeFirstAssistant).not.toContain("error"); - - // Step 6: Verify the agent actually responded to our second message - const assistantTexts = timeline.entries - .filter((entry) => entry.item.type === "assistant_message") - .map((entry) => { - const item = entry.item as Extract; - return item.text; - }); - - // No system error messages should leak into the timeline - const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]")); - expect(hasSystemError).toBe(false); - expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - - const responded = assistantTexts.some((text) => - text.toUpperCase().includes("INTERRUPT_RECEIVED"), - ); - expect(responded).toBe(true); - - collector.unsubscribe(); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 300_000, - ); + collector.unsubscribe(); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index 2ed6ff1f1..ced32c2b3 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -15,109 +15,117 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - send while running recovery", () => { - test.runIf(isProviderAvailable("claude"))( - "clears input processing when the interrupt transition is missed", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("clears input processing when the interrupt transition is missed", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + + const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await primary.connect(); + await secondary.connect(); + await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + + const agent = await primary.createAgent({ + cwd, + title: "stuck-repro-real-claude", + ...getFullAccessConfig("claude"), }); - const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await primary.sendMessage( + agent.id, + "Run bash command sleep 30, wait for completion, then reply done.", + ); + await primary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000, + ); + let isProcessing = true; + let previousIsRunning = true; + let latestUpdatedAt = Date.now(); + + await primary.close(); + + await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000, + ); + + const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); try { - await primary.connect(); - await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await reconnected.connect(); + const applySnapshot = ( + snapshot: Parameters[0]["snapshot"], + ) => { + const next = applyAgentInputProcessingTransition({ + snapshot, + currentIsProcessing: isProcessing, + previousIsRunning, + latestUpdatedAt, + }); + isProcessing = next.isProcessing; + previousIsRunning = next.previousIsRunning; + latestUpdatedAt = next.latestUpdatedAt; + }; - const agent = await primary.createAgent({ - cwd, - title: "stuck-repro-real-claude", - ...getFullAccessConfig("claude"), + reconnected.on("agent_update", (message) => { + if (message.type !== "agent_update" || message.payload.kind !== "upsert") { + return; + } + if (message.payload.agent.id !== agent.id) { + return; + } + applySnapshot(message.payload.agent); }); - await primary.sendMessage( - agent.id, - "Run bash command sleep 30, wait for completion, then reply done.", - ); - await primary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 60_000, - ); - - let isProcessing = true; - let previousIsRunning = true; - let latestUpdatedAt = Date.now(); - - await primary.close(); - - await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); - await secondary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 60_000, - ); - - const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await reconnected.connect(); - const applySnapshot = ( - snapshot: Parameters[0]["snapshot"], - ) => { - const next = applyAgentInputProcessingTransition({ - snapshot, - currentIsProcessing: isProcessing, - previousIsRunning, - latestUpdatedAt, - }); - isProcessing = next.isProcessing; - previousIsRunning = next.previousIsRunning; - latestUpdatedAt = next.latestUpdatedAt; - }; - - reconnected.on("agent_update", (message) => { - if (message.type !== "agent_update" || message.payload.kind !== "upsert") { - return; - } - if (message.payload.agent.id !== agent.id) { - return; - } - applySnapshot(message.payload.agent); - }); - - const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, - }); - const hydratedSnapshot = initial.entries.find( - (candidate) => candidate.agent.id === agent.id, - )?.agent; - if (hydratedSnapshot) { - applySnapshot(hydratedSnapshot); - } - - await secondary.waitForFinish(agent.id, 180_000); - const finalResult = await secondary.fetchAgent(agent.id); - if (finalResult) { - applySnapshot(finalResult.agent); - } - - // Sending while running should clear processing even if reconnect misses the - // not-running -> running transition. - expect(isProcessing).toBe(false); - } finally { - await reconnected.close(); + const initial = await reconnected.fetchAgents({ + subscribe: { subscriptionId: "reconnected" }, + }); + const hydratedSnapshot = initial.entries.find( + (candidate) => candidate.agent.id === agent.id, + )?.agent; + if (hydratedSnapshot) { + applySnapshot(hydratedSnapshot); } + + await secondary.waitForFinish(agent.id, 180_000); + const finalResult = await secondary.fetchAgent(agent.id); + if (finalResult) { + applySnapshot(finalResult.agent); + } + + // Sending while running should clear processing even if reconnect misses the + // not-running -> running transition. + expect(isProcessing).toBe(false); } finally { - await secondary.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + await reconnected.close(); } - }, - 300_000, - ); + } finally { + await secondary.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts index fb5470b6a..bc40fab46 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -15,106 +15,114 @@ function tmpCwd(): string { } describe("daemon E2E (real codex) - send while running recovery", () => { - test.runIf(isProviderAvailable("codex"))( - "clears input processing when the interrupt transition is missed", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { codex: new CodexAppServerAgentClient(logger) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("codex"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("clears input processing when the interrupt transition is missed", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: new CodexAppServerAgentClient(logger) }, + logger, + }); + + const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await primary.connect(); + await secondary.connect(); + await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + + const agent = await primary.createAgent({ + cwd, + title: "stuck-repro-real-codex", + ...getFullAccessConfig("codex"), }); - const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await primary.sendMessage(agent.id, "Run: sleep 5"); + await primary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + let isProcessing = true; + let previousIsRunning = true; + let latestUpdatedAt = Date.now(); + + await primary.close(); + + await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + + const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); try { - await primary.connect(); - await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await reconnected.connect(); + const applySnapshot = ( + snapshot: Parameters[0]["snapshot"], + ) => { + const next = applyAgentInputProcessingTransition({ + snapshot, + currentIsProcessing: isProcessing, + previousIsRunning, + latestUpdatedAt, + }); + isProcessing = next.isProcessing; + previousIsRunning = next.previousIsRunning; + latestUpdatedAt = next.latestUpdatedAt; + }; - const agent = await primary.createAgent({ - cwd, - title: "stuck-repro-real-codex", - ...getFullAccessConfig("codex"), + reconnected.on("agent_update", (message) => { + if (message.type !== "agent_update" || message.payload.kind !== "upsert") { + return; + } + if (message.payload.agent.id !== agent.id) { + return; + } + applySnapshot(message.payload.agent); }); - await primary.sendMessage(agent.id, "Run: sleep 5"); - await primary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - - let isProcessing = true; - let previousIsRunning = true; - let latestUpdatedAt = Date.now(); - - await primary.close(); - - await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); - await secondary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - - const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await reconnected.connect(); - const applySnapshot = ( - snapshot: Parameters[0]["snapshot"], - ) => { - const next = applyAgentInputProcessingTransition({ - snapshot, - currentIsProcessing: isProcessing, - previousIsRunning, - latestUpdatedAt, - }); - isProcessing = next.isProcessing; - previousIsRunning = next.previousIsRunning; - latestUpdatedAt = next.latestUpdatedAt; - }; - - reconnected.on("agent_update", (message) => { - if (message.type !== "agent_update" || message.payload.kind !== "upsert") { - return; - } - if (message.payload.agent.id !== agent.id) { - return; - } - applySnapshot(message.payload.agent); - }); - - const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, - }); - const hydratedSnapshot = initial.entries.find( - (candidate) => candidate.agent.id === agent.id, - )?.agent; - if (hydratedSnapshot) { - applySnapshot(hydratedSnapshot); - } - - await secondary.waitForFinish(agent.id, 120_000); - const finalResult = await secondary.fetchAgent(agent.id); - if (finalResult) { - applySnapshot(finalResult.agent); - } - - // Sending while running should clear processing even if reconnect misses the - // not-running -> running transition. - expect(isProcessing).toBe(false); - } finally { - await reconnected.close(); + const initial = await reconnected.fetchAgents({ + subscribe: { subscriptionId: "reconnected" }, + }); + const hydratedSnapshot = initial.entries.find( + (candidate) => candidate.agent.id === agent.id, + )?.agent; + if (hydratedSnapshot) { + applySnapshot(hydratedSnapshot); } + + await secondary.waitForFinish(agent.id, 120_000); + const finalResult = await secondary.fetchAgent(agent.id); + if (finalResult) { + applySnapshot(finalResult.agent); + } + + // Sending while running should clear processing even if reconnect misses the + // not-running -> running transition. + expect(isProcessing).toBe(false); } finally { - await secondary.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + await reconnected.close(); } - }, - 240_000, - ); + } finally { + await secondary.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 240_000); }); diff --git a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts index f07aa0900..4b3e99098 100644 --- a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -456,92 +456,94 @@ function createRealAgentClient(provider: AgentProvider, logger: pino.Logger): Ag } describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider) => { - const shouldRun = isProviderAvailable(provider); + let shouldRun = false; - test.runIf(shouldRun)( - "normal UI submit path (idle sends) stays correct", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { - [provider]: createRealAgentClient(provider, logger), - } as Partial>, - logger, + beforeAll(async () => { + shouldRun = await isProviderAvailable(provider); + }); + + beforeEach((context) => { + if (!shouldRun) { + context.skip(); + } + }); + + test("normal UI submit path (idle sends) stays correct", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { + [provider]: createRealAgentClient(provider, logger), + } as Partial>, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: `ui-stress-normal-${provider}` }, + }); + const agent = await client.createAgent({ + cwd, + title: `uist-n-${provider}`, + ...getFullAccessConfig(provider), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-normal-${provider}` }, - }); + await runUiScenario({ + client, + agentId: agent.id, + scenario: buildNormalUsageScenario(provider, 7), + }); + + await client.deleteAgent(agent.id); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); + + test("queued-send-now path is stable under overlap", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { + [provider]: createRealAgentClient(provider, logger), + } as Partial>, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const scenarios = [ + buildOverlapScenario(provider, 17, "last"), + buildOverlapScenario(provider, 31, "first"), + ]; + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: `ui-stress-overlap-${provider}` }, + }); + + for (const scenario of scenarios) { const agent = await client.createAgent({ cwd, - title: `uist-n-${provider}`, + title: `uist-o-${provider}`, ...getFullAccessConfig(provider), }); await runUiScenario({ client, agentId: agent.id, - scenario: buildNormalUsageScenario(provider, 7), + scenario, }); await client.deleteAgent(agent.id); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); } - }, - 420_000, - ); - - test.runIf(shouldRun)( - "queued-send-now path is stable under overlap", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { - [provider]: createRealAgentClient(provider, logger), - } as Partial>, - logger, - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const scenarios = [ - buildOverlapScenario(provider, 17, "last"), - buildOverlapScenario(provider, 31, "first"), - ]; - - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-overlap-${provider}` }, - }); - - for (const scenario of scenarios) { - const agent = await client.createAgent({ - cwd, - title: `uist-o-${provider}`, - ...getFullAccessConfig(provider), - }); - - await runUiScenario({ - client, - agentId: agent.id, - scenario, - }); - - await client.deleteAgent(agent.id); - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 600_000, - ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); }); diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 828c60f45..002478abc 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -33,11 +33,11 @@ export { export { applyProviderEnv } from "./agent/provider-launch-config.js"; export { findExecutable, - findExecutableSync, + executableExists, quoteWindowsArgument, quoteWindowsCommand, } from "../utils/executable.js"; -export { spawnProcess } from "../utils/spawn.js"; +export { execCommand, spawnProcess } from "../utils/spawn.js"; // Provider manifest (source of truth for provider definitions) export { diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index c3a2d4ab9..b049c04f1 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -1,8 +1,6 @@ import { randomUUID } from "node:crypto"; -import { execFile } from "node:child_process"; import { promises as fs } from "node:fs"; import path from "node:path"; -import { promisify } from "node:util"; import { z } from "zod"; import type { Logger } from "pino"; import { curateAgentActivity } from "./agent/activity-curator.js"; @@ -15,8 +13,8 @@ import type { AgentTimelineItem, AgentProvider, } from "./agent/agent-sdk-types.js"; +import { execCommand, platformShell } from "../utils/spawn.js"; -const execFileAsync = promisify(execFile); const LOOP_ID_LENGTH = 8; const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude"; const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024; @@ -256,7 +254,8 @@ async function runVerifyCheck(options: { }): Promise { const startedAt = nowIso(); try { - const result = await execFileAsync("/bin/sh", ["-lc", options.command], { + const shell = platformShell(); + const result = await execCommand(shell.command, [...shell.flag, options.command], { cwd: options.cwd, maxBuffer: MAX_VERIFY_OUTPUT_BYTES, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bcbe0e340..95e2505bb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { stat } from "fs/promises"; -import { exec, execFile } from "node:child_process"; +import { exec } from "node:child_process"; import { promisify } from "util"; import { resolve, sep } from "path"; import { homedir } from "node:os"; @@ -170,6 +170,7 @@ import { ChatServiceError, FileBackedChatService } from "./chat/chat-service.js" import { notifyChatMentions } from "./chat/chat-mentions.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; +import { execCommand } from "../utils/spawn.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, @@ -182,7 +183,6 @@ import { } from "./worktree-session.js"; const execAsync = promisify(exec); -const execFileAsync = promisify(execFile); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const pendingAgentInitializations = new Map>(); const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; @@ -3531,7 +3531,7 @@ export class Session { private async checkoutExistingBranch(cwd: string, branch: string): Promise { this.assertSafeGitRef(branch, "branch"); try { - await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd }); + await execCommand("git", ["rev-parse", "--verify", branch], { cwd }); } catch (error) { throw new Error(`Branch not found: ${branch}`); } @@ -3545,7 +3545,7 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execFileAsync("git", ["checkout", branch], { cwd }); + await execCommand("git", ["checkout", branch], { cwd }); } private async createBranchFromBase(params: { @@ -3558,7 +3558,7 @@ export class Session { this.assertSafeGitRef(newBranchName, "new branch"); try { - await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd }); + await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd }); } catch (error) { throw new Error(`Base branch not found: ${baseBranch}`); } @@ -3569,7 +3569,7 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], { + await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], { cwd, }); } @@ -3577,7 +3577,7 @@ export class Session { private async doesLocalBranchExist(cwd: string, branch: string): Promise { this.assertSafeGitRef(branch, "branch"); try { - await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { + await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd, }); return true; @@ -4030,7 +4030,7 @@ export class Session { // Try local branch first try { - await execFileAsync("git", ["rev-parse", "--verify", branchName], { + await execCommand("git", ["rev-parse", "--verify", branchName], { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, }); @@ -4051,7 +4051,7 @@ export class Session { // Try remote branch (origin/{branchName}) try { - await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], { + await execCommand("git", ["rev-parse", "--verify", `origin/${branchName}`], { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, }); @@ -4281,7 +4281,7 @@ export class Session { const message = branchLabel ? `${Session.PASEO_STASH_PREFIX} ${branchLabel}` : `${Session.PASEO_STASH_PREFIX} unnamed`; - await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd }); + await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ type: "stash_save_response", @@ -4300,7 +4300,7 @@ export class Session { ): Promise { const { cwd, stashIndex, requestId } = msg; try { - await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd }); + await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ type: "stash_pop_response", diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index 52a43383f..cb10035c8 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -1,11 +1,11 @@ #!/usr/bin/env node import { Command } from "commander"; -import { spawnSync } from "node:child_process"; import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { FileTaskStore } from "./task-store.js"; import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js"; import { resolvePackageVersion } from "../server/package-version.js"; +import { spawnProcess } from "../utils/spawn.js"; import type { AgentType, ModelName, Task } from "./types.js"; const TASKS_DIR = resolve(process.cwd(), ".tasks"); @@ -638,11 +638,11 @@ function getAgentConfig(modelStr: string): AgentConfig { return { cli: "claude", model }; } -function runAgentWithModel( +async function runAgentWithModel( prompt: string, modelStr: string, logFile: string, -): { success: boolean; output: string } { +): Promise<{ success: boolean; output: string }> { const config = getAgentConfig(modelStr); let args: string[]; @@ -667,20 +667,34 @@ function runAgentWithModel( args.push(prompt); } - const result = spawnSync(config.cli, args, { - stdio: ["inherit", "pipe", "pipe"], - cwd: process.cwd(), - maxBuffer: 50 * 1024 * 1024, + const { stdout, stderr, exitCode } = await new Promise<{ + stdout: string; + stderr: string; + exitCode: number | null; + }>((resolve, reject) => { + const child = spawnProcess(config.cli, args, { + stdio: ["inherit", "pipe", "pipe"], + cwd: process.cwd(), + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + child.on("error", reject); + child.on("close", (code) => { + resolve({ + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + exitCode: code, + }); + }); }); - - const stdout = result.stdout?.toString() ?? ""; - const stderr = result.stderr?.toString() ?? ""; const output = stdout + stderr; // Append to log file for history appendFileSync(logFile, output); - return { success: result.status === 0, output }; + return { success: exitCode === 0, output }; } async function buildTaskContext(task: Task, scopeId?: string): Promise { @@ -1102,7 +1116,7 @@ program log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`); const context = await buildTaskContext(task, scopeId); const plannerPrompt = makePlannerPrompt(task, context, reason); - runAgentWithModel(plannerPrompt, plannerModel, logFile); + await runAgentWithModel(plannerPrompt, plannerModel, logFile); // Check if planner created subtasks for this task const children = await store.getChildren(task.id); @@ -1221,7 +1235,7 @@ program const freshContext = await buildTaskContext(freshTask, scopeId); const workerPrompt = makeWorkerPrompt(freshTask, freshContext, iteration); - runAgentWithModel(workerPrompt, workerModel, logFile); + await runAgentWithModel(workerPrompt, workerModel, logFile); // Step 3: Judge log(logFile, `[JUDGE] Verifying with ${judgeModel}...`); @@ -1229,7 +1243,7 @@ program if (!judgeTask) break; const judgePrompt = makeJudgePrompt(judgeTask); - const judgeResult = runAgentWithModel(judgePrompt, judgeModel, logFile); + const judgeResult = await runAgentWithModel(judgePrompt, judgeModel, logFile); const verdict = parseJudgeVerdict(judgeResult.output); log(logFile, `[JUDGE] Verdict: ${verdict || "UNKNOWN"}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 10d4a972a..45cd97f35 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,5 +1,3 @@ -import { execFile } from "child_process"; -import { promisify } from "util"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, stat as statFile } from "fs/promises"; @@ -8,10 +6,9 @@ import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { findExecutable } from "./executable.js"; import { runGitCommand } from "./run-git-command.js"; +import { execCommand } from "./spawn.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; - -const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", @@ -1899,7 +1896,7 @@ export async function createPullRequest( if (options.body) { args.push("-f", `body=${options.body}`); } - const { stdout } = await execFileAsync(ghPath, args, { cwd, env: ghEnv }); + const { stdout } = await execCommand(ghPath, args, { cwd, env: ghEnv }); const parsed = JSON.parse(stdout.trim()); if (!parsed?.url || !parsed?.number) { throw new Error("GitHub CLI did not return PR url/number"); @@ -1951,7 +1948,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise void; -type FindExecutableDependencies = NonNullable[1]>; +async function loadExecutableModule(params?: { + execFileImpl?: ( + command: string, + args: string[], + options: unknown, + callback: ExecFileCallback, + ) => void; +}) { + vi.resetModules(); -function createFindExecutableDependencies(): FindExecutableDependencies { + const execFileMock = vi.fn( + params?.execFileImpl ?? + ((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(new Error("execFile not mocked"), "", ""); + }), + ); + Object.assign(execFileMock, { + [promisify.custom]: (command: string, args: string[], options: unknown) => + new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFileMock( + command, + args, + options, + (error: Error | null, stdout: string, stderr: string) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }, + ); + }), + }); + + vi.doMock("node:child_process", () => ({ + execFile: execFileMock, + })); + const module = await import("./executable.js"); return { - execFileSync: vi.fn(), - existsSync: vi.fn(), - platform: vi.fn(() => "darwin"), + ...module, + execFileMock, }; } -let findExecutableDependencies: FindExecutableDependencies; +describe("findExecutable", () => { + const originalPlatform = process.platform; -beforeEach(() => { - findExecutableDependencies = createFindExecutableDependencies(); -}); + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } -describe("findExecutableSync", () => { - test("on Windows, resolves executables using where.exe with inherited PATH", () => { - findExecutableDependencies.platform = vi.fn(() => "win32"); - findExecutableDependencies.execFileSync.mockReturnValue( - "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", - ); + afterEach(() => { + setPlatform(originalPlatform); + }); - expect(findExecutableSync("claude", findExecutableDependencies)).toBe( + test("on Windows, resolves executables using where.exe with inherited PATH", async () => { + setPlatform("win32"); + const { execFileMock, findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", ""); + }, + }); + + await expect(findExecutable("claude")).resolves.toBe( "C:\\Users\\boudr\\.local\\bin\\claude.exe", ); - expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce(); - const call = findExecutableDependencies.execFileSync.mock.calls[0]; + expect(execFileMock).toHaveBeenCalledOnce(); + const call = execFileMock.mock.calls[0]; expect(call?.[0]).toBe("where.exe"); expect(call?.[1]).toEqual(["claude"]); - expect(call?.[2]?.encoding).toBe("utf8"); - expect(call?.[2]?.windowsHide).toBe(true); - }); - - test("on Windows, preserves the first where.exe match", () => { - findExecutableDependencies.platform = vi.fn(() => "win32"); - findExecutableDependencies.execFileSync.mockReturnValue( - "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", - ); - - expect(findExecutableSync("codex", findExecutableDependencies)).toBe( - "C:\\nvm4w\\nodejs\\codex", - ); - }); - - test("on Unix, uses the last line from which output", () => { - findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n"); - - expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); - expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], { + expect(call?.[2]).toMatchObject({ encoding: "utf8", + windowsHide: true, }); }); - test("warns and returns null when the final which line is not an absolute path", () => { - findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + test("on Windows, prefers an executable match from where.exe output", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", ""); + }, + }); - expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull(); + await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd"); + }); + + test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback( + null, + [ + "C:\\nvm4w\\nodejs\\codex", + "C:\\nvm4w\\nodejs\\codex.ps1", + "C:\\nvm4w\\nodejs\\codex.cmd", + "C:\\nvm4w\\nodejs\\codex.exe", + ].join("\r\n"), + "", + ); + }, + }); + + await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe"); + }); + + test("on Windows, returns null when where.exe output is empty", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "\r\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBeNull(); + }); + + test("on Windows, falls back to the first extensionless candidate when needed", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex"); + }); + + test("on Unix, uses the last line from which output", async () => { + const { execFileMock, findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "/usr/local/bin/codex\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex"); + expect(execFileMock).toHaveBeenCalledWith( + "which", + ["codex"], + { encoding: "utf8" }, + expect.any(Function), + ); + }); + + test("warns and returns null when the final which line is not an absolute path", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "codex\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBeNull(); expect(warnSpy).toHaveBeenCalledOnce(); warnSpy.mockRestore(); }); - test("returns direct paths when they exist", () => { - findExecutableDependencies.existsSync.mockReturnValue(true); + test("returns null when which lookup fails", async () => { + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(new Error("which failed"), "", ""); + }, + }); - expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe( - "/usr/local/bin/codex", - ); - expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex"); + await expect(findExecutable("codex")).resolves.toBeNull(); + }); +}); + +describe("executableExists", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("returns the path when it already exists", async () => { + const { executableExists } = await loadExecutableModule(); + const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex"); + + expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex"); + }); + + test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", async () => { + setPlatform("win32"); + const { executableExists } = await loadExecutableModule(); + const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd"); + + expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd"); + }); + + test("returns null when no matching path exists", async () => { + const { executableExists } = await loadExecutableModule(); + const exists = vi.fn(() => false); + + expect(executableExists("/missing/codex", exists)).toBeNull(); }); }); @@ -87,27 +218,31 @@ describe("quoteWindowsCommand", () => { setPlatform(originalPlatform); }); - test("quotes a Windows path with spaces", () => { + test("quotes a Windows path with spaces", async () => { setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( '"C:\\Program Files\\Anthropic\\claude.exe"', ); }); - test("does not double-quote an already-quoted path", () => { + test("does not double-quote an already-quoted path", async () => { setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( '"C:\\Program Files\\Anthropic\\claude.exe"', ); }); - test("returns the command unchanged when there are no spaces", () => { + test("returns the command unchanged when there are no spaces", async () => { setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); }); - test("returns the command unchanged on non-Windows platforms", () => { + test("returns the command unchanged on non-Windows platforms", async () => { setPlatform("darwin"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); }); }); @@ -123,27 +258,31 @@ describe("quoteWindowsArgument", () => { setPlatform(originalPlatform); }); - test("quotes a Windows argument with spaces", () => { + test("quotes a Windows argument with spaces", async () => { setPlatform("win32"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( '"C:\\Program Files\\Anthropic\\cli.js"', ); }); - test("does not double-quote an already-quoted argument", () => { + test("does not double-quote an already-quoted argument", async () => { setPlatform("win32"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( '"C:\\Program Files\\Anthropic\\cli.js"', ); }); - test("returns the argument unchanged when there are no spaces", () => { + test("returns the argument unchanged when there are no spaces", async () => { setPlatform("win32"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument("--version")).toBe("--version"); }); - test("returns the argument unchanged on non-Windows platforms", () => { + test("returns the argument unchanged on non-Windows platforms", async () => { setPlatform("darwin"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); }); }); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 25a91acab..0c71db88e 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -1,15 +1,21 @@ -import { execFile, execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { platform } from "node:os"; -import path from "node:path"; +import path, { extname } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -export interface FindExecutableDependencies { - execFileSync: typeof execFileSync; - existsSync: typeof existsSync; - platform: typeof platform; +function pickBestWindowsCandidate(lines: string[]): string | null { + const candidates = lines.filter((line) => line.length > 0); + if (candidates.length === 0) return null; + + const extPriority = [".exe", ".cmd", ".ps1"]; + for (const ext of extPriority) { + const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext)); + if (match) return match; + } + + return candidates[0] ?? null; } function resolveExecutableFromWhichOutput( @@ -44,58 +50,18 @@ function resolveExecutableFromWhichOutput( * enriches it at startup via inheritLoginShellEnv(); on Windows, Electron * inherits the full user environment from Explorer. */ -export function findExecutableSync( - name: string, - dependencies?: FindExecutableDependencies, +export function executableExists( + executablePath: string, + exists: typeof existsSync = existsSync, ): string | null { - const trimmed = name.trim(); - if (!trimmed) { - return null; - } - - const deps: FindExecutableDependencies = { - execFileSync, - existsSync, - platform, - ...dependencies, - }; - - if (trimmed.includes("/") || trimmed.includes("\\")) { - return deps.existsSync(trimmed) ? trimmed : null; - } - - if (deps.platform() === "win32") { - try { - const out = deps - .execFileSync("where.exe", [trimmed], { - encoding: "utf8", - windowsHide: true, - }) - .trim(); - return ( - out - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? null - ); - } catch { - return null; + if (exists(executablePath)) return executablePath; + if (process.platform === "win32" && !extname(executablePath)) { + for (const ext of [".exe", ".cmd", ".ps1"]) { + const candidate = executablePath + ext; + if (exists(candidate)) return candidate; } } - - try { - return resolveExecutableFromWhichOutput( - trimmed, - deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(), - "which", - ); - } catch { - return null; - } -} - -export function isCommandAvailableSync(command: string): boolean { - return findExecutableSync(command) !== null; + return null; } export async function findExecutable(name: string): Promise { @@ -105,21 +71,22 @@ export async function findExecutable(name: string): Promise { } if (trimmed.includes("/") || trimmed.includes("\\")) { - return existsSync(trimmed) ? trimmed : null; + return executableExists(trimmed); } - if (platform() === "win32") { + if (process.platform === "win32") { try { const { stdout } = await execFileAsync("where.exe", [trimmed], { encoding: "utf8", windowsHide: true, }); return ( - stdout - .trim() - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? null + pickBestWindowsCandidate( + stdout + .trim() + .split(/\r?\n/) + .map((line) => line.trim()), + ) ?? null ); } catch { return null; diff --git a/packages/server/src/utils/spawn.test.ts b/packages/server/src/utils/spawn.test.ts new file mode 100644 index 000000000..648d01d22 --- /dev/null +++ b/packages/server/src/utils/spawn.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { execCommand } from "./spawn.js"; + +describe("execCommand", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + test("returns stdout and stderr for a successful command", async () => { + await expect(execCommand("echo", ["hello"])).resolves.toEqual({ + stdout: "hello\n", + stderr: "", + }); + }); + + test("rejects when the command times out", async () => { + const command = + process.platform === "win32" + ? { + command: process.execPath, + args: ["-e", "setTimeout(() => {}, 10_000)"], + } + : { command: "sleep", args: ["10"] }; + + await expect(execCommand(command.command, command.args, { timeout: 100 })).rejects.toThrow(); + }); + + test("runs the command in the provided cwd", async () => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "spawn-test-"))); + tempDirs.push(cwd); + + const command = + process.platform === "win32" + ? { + command: process.execPath, + args: ["-e", "console.log(process.cwd())"], + } + : { command: "pwd", args: [] }; + + await expect(execCommand(command.command, command.args, { cwd })).resolves.toEqual({ + stdout: `${cwd}\n`, + stderr: "", + }); + }); +}); diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 32c1d911d..1168a59e6 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,15 +1,27 @@ -import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +interface ExecCommandOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + encoding?: BufferEncoding; + timeout?: number; + maxBuffer?: number; +} + +interface ExecCommandResult { + stdout: string; + stderr: string; +} + +function quoteForCmd(value: string): string { + if (!value.includes(" ")) return value; + if (value.startsWith('"') && value.endsWith('"')) return value; + return `"${value}"`; +} -/** - * Platform-aware spawn that centralizes Windows shell and quoting concerns. - * - * On Windows: - * - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell` - * - Quotes the command and arguments so paths with spaces survive cmd.exe parsing - * - Always sets `windowsHide: true` to prevent console window flashes - * - * On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`). - */ export function spawnProcess( command: string, args: string[], @@ -27,12 +39,38 @@ export function spawnProcess( }); } -/** - * Quote a string for cmd.exe if it contains spaces and isn't already quoted. - * No-op for strings without spaces or strings that are already double-quoted. - */ -function quoteForCmd(value: string): string { - if (!value.includes(" ")) return value; - if (value.startsWith('"') && value.endsWith('"')) return value; - return `"${value}"`; +export async function execCommand( + command: string, + args: string[], + options?: ExecCommandOptions, +): Promise { + const isWindows = process.platform === "win32"; + const resolvedCommand = isWindows ? quoteForCmd(command) : command; + const resolvedArgs = isWindows ? args.map(quoteForCmd) : args; + + return execFileAsync(resolvedCommand, resolvedArgs, { + cwd: options?.cwd, + env: options?.env, + encoding: options?.encoding ?? "utf8", + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + shell: isWindows, + windowsHide: true, + }) as Promise; +} + +export function platformShell(): { command: string; flag: string[] } { + if (process.platform === "win32") { + return { command: "cmd.exe", flag: ["/c"] }; + } + + return { command: "/bin/sh", flag: ["-lc"] }; +} + +export function platformBash(): { command: string; flag: string[] } { + if (process.platform === "win32") { + return { command: "cmd.exe", flag: ["/c"] }; + } + + return { command: "/bin/bash", flag: ["-lc"] }; } diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 38d74268c..e114dce9d 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,4 +1,4 @@ -import { exec, spawn } from "child_process"; +import { exec } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; @@ -13,6 +13,7 @@ import { writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; import { runGitCommand } from "./run-git-command.js"; +import { platformBash, spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { @@ -204,7 +205,7 @@ async function execSetupCommand( const { stdout, stderr } = await execAsync(command, { cwd: options.cwd, env: options.env, - shell: "/bin/bash", + ...(process.platform === "win32" ? {} : { shell: "/bin/bash" }), }); return { command, @@ -275,7 +276,8 @@ async function execSetupCommandStreamed(options: { cwd: options.cwd, }); - const child = spawn("/bin/bash", ["-lc", options.command], { + const shell = platformBash(); + const child = spawnProcess(shell.command, [...shell.flag, options.command], { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], From a2d652d8d82467b71f160394c7567cbfdc368b62 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:35:26 +0800 Subject: [PATCH 09/21] Add `max` reasoning effort option for Opus 4.6 models (#322) The codebase already has full backend support for "max" effort in types, validation, SDK passthrough, and error handling. The only missing piece was the `CLAUDE_THINKING_OPTIONS` array, which didn't surface "max" in the UI dropdown. Fixes #271 Co-authored-by: Claude Code --- .../server/src/server/agent/providers/claude/claude-models.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/server/agent/providers/claude/claude-models.ts b/packages/server/src/server/agent/providers/claude/claude-models.ts index 3d11cf7fa..e59d19384 100644 --- a/packages/server/src/server/agent/providers/claude/claude-models.ts +++ b/packages/server/src/server/agent/providers/claude/claude-models.ts @@ -4,6 +4,7 @@ const CLAUDE_THINKING_OPTIONS = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, + { id: "max", label: "Max" }, ] as const; const CLAUDE_MODELS: AgentModelDefinition[] = [ From 8023e1f0c37cc730be03b99691352cb6e0a6119f Mon Sep 17 00:00:00 2001 From: cjh-store <88535245+cjh-store@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:07:30 +0800 Subject: [PATCH 10/21] fix: skip Enter key handling during IME composition (#270) * fix: skip Enter key handling during IME composition During CJK input (Chinese, Japanese, Korean), pressing Enter while composing characters should confirm character selection, not send the message. Check isComposing and keyCode 229 on key events before handling Enter, matching react-native-web's own IME detection logic. Fixes #241, #264 * fix: scope IME composition guard to web-only handler Move isComposing check to top of handleDesktopKeyPress so it never leaks into cross-platform onKeyPress interfaces. This keeps CJK IME handling explicit and web-gated. * refactor: remove IS_WEB alias, use isWeb from platform.ts directly --------- Co-authored-by: Mohamed Boudra --- packages/app/src/components/message-input.tsx | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 80c9e2054..fbe4cb3d5 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -115,13 +115,15 @@ export interface MessageInputRef { const MIN_INPUT_HEIGHT = 30; const MAX_INPUT_HEIGHT = 160; -const IS_WEB = isWeb; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; + // Web-only: present on DOM KeyboardEvent during IME composition (CJK input). + isComposing?: boolean; + keyCode?: number; } >; @@ -222,7 +224,7 @@ export const MessageInput = forwardRef(funct ref, ) { const { theme } = useUnistyles(); - const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg; + const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; markScrollInvestigationRender(investigationComponentId); const toast = useToast(); @@ -293,7 +295,7 @@ export const MessageInput = forwardRef(funct return false; }, getNativeElement: () => { - if (!IS_WEB) return null; + if (!isWeb) return null; const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null; const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; return native instanceof HTMLElement ? native : null; @@ -328,7 +330,7 @@ export const MessageInput = forwardRef(funct // Autofocus on web when autoFocus is true, and re-run when focus key changes. useEffect(() => { - if (!IS_WEB || !autoFocus) return; + if (!isWeb || !autoFocus) return; return focusWithRetries({ focus: () => textInputRef.current?.focus(), isFocused: () => { @@ -371,7 +373,7 @@ export const MessageInput = forwardRef(funct onChangeText(nextValue); } - if (IS_WEB && typeof requestAnimationFrame === "function") { + if (isWeb && typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { measureWebInputHeight("dictation"); }); @@ -627,13 +629,13 @@ export const MessageInput = forwardRef(funct const webTextareaRef = useRef(null); useLayoutEffect(() => { - if (IS_WEB) { + if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } }, [getWebTextArea]); const inputScrollbar = useWebElementScrollbar(webTextareaRef, { - enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT, + enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, }); const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { @@ -647,7 +649,7 @@ export const MessageInput = forwardRef(funct }, []); useEffect(() => { - if (!IS_WEB || !onAddImages) { + if (!isWeb || !onAddImages) { return; } @@ -700,7 +702,7 @@ export const MessageInput = forwardRef(funct ]); useEffect(() => { - if (!IS_WEB || typeof ResizeObserver === "undefined") { + if (!isWeb || typeof ResizeObserver === "undefined") { return; } @@ -754,7 +756,7 @@ export const MessageInput = forwardRef(funct }, [getWebElement, getWebTextArea]); useEffect(() => { - if (!IS_WEB) { + if (!isWeb) { return; } const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; @@ -792,7 +794,7 @@ export const MessageInput = forwardRef(funct }, [getWebTextArea]); function measureWebInputHeight(source: string): boolean { - if (!IS_WEB) return false; + if (!isWeb) return false; const textarea = getWebTextArea(); if (!textarea || typeof textarea.scrollHeight !== "number") return false; const scrollHeight = textarea.scrollHeight ?? 0; @@ -846,7 +848,7 @@ export const MessageInput = forwardRef(funct event: NativeSyntheticEvent, ) { const contentHeight = event.nativeEvent.contentSize.height; - if (IS_WEB) { + if (isWeb) { logWebStickyBottom("composer_content_size_change", { reportedHeight: contentHeight, }); @@ -866,7 +868,7 @@ export const MessageInput = forwardRef(funct function handleSelectionChange(event: NativeSyntheticEvent) { const start = event.nativeEvent.selection?.start ?? 0; const end = event.nativeEvent.selection?.end ?? start; - if (IS_WEB) { + if (isWeb) { const textarea = getWebTextArea(); logWebStickyBottom("composer_selection_changed", { now: getDebugNow(), @@ -880,12 +882,17 @@ export const MessageInput = forwardRef(funct onSelectionChangeCallback?.({ start, end }); } - const shouldHandleDesktopSubmit = IS_WEB; + const shouldHandleDesktopSubmit = isWeb; function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { markScrollInvestigationEvent(investigationComponentId, "keyPress"); if (!shouldHandleDesktopSubmit) return; + // IME composition in progress (e.g. CJK input) — all key events belong to the + // IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is + // cleared before the keydown fires. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + // Allow parent to intercept key events (e.g., for autocomplete navigation) if (onKeyPressCallback) { const handled = onKeyPressCallback({ @@ -935,7 +942,7 @@ export const MessageInput = forwardRef(funct (nextValue: string) => { markScrollInvestigationEvent(investigationComponentId, "inputChange"); onChangeText(nextValue); - if (IS_WEB) { + if (isWeb) { logWebStickyBottom("composer_text_changed", { valueLength: nextValue.length, lineCount: nextValue.split("\n").length, @@ -966,7 +973,7 @@ export const MessageInput = forwardRef(funct @@ -998,7 +1005,7 @@ export const MessageInput = forwardRef(funct }} style={[ styles.textInput, - IS_WEB + isWeb ? { height: inputHeight, minHeight: MIN_INPUT_HEIGHT, @@ -1010,12 +1017,12 @@ export const MessageInput = forwardRef(funct }, ]} multiline - scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} + scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} - autoFocus={IS_WEB && autoFocus} + autoFocus={isWeb && autoFocus} /> {inputScrollbar} @@ -1206,7 +1213,7 @@ const styles = StyleSheet.create(((theme: any) => ({ xs: theme.spacing[3], md: theme.spacing[4], }, - ...(IS_WEB + ...(isWeb ? { transitionProperty: "border-color", transitionDuration: "200ms", @@ -1225,7 +1232,7 @@ const styles = StyleSheet.create(((theme: any) => ({ borderWidth: 1, borderColor: theme.colors.borderAccent, overflow: "hidden", - ...(IS_WEB + ...(isWeb ? { cursor: "pointer", } @@ -1250,7 +1257,7 @@ const styles = StyleSheet.create(((theme: any) => ({ justifyContent: "center", backgroundColor: "rgba(0, 0, 0, 0.5)", opacity: 0, - ...(IS_WEB + ...(isWeb ? { transitionProperty: "opacity", transitionDuration: "150ms", @@ -1269,7 +1276,7 @@ const styles = StyleSheet.create(((theme: any) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.base * 1.4, - ...(IS_WEB + ...(isWeb ? { outlineStyle: "none" as const, outlineWidth: 0, From 8122d501b09ea4bd12c4a1efbdcc52e04a3ba734 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 17:08:00 +0700 Subject: [PATCH 11/21] fix: add getId to workspace route so navigation updates params (#325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace Stack.Screen was missing a getId callback, causing expo-router to reuse the same screen instance across workspace navigations. This left stale params — sidebar switching and new workspace creation both appeared to navigate but the header stayed on the old workspace (skeleton for new workspaces). Adds e2e tests for sidebar workspace switching and new workspace creation flow (URL, sidebar row, header, single draft tab). --- packages/app/e2e/helpers/new-workspace.ts | 231 ++++++++++++++++++++++ packages/app/e2e/new-workspace.spec.ts | 153 ++++++++++++++ packages/app/src/app/_layout.tsx | 25 ++- 3 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/helpers/new-workspace.ts create mode 100644 packages/app/e2e/new-workspace.spec.ts diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts new file mode 100644 index 000000000..e9d9a401a --- /dev/null +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -0,0 +1,231 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, type Page } from "@playwright/test"; +import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client"; +import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; +import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui"; +import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; + +type NewWorkspaceDaemonClient = Pick< + ServerDaemonClient, + | "archivePaseoWorktree" + | "archiveWorkspace" + | "close" + | "connect" + | "createPaseoWorktree" + | "openProject" +>; + +type NewWorkspaceDaemonClientConfig = { + url: string; + clientId: string; + clientType: "cli"; + webSocketFactory?: NodeWebSocketFactory; +}; + +type OpenProjectPayload = Awaited>; + +export type OpenedProject = { + workspaceId: string; + projectKey: string; + projectDisplayName: string; + workspaceName: string; +}; + +function getDaemonPort(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + if (daemonPort === "6767") { + throw new Error("E2E_DAEMON_PORT must not point at the developer daemon."); + } + return daemonPort; +} + +function getDaemonWsUrl(): string { + return `ws://127.0.0.1:${getDaemonPort()}/ws`; +} + +async function loadDaemonClientConstructor(): Promise< + new ( + config: NewWorkspaceDaemonClientConfig, + ) => NewWorkspaceDaemonClient +> { + const repoRoot = path.resolve(__dirname, "../../../../"); + const moduleUrl = pathToFileURL( + path.join(repoRoot, "packages/server/dist/server/server/exports.js"), + ).href; + const mod = (await import(moduleUrl)) as { + DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient; + }; + return mod.DaemonClient; +} + +function requireWorkspace(payload: OpenProjectPayload) { + if (payload.error) { + throw new Error(payload.error); + } + if (!payload.workspace) { + throw new Error("openProject returned no workspace."); + } + return payload.workspace; +} + +function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null { + const pathname = new URL(page.url()).pathname; + const match = pathname.match( + new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`), + ); + if (!match?.[1]) { + return null; + } + return decodeWorkspaceIdFromPathSegment(match[1]); +} + +function parseWorkspaceIdFromSidebarRowTestId( + testId: string, + input: { serverId: string; previousWorkspaceId: string }, +): string | null { + const prefix = `sidebar-workspace-row-${input.serverId}:`; + if (!testId.startsWith(prefix)) { + return null; + } + const workspaceId = testId.slice(prefix.length).trim(); + if (!workspaceId || workspaceId === input.previousWorkspaceId) { + return null; + } + return workspaceId; +} + +export async function connectNewWorkspaceDaemonClient(): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const webSocketFactory = createNodeWebSocketFactory(); + const client = new DaemonClient({ + url: getDaemonWsUrl(), + clientId: `app-e2e-new-workspace-${randomUUID()}`, + clientType: "cli", + webSocketFactory, + }); + await client.connect(); + return client; +} + +export async function openProjectViaDaemon( + client: NewWorkspaceDaemonClient, + repoPath: string, +): Promise { + const workspace = requireWorkspace(await client.openProject(repoPath)); + return { + workspaceId: workspace.id, + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + workspaceName: workspace.name, + }; +} + +export async function archiveWorkspaceFromDaemon( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId }); + if (payload.error) { + throw new Error(payload.error.message); + } + if (!payload.success) { + throw new Error(`Failed to archive workspace: ${workspaceId}`); + } +} + +export async function archiveLocalWorkspaceFromDaemon( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.archiveWorkspace(workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + if (!payload.archivedAt) { + throw new Error(`Failed to archive workspace: ${workspaceId}`); + } +} + +export async function createWorktreeViaDaemon( + client: NewWorkspaceDaemonClient, + input: { cwd: string; slug: string }, +): Promise { + const payload = await client.createPaseoWorktree({ + cwd: input.cwd, + worktreeSlug: input.slug, + }); + const workspace = requireWorkspace(payload); + return { + workspaceId: workspace.id, + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + workspaceName: workspace.name, + }; +} + +export async function clickNewWorkspaceButton( + page: Page, + input: { projectKey: string; projectDisplayName: string }, +): Promise { + const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first(); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await projectRow.hover(); + + const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first(); + await expect(button).toBeVisible({ timeout: 30_000 }); + await button.click(); +} + +export async function assertNewWorkspaceSidebarAndHeader( + page: Page, + input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string }, +): Promise<{ workspaceId: string }> { + let workspaceId: string | null = null; + const sidebarWorkspaceRows = page.locator( + `[data-testid^="sidebar-workspace-row-${input.serverId}:"]`, + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-testid") ?? ""), + ); + workspaceId = + sidebarRowTestIds + .map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input)) + .find((id) => id !== null) ?? null; + if (workspaceId) { + break; + } + await page.waitForTimeout(250); + } + + if (!workspaceId || workspaceId === input.previousWorkspaceId) { + const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-testid") ?? ""), + ); + throw new Error( + [ + "Expected a newly created workspace to load.", + `Current URL: ${page.url()}`, + `Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || ""}`, + ].join("\n"), + ); + } + + const createdWorkspaceRow = page.getByTestId( + `sidebar-workspace-row-${input.serverId}:${workspaceId}`, + ); + await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 }); + + await expectWorkspaceHeader(page, { + title: workspaceLabelFromPath(workspaceId), + subtitle: input.projectDisplayName, + }); + + return { workspaceId }; +} diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts new file mode 100644 index 000000000..e4aa23ff4 --- /dev/null +++ b/packages/app/e2e/new-workspace.spec.ts @@ -0,0 +1,153 @@ +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { expect, test } from "./fixtures"; +import { + archiveWorkspaceFromDaemon, + archiveLocalWorkspaceFromDaemon, + assertNewWorkspaceSidebarAndHeader, + clickNewWorkspaceButton, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + expectWorkspaceHeader, + switchWorkspaceViaSidebar, + workspaceLabelFromPath, +} from "./helpers/workspace-ui"; + +test.describe("New workspace flow", () => { + let client: Awaited>; + const localWorkspaceIds = new Set(); + const createdWorktreeIds = new Set(); + + test.describe.configure({ timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + }); + + test.afterEach(async () => { + if (client) { + for (const workspaceId of createdWorktreeIds) { + await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); + } + for (const workspaceId of localWorkspaceIds) { + await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); + } + } + createdWorktreeIds.clear(); + localWorkspaceIds.clear(); + await client?.close().catch(() => undefined); + }); + + test("sidebar workspace navigation updates URL and header", async ({ page }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const firstRepo = await createTempGitRepo("workspace-nav-a-"); + const secondRepo = await createTempGitRepo("workspace-nav-b-"); + + try { + const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path); + const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path); + localWorkspaceIds.add(firstWorkspace.workspaceId); + localWorkspaceIds.add(secondWorkspace.workspaceId); + + await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); + await expectWorkspaceHeader(page, { + title: firstWorkspace.workspaceName, + subtitle: workspaceLabelFromPath(firstRepo.path), + }); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: secondWorkspace.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: secondWorkspace.workspaceName, + subtitle: workspaceLabelFromPath(secondRepo.path), + }); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: firstWorkspace.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: firstWorkspace.workspaceName, + subtitle: workspaceLabelFromPath(firstRepo.path), + }); + } finally { + await secondRepo.cleanup(); + await firstRepo.cleanup(); + } + }); + + test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({ + page, + }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const tempRepo = await createTempGitRepo("new-workspace-"); + + try { + const openedProject = await openProjectViaDaemon(client, tempRepo.path); + localWorkspaceIds.add(openedProject.workspaceId); + + await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId)); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId)); + await expectWorkspaceHeader(page, { + title: openedProject.workspaceName, + subtitle: workspaceLabelFromPath(tempRepo.path), + }); + + await clickNewWorkspaceButton(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + + const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { + serverId, + previousWorkspaceId: openedProject.workspaceId, + projectDisplayName: openedProject.projectDisplayName, + }); + createdWorktreeIds.add(createdWorkspace.workspaceId); + + expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId); + await expect(page).toHaveURL( + buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId), + { + timeout: 30_000, + }, + ); + + const createdWorkspaceRow = page.getByTestId( + `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`, + ); + await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 }); + + await expectWorkspaceHeader(page, { + title: workspaceLabelFromPath(createdWorkspace.workspaceId), + subtitle: openedProject.projectDisplayName, + }); + + const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({ + has: page.getByText("New Agent", { exact: true }), + }); + await expect(draftTabs).toHaveCount(1, { timeout: 30_000 }); + + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(composer).toBeEditable({ timeout: 30_000 }); + } finally { + await tempRepo.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index dd68ff244..4422ba3c6 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -91,6 +91,22 @@ export type HostRuntimeBootstrapState = { retry: () => void; }; +function getRouteParamValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (Array.isArray(value)) { + const firstValue = value[0]; + if (typeof firstValue !== "string") { + return undefined; + } + const trimmed = firstValue.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + return undefined; +} + const HostRuntimeBootstrapContext = createContext({ phase: "starting-daemon", error: null, @@ -788,7 +804,14 @@ function RootStack() { - + { + const serverId = getRouteParamValue(params?.serverId); + const workspaceId = getRouteParamValue(params?.workspaceId); + return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined; + }} + /> From 6f28027687a8ffd71876f16999c879af1e5b047c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 19:42:01 +0700 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20provider=20profiles=20=E2=80=94?= =?UTF-8?q?=20custom=20provider=20definitions=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add provider profiles for custom provider definitions Users can define custom providers in config.json that appear as first-class entries alongside built-ins. A provider can override a built-in (custom binary, env, models) or create a new one by extending a base via `extends`. Generic ACP transport supported via `extends: "acp"`. Providers can be hidden with `enabled: false`. Hardcoded models merge with runtime-fetched ones. - Config schema with Zod validation, auto-migration from old format - Dynamic provider registry replaces static provider lists - GenericACPAgentClient for user-defined ACP providers - Snapshot entries carry label/description/defaultModeId over the wire - MCP tools accept dynamic provider IDs - App derives provider definitions from snapshot with static fallback - CLI `provider ls` calls daemon with label column - Schedule/session rehydration validates providers against registry * fix: accept any provider status in CLI provider ls test The test now connects to a real daemon where providers may be loading or unavailable, not just the static "available" fallback. * ci: re-trigger CI checks * style: fix checkout-git.ts formatting to match CI Biome version * fix: relax provider ls test assertions for daemon-backed responses The daemon snapshot may not include all 5 built-in providers in CI (some require external binaries). Assert at least the core 3 (claude, codex, opencode) instead of all 5. * fix app combobox dropdown positioning flash * refactor: stop merging models in provider registry, use override models directly Override models now replace instead of merge with base provider models. Also add icon/colorTier fallback from definition modes in fetchModes. * refactor: make provider definitions fully dynamic from server snapshots Remove static AGENT_PROVIDER_DEFINITIONS fallbacks from the client — providers, modes, icons, and color tiers now flow entirely from runtime snapshots. Add icon and colorTier to AgentMode schema so the server can advertise mode visuals directly. Fix setAgentMode to persist modeId in agent config so the selected mode survives session reload. Simplify model merging so profile models replace runtime models instead of prepending. * docs: add ad-hoc daemon testing guide --- docs/AD-HOC-DAEMON-TESTING.md | 159 +++++ .../agent-form/agent-form-dropdowns.tsx | 6 +- .../app/src/components/agent-status-bar.tsx | 29 +- .../components/provider-diagnostic-sheet.tsx | 7 +- packages/app/src/components/ui/combobox.tsx | 77 ++- .../src/hooks/use-agent-form-state.test.ts | 129 +++- .../app/src/hooks/use-agent-form-state.ts | 34 +- .../src/screens/agent/draft-agent-screen.tsx | 11 +- packages/app/src/screens/settings-screen.tsx | 5 +- .../app/src/utils/provider-definitions.ts | 47 ++ packages/cli/src/commands/provider/ls.ts | 48 +- packages/cli/tests/15-provider.test.ts | 21 +- .../src/server/agent/agent-management-mcp.ts | 4 +- .../src/server/agent/agent-manager.test.ts | 150 +++++ .../server/src/server/agent/agent-manager.ts | 10 +- .../src/server/agent/agent-sdk-types.ts | 5 + .../src/server/agent/mcp-server.test.ts | 77 +++ .../server/src/server/agent/mcp-server.ts | 4 +- .../server/src/server/agent/mcp-shared.ts | 12 +- .../agent/provider-launch-config.test.ts | 176 +++++- .../server/agent/provider-launch-config.ts | 76 +++ .../src/server/agent/provider-manifest.ts | 27 +- .../server/agent/provider-registry.test.ts | 584 ++++++++++++++++++ .../src/server/agent/provider-registry.ts | 415 ++++++++++++- .../agent/provider-snapshot-manager.test.ts | 148 ++++- .../server/agent/provider-snapshot-manager.ts | 19 +- .../agent/providers/generic-acp-agent.ts | 46 ++ packages/server/src/server/bootstrap.ts | 10 +- packages/server/src/server/config.ts | 60 +- packages/server/src/server/exports.ts | 7 +- .../src/server/persisted-config.test.ts | 292 ++++++++- .../server/src/server/persisted-config.ts | 124 +++- .../src/server/persistence-hooks.test.ts | 19 + .../server/src/server/persistence-hooks.ts | 24 +- .../server/src/server/schedule/service.ts | 9 +- packages/server/src/server/session.ts | 72 ++- .../websocket-server.notifications.test.ts | 1 + .../websocket-server.relay-reconnect.test.ts | 1 + .../server/src/server/websocket-server.ts | 10 +- packages/server/src/shared/messages.ts | 5 + 40 files changed, 2762 insertions(+), 198 deletions(-) create mode 100644 docs/AD-HOC-DAEMON-TESTING.md create mode 100644 packages/app/src/utils/provider-definitions.ts create mode 100644 packages/server/src/server/agent/provider-registry.test.ts create mode 100644 packages/server/src/server/agent/providers/generic-acp-agent.ts diff --git a/docs/AD-HOC-DAEMON-TESTING.md b/docs/AD-HOC-DAEMON-TESTING.md new file mode 100644 index 000000000..30c0d2e65 --- /dev/null +++ b/docs/AD-HOC-DAEMON-TESTING.md @@ -0,0 +1,159 @@ +# Ad-hoc daemon testing + +Spin up an isolated daemon programmatically without touching the main daemon on port 6767. + +## Quick start + +```typescript +import os from "node:os"; +import path from "node:path"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import pino from "pino"; +import { createPaseoDaemon } from "./bootstrap.js"; +import { DaemonClient } from "./test-utils/daemon-client.js"; + +const logger = pino({ level: "warn" }); +const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-")); +const paseoHome = path.join(paseoHomeRoot, ".paseo"); +await mkdir(paseoHome, { recursive: true }); +const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + +const daemon = await createPaseoDaemon( + { + listen: "127.0.0.1:0", // OS picks a free port + paseoHome, + corsAllowedOrigins: [], + allowedHosts: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: {}, + agentStoragePath: path.join(paseoHome, "agents"), + relayEnabled: false, + relayEndpoint: "relay.paseo.sh:443", + appBaseUrl: "https://app.paseo.sh", + // Add custom config here, e.g.: + // providerOverrides: { ... }, + }, + logger, +); + +await daemon.start(); +const target = daemon.getListenTarget(); +const port = target!.type === "tcp" ? target!.port : null; + +const client = new DaemonClient({ + url: `ws://127.0.0.1:${port}/ws`, + appVersion: "0.1.54", // see gotcha #1 +}); +await client.connect(); +await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + +// ... do your testing ... + +await client.close(); +await daemon.stop(); +await rm(paseoHomeRoot, { recursive: true, force: true }); +await rm(staticDir, { recursive: true, force: true }); +``` + +Run with: +```bash +npx tsx packages/server/src/server/your-script.ts +``` + +## Using the test helper + +For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection: + +```typescript +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { DaemonClient } from "./test-utils/daemon-client.js"; + +const daemon = await createTestPaseoDaemon(); +const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.54", +}); +await client.connect(); +await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + +// ... test ... + +await client.close(); +await daemon.close(); // stops daemon + cleans up temp dirs +``` + +The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above). + +## Common client methods + +```typescript +// Provider discovery +const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" }); +const models = await client.listProviderModels("claude"); +const modes = await client.listProviderModes("claude"); + +// Agent lifecycle +const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" }); +await client.sendMessage(agent.id, "Hello"); +const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle"); +``` + +## Gotchas + +### 1. appVersion gates provider visibility + +The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses. + +Always pass `appVersion`: +```typescript +const client = new DaemonClient({ + url: `ws://127.0.0.1:${port}/ws`, + appVersion: "0.1.54", +}); +``` + +### 2. Provider snapshots are async + +After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading: + +```typescript +let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" }); +for (let i = 0; i < 20; i++) { + const entry = snapshot.entries.find((e) => e.provider === "gemini"); + if (entry && entry.status !== "loading") break; + await new Promise((r) => setTimeout(r, 2_000)); + snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" }); +} +``` + +### 3. fetchAgents is required before most operations + +Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang. + +### 4. listen: "127.0.0.1:0" for port allocation + +Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs. + +### 5. Script must live inside packages/server + +The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors. + +### 6. Cleanup on failure + +Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails: + +```typescript +try { + // ... test logic ... +} finally { + await client.close(); + await daemon.stop().catch(() => undefined); + await rm(paseoHomeRoot, { recursive: true, force: true }); +} +``` + +### 7. ACP providers spawn real processes + +When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider. diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 5c5b596c8..d8a12afd3 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -557,7 +557,11 @@ export function AgentConfigRow({ const effectiveSelectedThinkingOption = selectedThinkingOptionId || thinkingSelectOptions[0]?.id || ""; - const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode); + const selectedModeVisuals = getModeVisuals( + selectedProvider, + effectiveSelectedMode, + providerDefinitions, + ); const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"]; const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"]; diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index f18afc048..f79392b9d 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { buildFavoriteModelKey, mergeProviderPreferences, @@ -40,7 +41,6 @@ import type { } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { - AGENT_PROVIDER_DEFINITIONS, getModeVisuals, type AgentModeColorTier, type AgentModeIcon, @@ -60,10 +60,6 @@ type StatusOption = { type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; -const PROVIDER_DEFINITION_MAP = new Map( - AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), -); - type ControlledAgentStatusBarProps = { provider: string; providerOptions?: StatusOption[]; @@ -81,7 +77,7 @@ type ControlledAgentStatusBarProps = { onSelectThinkingOption?: (thinkingOptionId: string) => void; disabled?: boolean; isModelLoading?: boolean; - providerDefinitions?: AgentProviderDefinition[]; + providerDefinitions: AgentProviderDefinition[]; allProviderModels?: Map; canSelectModelProvider?: (providerId: string) => boolean; favoriteKeys?: Set; @@ -252,7 +248,9 @@ function ControlledStatusBar({ thinkingOptions?.[0]?.label ?? "Unknown", ); - const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined; + const modeVisuals = selectedModeId + ? getModeVisuals(provider, selectedModeId, providerDefinitions) + : undefined; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette); const ProviderIcon = getProviderIcon(provider); @@ -300,9 +298,7 @@ function ControlledStatusBar({ ); return map; }, [modelOptions, provider]); - const effectiveProviderDefinitions = - providerDefinitions ?? - (PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []); + const effectiveProviderDefinitions = providerDefinitions; const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels; const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true); const comboboxThinkingOptions = useMemo( @@ -322,7 +318,7 @@ function ControlledStatusBar({ active: boolean; onPress: () => void; }) => { - const visuals = getModeVisuals(provider, option.id); + const visuals = getModeVisuals(provider, option.id, providerDefinitions); const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; return ( ); }, - [provider, theme.colors.foreground], + [provider, providerDefinitions, theme.colors.foreground], ); const handleOpenChange = useCallback( @@ -745,7 +741,7 @@ function ControlledStatusBar({ {modeOptions.map((mode) => { - const visuals = getModeVisuals(provider, mode.id); + const visuals = getModeVisuals(provider, mode.id, providerDefinitions); const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; return ( { - const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); + const definition = agent?.provider + ? resolveProviderDefinition(agent.provider, snapshotEntries) + : undefined; return definition ? [definition] : []; - }, [agent?.provider]); + }, [agent?.provider, snapshotEntries]); const agentProviderModels = useMemo(() => { const map = new Map(); @@ -1128,6 +1126,7 @@ export function DraftAgentStatusBar({ /> (null); const [loading, setLoading] = useState(false); - const providerLabel = - AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider; + const providerLabel = resolveProviderLabel(provider, snapshotEntries); const fetchDiagnostic = useCallback(async () => { if (!client || !provider) return; diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 929dbb5e9..a646bac20 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -268,7 +268,7 @@ export function Combobox({ const isMobile = useIsCompactFormFactor(); const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; - const { height: windowHeight } = useWindowDimensions(); + const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const bottomSheetRef = useRef(null); const hasPresentedBottomSheetRef = useRef(false); const snapPoints = useMemo(() => ["60%", "90%"], []); @@ -276,11 +276,13 @@ export function Combobox({ null, ); const [referenceWidth, setReferenceWidth] = useState(null); + const [referenceLeft, setReferenceLeft] = useState(null); const [referenceTop, setReferenceTop] = useState(null); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(-1); const desktopOptionsScrollRef = useRef(null); + const [desktopContentWidth, setDesktopContentWidth] = useState(null); const isControlled = typeof open === "boolean"; const [internalOpen, setInternalOpen] = useState(false); @@ -324,7 +326,7 @@ export function Combobox({ const middleware = useMemo( () => [ - floatingOffset(isWeb ? 0 : 4), + floatingOffset(isWeb ? 5 : 4), ...(isWeb ? [] : [flip({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ @@ -338,6 +340,9 @@ export function Combobox({ }); setReferenceWidth((prev) => { const next = rects.reference.width; + if (!(next > 0)) { + return prev; + } if (prev === next) return prev; return next; }); @@ -359,15 +364,18 @@ export function Combobox({ useEffect(() => { if (!isOpen || isMobile) { setAvailableSize(null); + setDesktopContentWidth(null); + setReferenceLeft(null); setReferenceWidth(null); return; } const raf = requestAnimationFrame(() => void update()); return () => cancelAnimationFrame(raf); - }, [desktopPlacement, isMobile, update, isOpen]); + }, [desktopPlacement, isMobile, isOpen, update]); useEffect(() => { if (!isOpen || isMobile) { + setReferenceLeft(null); setReferenceAtOrigin(false); setReferenceTop(null); return; @@ -381,9 +389,16 @@ export function Combobox({ } const measure = () => { - referenceEl.measureInWindow((x, y) => { + referenceEl.measureInWindow((x, y, width, height) => { + setReferenceLeft((prev) => (prev === x ? prev : x)); setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); setReferenceTop((prev) => (prev === y ? prev : y)); + setReferenceWidth((prev) => { + if (!(width > 0)) { + return prev; + } + return prev === width ? prev : width; + }); }); }; @@ -398,32 +413,46 @@ export function Combobox({ isDesktopAboveSearch && referenceTop !== null ? Math.max(windowHeight - referenceTop, collisionPadding) : null; - const hasResolvedDesktopPosition = - referenceWidth !== null && - floatingLeft !== null && - (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && - ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); - const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; - const shouldUseDesktopFade = !desktopPreventInitialFlash; - // For top-placed popups: once position resolves, use bottom-based CSS positioning - // so height changes grow upward naturally without floating-ui needing to reposition. - const useStableBottom = + const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0; + const useMeasuredTopStartPosition = !isDesktopAboveSearch && IS_WEB && !isMobile && - hasResolvedDesktopPosition && - desktopPlacement.startsWith("top") && - referenceTop !== null; + desktopPlacement === "top-start" && + referenceTop !== null && + referenceLeft !== null && + desktopContentWidth !== null; + const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition + ? Math.max( + collisionPadding, + Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft), + ) + : null; + const measuredTopStartBottom = useMeasuredTopStartPosition + ? Math.max(windowHeight - referenceTop + 5, collisionPadding) + : null; + const hasResolvedDesktopPosition = + referenceWidth !== null && + referenceWidth > 0 && + (isDesktopAboveSearch + ? floatingLeft !== null && desktopAboveSearchBottom !== null + : useMeasuredTopStartPosition + ? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null + : floatingLeft !== null && + floatingTop !== null && + (hasNonZeroFloatingPosition || !referenceAtOrigin)); + const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; + const shouldUseDesktopFade = !desktopPreventInitialFlash; const desktopPositionStyle = isDesktopAboveSearch ? { left: floatingLeft ?? 0, bottom: desktopAboveSearchBottom ?? 0, } - : useStableBottom + : useMeasuredTopStartPosition ? { - left: floatingLeft ?? 0, - bottom: Math.max(windowHeight - referenceTop!, collisionPadding), + left: clampedMeasuredTopStartLeft ?? 0, + bottom: measuredTopStartBottom ?? 0, } : floatingStyles; @@ -728,7 +757,13 @@ export function Combobox({ ]} ref={refs.setFloating} collapsable={false} - onLayout={() => update()} + onLayout={(event) => { + const { width, height } = event.nativeEvent.layout; + setDesktopContentWidth((prev) => (prev === width ? prev : width)); + if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) { + void update(); + } + }} > {children ? ( <> diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index d909d817c..8b9457797 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -1,12 +1,117 @@ import { describe, expect, it } from "vitest"; import { __private__ } from "./use-agent-form-state"; -import { - AGENT_PROVIDER_DEFINITIONS, - type AgentProviderDefinition, -} from "@server/server/agent/provider-manifest"; -import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { buildProviderDefinitions } from "@/utils/provider-definitions"; +import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; +import type { + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "@server/server/agent/agent-sdk-types"; + +const TEST_CODEX_DEFINITION: AgentProviderDefinition = { + id: "codex", + label: "Codex", + description: "Codex test provider", + defaultModeId: "auto", + modes: [ + { id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" }, + { id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" }, + ], +}; + +const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = { + id: "claude", + label: "Claude", + description: "Claude test provider", + defaultModeId: "default", + modes: [ + { id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }, + { id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" }, + { id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" }, + { id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" }, + ], +}; + +function makeProviderMap( + ...definitions: AgentProviderDefinition[] +): Map { + return new Map(definitions.map((d) => [d.id as AgentProvider, d])); +} + +const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION); +const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION); describe("useAgentFormState", () => { + describe("buildProviderDefinitions", () => { + it("returns empty array when snapshot data is unavailable", () => { + expect(buildProviderDefinitions(undefined)).toEqual([]); + expect(buildProviderDefinitions([])).toEqual([]); + }); + + it("builds custom provider definitions from snapshot metadata", () => { + const entries: ProviderSnapshotEntry[] = [ + { + provider: "zai", + status: "ready", + label: "ZAI", + description: "Claude with ZAI config", + defaultModeId: "default", + modes: [ + { + id: "default", + label: "Default", + description: "Safe mode", + icon: "ShieldCheck", + colorTier: "safe", + }, + ], + }, + { + provider: "claude", + status: "ready", + label: "Claude", + description: "Anthropic Claude", + defaultModeId: "default", + modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }], + }, + ]; + + const definitions = buildProviderDefinitions(entries); + + expect(definitions).toEqual([ + { + id: "zai", + label: "ZAI", + description: "Claude with ZAI config", + defaultModeId: "default", + modes: [ + { + id: "default", + label: "Default", + description: "Safe mode", + icon: "ShieldCheck", + colorTier: "safe", + }, + ], + }, + { + id: "claude", + label: "Claude", + description: "Anthropic Claude", + defaultModeId: "default", + modes: [ + { + id: "default", + label: "Always Ask", + icon: "ShieldCheck", + colorTier: "safe", + }, + ], + }, + ]); + }); + }); + describe("__private__.combineInitialValues", () => { it("returns undefined when no initial values and no initial server id", () => { expect(__private__.combineInitialValues(undefined, null)).toBeUndefined(); @@ -78,6 +183,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), + codexProviderMap, ); expect(resolved.model).toBe("gpt-5.3-codex"); @@ -106,6 +212,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), + codexProviderMap, ); expect(resolved.model).toBe("gpt-5.3-codex"); @@ -134,6 +241,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), + codexProviderMap, ); expect(resolved.thinkingOptionId).toBe("xhigh"); @@ -161,6 +269,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), + codexProviderMap, ); expect(resolved.model).toBe("gpt-5.3-codex"); @@ -188,6 +297,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), + codexProviderMap, ); expect(resolved.model).toBe("gpt-5.3-codex"); @@ -215,6 +325,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), + codexProviderMap, ); expect(resolved.model).toBe("gpt-5.3-codex"); @@ -256,6 +367,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), + claudeProviderMap, ); expect(resolved.model).toBe("default"); @@ -263,11 +375,6 @@ describe("useAgentFormState", () => { }); it("resolves provider only from allowed provider map", () => { - const allowedProviderMap = new Map( - AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map( - (definition) => [definition.id as AgentProvider, definition], - ), - ); const resolved = __private__.resolveFormState( undefined, { provider: "codex" }, @@ -289,7 +396,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), - allowedProviderMap, + claudeProviderMap, ); expect(resolved.provider).toBe("claude"); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index a208c9a8b..b1df8ae9d 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,8 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - AGENT_PROVIDER_DEFINITIONS, - type AgentProviderDefinition, -} from "@server/server/agent/provider-manifest"; +import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentMode, AgentModelDefinition, @@ -10,6 +7,7 @@ import type { ProviderSnapshotEntry, } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; +import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, @@ -99,13 +97,8 @@ type UseAgentFormStateResult = { persistFormPreferences: () => Promise; }; -const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS; -const allProviderDefinitionMap = new Map( - allProviderDefinitions.map((definition) => [definition.id, definition]), -); -const fallbackDefinition = allProviderDefinitions[0]; -const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude"; -const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? ""; +const DEFAULT_PROVIDER: AgentProvider = "claude"; +const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default"; function normalizeSelectedModelId(modelId: string | null | undefined): string { const normalized = typeof modelId === "string" ? modelId.trim() : ""; @@ -180,7 +173,7 @@ function resolveFormState( userModified: UserModifiedFields, currentState: FormState, validServerIds: Set, - allowedProviderMap: Map = allProviderDefinitionMap, + allowedProviderMap: Map, ): FormState { // Start with current state - we only update non-user-modified fields const result = { ...currentState }; @@ -376,10 +369,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } = useProvidersSnapshot(formState.serverId); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); - const snapshotProviderDefinitions = useMemo(() => { - const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider)); - return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id)); - }, [snapshotEntries]); + const snapshotProviderDefinitions = useMemo( + () => buildProviderDefinitions(snapshotEntries), + [snapshotEntries], + ); const snapshotProviderDefinitionMap = useMemo( () => new Map( @@ -388,17 +381,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [snapshotProviderDefinitions], ); const snapshotSelectableProviderDefinitionMap = useMemo(() => { + if (!snapshotEntries?.length) { + return snapshotProviderDefinitionMap; + } const readyProviders = new Set( - (snapshotEntries ?? []) - .filter((entry) => entry.status === "ready") - .map((entry) => entry.provider), + snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider), ); return new Map( snapshotProviderDefinitions .filter((definition) => readyProviders.has(definition.id)) .map((definition) => [definition.id, definition]), ); - }, [snapshotEntries, snapshotProviderDefinitions]); + }, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]); const snapshotAllProviderModels = useMemo(() => { const map = new Map(); for (const entry of snapshotEntries ?? []) { diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 6e19d7db9..1cfcede0d 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -49,7 +49,6 @@ import type { AgentCapabilityFlags, AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -68,10 +67,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsReasoningStream: false, supportsToolInvocations: false, }; -const PROVIDER_DEFINITION_MAP = new Map( - AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), -); - function getParamValue(value: string | string[] | undefined) { if (typeof value === "string") { const trimmed = value.trim(); @@ -92,16 +87,14 @@ function getValidProvider(value: string | undefined) { if (!value) { return undefined; } - return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined; + return value as AgentProvider; } function getValidMode(provider: AgentProvider | undefined, value: string | undefined) { if (!provider || !value) { return undefined; } - const definition = PROVIDER_DEFINITION_MAP.get(provider); - const modes = definition?.modes ?? []; - return modes.some((mode) => mode.id === value) ? value : undefined; + return value; } type DraftAgentParams = { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index a70f80cd7..638d7a9a1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -68,10 +68,10 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { StatusBadge } from "@/components/ui/status-badge"; +import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { isWeb } from "@/constants/platform"; // --------------------------------------------------------------------------- @@ -523,6 +523,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { const isConnected = useHostRuntimeIsConnected(routeServerId); const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); const [diagnosticProvider, setDiagnosticProvider] = useState(null); + const providerDefinitions = buildProviderDefinitions(entries); const hasServer = routeServerId.length > 0; @@ -558,7 +559,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { ) : ( - {AGENT_PROVIDER_DEFINITIONS.map((def) => { + {providerDefinitions.map((def) => { const entry = entries?.find((e) => e.provider === def.id); const status = entry?.status ?? "unavailable"; const ProviderIcon = getProviderIcon(def.id); diff --git a/packages/app/src/utils/provider-definitions.ts b/packages/app/src/utils/provider-definitions.ts new file mode 100644 index 000000000..c7cdb5e41 --- /dev/null +++ b/packages/app/src/utils/provider-definitions.ts @@ -0,0 +1,47 @@ +import type { ProviderSnapshotEntry, AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { + type AgentModeColorTier, + type AgentModeIcon, + type AgentProviderDefinition, + type AgentProviderModeDefinition, +} from "@server/server/agent/provider-manifest"; + +function buildProviderModes(entry: ProviderSnapshotEntry): AgentProviderModeDefinition[] { + const entryModes = entry.modes ?? []; + + return entryModes.map((mode) => ({ + ...mode, + icon: (mode.icon ?? "ShieldCheck") as AgentModeIcon, + colorTier: (mode.colorTier ?? "moderate") as AgentModeColorTier, + })); +} + +export function buildProviderDefinitions( + snapshotEntries: ProviderSnapshotEntry[] | undefined, +): AgentProviderDefinition[] { + if (!snapshotEntries?.length) { + return []; + } + + return snapshotEntries.map((entry) => ({ + id: entry.provider, + label: entry.label ?? entry.provider, + description: entry.description ?? "", + defaultModeId: entry.defaultModeId ?? null, + modes: buildProviderModes(entry), + })); +} + +export function resolveProviderLabel( + provider: string, + snapshotEntries: ProviderSnapshotEntry[] | undefined, +): string { + return snapshotEntries?.find((entry) => entry.provider === provider)?.label ?? provider; +} + +export function resolveProviderDefinition( + provider: string, + snapshotEntries: ProviderSnapshotEntry[] | undefined, +): AgentProviderDefinition | undefined { + return buildProviderDefinitions(snapshotEntries).find((definition) => definition.id === provider); +} diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 7e8bcfbb5..a4d8b7279 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -1,10 +1,12 @@ import type { Command } from "commander"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server"; +import { tryConnectToDaemon } from "../../utils/client.js"; /** Provider list item for display */ export interface ProviderListItem { provider: string; + label: string; status: string; defaultMode: string; modes: string; @@ -13,16 +15,22 @@ export interface ProviderListItem { /** Derive provider list from the manifest — single source of truth */ const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({ provider: def.id, + label: def.label, status: "available", defaultMode: def.defaultModeId ?? "default", modes: def.modes.map((m) => m.label).join(", "), })); +function getStaticProviders(): ProviderListItem[] { + return PROVIDERS; +} + /** Schema for provider ls output */ export const providerLsSchema: OutputSchema = { idField: "provider", columns: [ { header: "PROVIDER", field: "provider", width: 12 }, + { header: "LABEL", field: "label", width: 16 }, { header: "STATUS", field: "status", @@ -45,13 +53,39 @@ export interface ProviderLsOptions extends CommandOptions { } export async function runLsCommand( - _options: ProviderLsOptions, + options: ProviderLsOptions, _command: Command, ): Promise { - // Provider data is static - no daemon connection needed - return { - type: "list", - data: PROVIDERS, - schema: providerLsSchema, - }; + const client = await tryConnectToDaemon({ host: options.host }); + + if (!client) { + return { + type: "list", + data: getStaticProviders(), + schema: providerLsSchema, + }; + } + + try { + const snapshot = await client.getProvidersSnapshot(); + return { + type: "list", + data: snapshot.entries.map((entry) => ({ + provider: entry.provider, + label: entry.label ?? entry.provider, + status: entry.status === "ready" ? "available" : entry.status, + defaultMode: entry.defaultModeId ?? "default", + modes: (entry.modes ?? []).map((mode) => mode.label).join(", "), + })), + schema: providerLsSchema, + }; + } catch { + return { + type: "list", + data: getStaticProviders(), + schema: providerLsSchema, + }; + } finally { + await client.close().catch(() => {}); + } } diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index c51f1d6a1..f08f9b420 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -129,7 +129,12 @@ try { assert(result.stdout.includes("claude"), "output should include claude"); assert(result.stdout.includes("codex"), "output should include codex"); assert(result.stdout.includes("opencode"), "output should include opencode"); - assert(result.stdout.includes("available"), "output should show available status"); + assert( + result.stdout.includes("available") || + result.stdout.includes("loading") || + result.stdout.includes("unavailable"), + "output should show a provider status", + ); console.log("✓ provider ls lists all providers\n"); } @@ -140,7 +145,7 @@ try { assert.strictEqual(result.exitCode, 0, "should exit 0"); const data = JSON.parse(result.stdout.trim()); assert(Array.isArray(data), "output should be an array"); - assert.strictEqual(data.length, 5, "should have 5 providers"); + assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`); assert( data.some((p: { provider: string }) => p.provider === "claude"), "should include claude", @@ -153,14 +158,6 @@ try { data.some((p: { provider: string }) => p.provider === "opencode"), "should include opencode", ); - assert( - data.some((p: { provider: string }) => p.provider === "copilot"), - "should include copilot", - ); - assert( - data.some((p: { provider: string }) => p.provider === "pi"), - "should include pi", - ); console.log("✓ provider ls --json outputs valid JSON\n"); } @@ -170,12 +167,10 @@ try { const result = await ctx.paseo(["provider", "ls", "--quiet"]); assert.strictEqual(result.exitCode, 0, "should exit 0"); const lines = result.stdout.trim().split("\n"); - assert.strictEqual(lines.length, 5, "should have 5 lines"); + assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`); assert(lines.includes("claude"), "should include claude"); assert(lines.includes("codex"), "should include codex"); assert(lines.includes("opencode"), "should include opencode"); - assert(lines.includes("copilot"), "should include copilot"); - assert(lines.includes("pi"), "should include pi"); console.log("✓ provider ls --quiet outputs provider names only\n"); } diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 9ada46dec..7a009f5e7 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -48,7 +48,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import type { ScheduleService } from "../schedule/service.js"; import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; -import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js"; +import type { ProviderDefinition } from "./provider-registry.js"; import { AgentModelSchema, AgentProviderEnum, @@ -832,7 +832,7 @@ export async function createAgentManagementMcpServer( async () => ({ content: [], structuredContent: ensureValidJson({ - providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({ + providers: Object.values(providerRegistry ?? {}).map((provider) => ({ id: provider.id, label: provider.label, modes: provider.modes.map((mode) => ({ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2a104e09d..a10735909 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -273,6 +273,156 @@ describe("AgentManager", () => { expect(snapshot.config.modeId).toBe("auto"); }); + test("setAgentMode persists the selected mode across session reload", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ModeAwareSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + private currentMode: string | null; + + constructor(private readonly config: AgentSessionConfig) { + this.currentMode = config.modeId ?? null; + } + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(): Promise<{ turnId: string }> { + return { turnId: "turn-1" }; + } + + subscribe(): () => void { + return () => {}; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? null, + modeId: this.currentMode, + }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return this.currentMode; + } + + async setMode(modeId: string): Promise { + this.currentMode = modeId; + } + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise {} + async close(): Promise {} + } + + class ModeAwareClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new ModeAwareSession(config); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new ModeAwareSession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + modeId: config?.modeId, + model: config?.model, + }); + } + + async listModels() { + return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }]; + } + } + + const manager = new AgentManager({ + clients: { + codex: new ModeAwareClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000301", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + modeId: "auto", + }); + + await manager.setAgentMode(snapshot.id, "full-access"); + + const beforeReload = manager.getAgent(snapshot.id); + expect(beforeReload?.config.modeId).toBe("full-access"); + expect(beforeReload?.currentModeId).toBe("full-access"); + + const reloaded = await manager.reloadAgentSession(snapshot.id); + expect(reloaded.config.modeId).toBe("full-access"); + expect(reloaded.currentModeId).toBe("full-access"); + }); + + test("listProviderAvailability uses registered client keys, including custom providers", async () => { + const customClient: AgentClient = { + provider: "zai", + capabilities: TEST_CAPABILITIES, + async isAvailable() { + return true; + }, + async createSession() { + throw new Error("not implemented"); + }, + async resumeSession() { + throw new Error("not implemented"); + }, + }; + + const manager = new AgentManager({ + clients: { + zai: customClient, + }, + logger, + }); + + await expect(manager.listProviderAvailability()).resolves.toEqual([ + { + provider: "zai", + available: true, + error: null, + }, + ]); + }); + test("createAgent passes daemon launch env through the provider launch context", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 014459b98..ba201b3ce 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -33,7 +33,7 @@ import type { PersistedAgentDescriptor, } from "./agent-sdk-types.js"; import type { AgentStorage } from "./agent-storage.js"; -import { AGENT_PROVIDER_IDS, getAgentProviderDefinition } from "./provider-manifest.js"; +import { getAgentProviderDefinition } from "./provider-manifest.js"; export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus }; @@ -362,6 +362,10 @@ export class AgentManager { this.clients.set(provider, client); } + getRegisteredProviderIds(): AgentProvider[] { + return Array.from(this.clients.keys()); + } + setAgentAttentionCallback(callback: AgentAttentionCallback): void { this.onAgentAttention = callback; } @@ -501,8 +505,7 @@ export class AgentManager { } async listProviderAvailability(): Promise { - const checks = AGENT_PROVIDER_IDS.map(async (providerId) => { - const provider = providerId as AgentProvider; + const checks = Array.from(this.clients.keys()).map(async (provider) => { const client = this.clients.get(provider); if (!client) { return { @@ -950,6 +953,7 @@ export class AgentManager { async setAgentMode(agentId: string, modeId: string): Promise { const agent = this.requireAgent(agentId); await agent.session.setMode(modeId); + agent.config.modeId = modeId; agent.currentModeId = modeId; // Update runtimeInfo to reflect the new mode if (agent.runtimeInfo) { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 5530b3059..19e1cddf5 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -43,6 +43,8 @@ export type AgentMode = { id: string; label: string; description?: string; + icon?: string; + colorTier?: string; }; export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; @@ -73,6 +75,9 @@ export interface ProviderSnapshotEntry { models?: AgentModelDefinition[]; modes?: AgentMode[]; fetchedAt?: string; + label?: string; + description?: string; + defaultModeId?: string | null; } export type AgentFeatureToggle = { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 2d3a649dc..f2394be1b 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -7,6 +7,7 @@ import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; +import type { ProviderDefinition } from "./provider-registry.js"; type TestDeps = { agentManager: AgentManager; @@ -53,6 +54,20 @@ function createTestDeps(): TestDeps { }; } +function createProviderDefinition(overrides: Partial): ProviderDefinition { + return { + id: "claude", + label: "Claude", + description: "Test provider", + defaultModeId: "default", + modes: [], + createClient: vi.fn() as ProviderDefinition["createClient"], + fetchModels: vi.fn().mockResolvedValue([]), + fetchModes: vi.fn().mockResolvedValue([]), + ...overrides, + }; +} + describe("create_agent MCP tool", () => { const logger = createTestLogger(); const existingCwd = process.cwd(); @@ -213,6 +228,22 @@ describe("create_agent MCP tool", () => { ); }); + it("accepts custom provider IDs in create_agent input validation", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = (server as any)._registeredTools["create_agent"]; + + const parsed = await tool.inputSchema.safeParseAsync({ + cwd: existingCwd, + title: "Custom provider agent", + initialMode: "default", + agentType: "zai", + initialPrompt: "Do work", + }); + + expect(parsed.success).toBe(true); + }); + it("allows caller agents to override cwd and applies caller context labels", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-")); @@ -302,6 +333,52 @@ describe("create_agent MCP tool", () => { }); }); +describe("provider listing MCP tool", () => { + const logger = createTestLogger(); + + it("returns providers from the registry, including custom providers", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const providerRegistry = { + claude: createProviderDefinition({ + id: "claude", + label: "Claude", + modes: [{ id: "default", label: "Default", description: "Built-in mode" }], + }), + zai: createProviderDefinition({ + id: "zai", + label: "ZAI", + description: "Custom Claude profile", + defaultModeId: "default", + modes: [{ id: "default", label: "Default", description: "Custom mode" }], + }), + }; + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerRegistry, + logger, + }); + const tool = (server as any)._registeredTools["list_providers"]; + const response = await tool.callback({}); + + expect(response.structuredContent).toEqual({ + providers: [ + { + id: "claude", + label: "Claude", + modes: [{ id: "default", label: "Default", description: "Built-in mode" }], + }, + { + id: "zai", + label: "ZAI", + modes: [{ id: "default", label: "Default", description: "Custom mode" }], + }, + ], + }); + }); +}); + describe("speak MCP tool", () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 422674651..b634eaa6f 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -29,7 +29,7 @@ import { captureTerminalLines } from "../../terminal/terminal.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import type { ScheduleService } from "../schedule/service.js"; import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; -import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js"; +import type { ProviderDefinition } from "./provider-registry.js"; import { deletePaseoWorktree, listPaseoWorktrees } from "../../utils/worktree.js"; import { AgentModelSchema, @@ -1362,7 +1362,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom async () => ({ content: [], structuredContent: ensureValidJson({ - providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({ + providers: Object.values(providerRegistry ?? {}).map((provider) => ({ id: provider.id, label: provider.label, modes: provider.modes.map((mode) => ({ diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index a018b80ae..79d7172aa 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -1,20 +1,14 @@ import { z } from "zod"; import type { Logger } from "pino"; -import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js"; +import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; import { curateAgentActivity } from "./activity-curator.js"; -import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import type { AgentStorage } from "./agent-storage.js"; import { serializeAgentSnapshot } from "../messages.js"; import { StoredScheduleSchema } from "../schedule/types.js"; -export const AgentProviderEnum = z.enum( - AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [ - AgentProvider, - ...AgentProvider[], - ], -); +export const AgentProviderEnum = z.string(); export const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]); @@ -22,6 +16,8 @@ export const ProviderModeSchema = z.object({ id: z.string(), label: z.string(), description: z.string().optional(), + icon: z.string().optional(), + colorTier: z.string().optional(), }); export const ProviderSummarySchema = z.object({ diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index a172ee0a7..1f210a44d 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test, vi } from "vitest"; import { - resolveProviderCommandPrefix, applyProviderEnv, + migrateProviderSettings, + ProviderOverrideSchema, + resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "./provider-launch-config.js"; @@ -103,3 +105,175 @@ describe("applyProviderEnv", () => { expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined(); }); }); + +describe("ProviderOverrideSchema", () => { + test("accepts built-in override fields", () => { + const parsed = ProviderOverrideSchema.parse({ + command: ["custom-claude", "--json"], + env: { + FOO: "bar", + }, + enabled: false, + order: 2, + }); + + expect(parsed.command).toEqual(["custom-claude", "--json"]); + expect(parsed.env?.FOO).toBe("bar"); + expect(parsed.enabled).toBe(false); + expect(parsed.order).toBe(2); + }); + + test("accepts models with thinking options", () => { + const parsed = ProviderOverrideSchema.parse({ + models: [ + { + id: "zai-fast", + label: "ZAI Fast", + isDefault: true, + thinkingOptions: [ + { + id: "deep", + label: "Deep", + description: "Higher effort", + }, + ], + }, + ], + }); + + expect(parsed.models).toEqual([ + { + id: "zai-fast", + label: "ZAI Fast", + isDefault: true, + thinkingOptions: [ + { + id: "deep", + label: "Deep", + description: "Higher effort", + }, + ], + }, + ]); + }); +}); + +describe("migrateProviderSettings", () => { + const builtinProviderIds = ["claude", "codex", "copilot", "opencode", "pi"]; + + test("passes through entries already in the new format", () => { + const migrated = migrateProviderSettings( + { + zai: { + extends: "claude", + label: "ZAI", + command: ["zai"], + env: { + ZAI_KEY: "secret", + }, + }, + }, + builtinProviderIds, + ); + + expect(migrated).toEqual({ + zai: { + extends: "claude", + label: "ZAI", + command: ["zai"], + env: { + ZAI_KEY: "secret", + }, + }, + }); + }); + + test("migrates mode replace to command argv", () => { + const migrated = migrateProviderSettings( + { + claude: { + command: { + mode: "replace", + argv: ["docker", "run", "--rm", "claude"], + }, + }, + }, + builtinProviderIds, + ); + + expect(migrated).toEqual({ + claude: { + command: ["docker", "run", "--rm", "claude"], + }, + }); + }); + + test("migrates mode default by dropping command", () => { + const migrated = migrateProviderSettings( + { + codex: { + command: { + mode: "default", + }, + env: { + FOO: "bar", + }, + }, + }, + builtinProviderIds, + ); + + expect(migrated).toEqual({ + codex: { + env: { + FOO: "bar", + }, + }, + }); + }); + + test("drops append mode entries because they cannot be auto-migrated", () => { + const migrated = migrateProviderSettings( + { + claude: { + command: { + mode: "append", + args: ["--debug"], + }, + env: { + FOO: "bar", + }, + }, + }, + builtinProviderIds, + ); + + expect(migrated).toEqual({}); + }); + + test("preserves legacy env while migrating old entries", () => { + const migrated = migrateProviderSettings( + { + opencode: { + command: { + mode: "replace", + argv: ["opencode"], + }, + env: { + PATH: "/custom/bin", + }, + }, + }, + builtinProviderIds, + ); + + expect(migrated).toEqual({ + opencode: { + command: ["opencode"], + env: { + PATH: "/custom/bin", + }, + }, + }); + }); +}); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 66bf83944..cf7de3a9b 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -37,6 +37,38 @@ export const ProviderRuntimeSettingsSchema = z }) .strict(); +const ProviderProfileThinkingOptionSchema = z + .object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + }) + .strict(); + +export const ProviderProfileModelSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + isDefault: z.boolean().optional(), + thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(), + }) + .strict(); + +export const ProviderOverrideSchema = z + .object({ + extends: z.string().optional(), + label: z.string().optional(), + description: z.string().optional(), + command: z.array(z.string().min(1)).min(1).optional(), + env: z.record(z.string()).optional(), + models: z.array(ProviderProfileModelSchema).optional(), + enabled: z.boolean().optional(), + order: z.number().optional(), + }) + .strict(); + export const AgentProviderRuntimeSettingsMapSchema = z.record( AgentProviderSchema, ProviderRuntimeSettingsSchema, @@ -44,6 +76,8 @@ export const AgentProviderRuntimeSettingsMapSchema = z.record( export type ProviderCommand = z.infer; export type ProviderRuntimeSettings = z.infer; +export type ProviderProfileModel = z.infer; +export type ProviderOverride = z.infer; export type AgentProviderRuntimeSettingsMap = Partial< Record >; @@ -77,6 +111,48 @@ export async function resolveProviderCommandPrefix( }; } +export function migrateProviderSettings( + raw: Record, + builtinProviderIds: string[], +): Record { + const migrated: Record = {}; + const builtinProviderIdSet = new Set(builtinProviderIds); + + for (const [providerId, value] of Object.entries(raw)) { + const parsedNew = ProviderOverrideSchema.safeParse(value); + if (parsedNew.success) { + migrated[providerId] = parsedNew.data; + continue; + } + + const parsedOld = ProviderRuntimeSettingsSchema.safeParse(value); + if (!parsedOld.success) { + continue; + } + + const nextEntry: ProviderOverride = {}; + const command = parsedOld.data.command; + if (command?.mode === "append") { + console.warn( + `[Config] Skipping legacy agents.providers.${providerId}.command append mode during provider override migration because it cannot be auto-migrated.`, + ); + continue; + } + if (command?.mode === "replace") { + nextEntry.command = command.argv; + } + if (parsedOld.data.env) { + nextEntry.env = parsedOld.data.env; + } + if (!builtinProviderIdSet.has(providerId) && nextEntry.extends === undefined) { + delete nextEntry.extends; + } + migrated[providerId] = nextEntry; + } + + return migrated; +} + // Env vars that indicate a running Claude Code session. If the daemon itself is // launched from inside Claude Code (e.g. by a Paseo agent), these leak into // child processes and cause "cannot be launched inside another session" errors. diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 8d27d2daf..5884fd8e9 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -9,7 +9,7 @@ export interface AgentModeVisuals { colorTier: AgentModeColorTier; } -export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {} +export type AgentProviderModeDefinition = Omit & AgentModeVisuals; // TODO: `modes` should not be static. Providers (especially ACP) report their // own modes at runtime via session/new. We should fetch modes from the provider @@ -168,24 +168,35 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ }, ]; -export function getAgentProviderDefinition(provider: string): AgentProviderDefinition { - const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider); +export function getAgentProviderDefinition( + provider: string, + definitions: AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS, +): AgentProviderDefinition { + const definition = definitions.find((entry) => entry.id === provider); if (!definition) { throw new Error(`Unknown agent provider: ${provider}`); } return definition; } -export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id); +export const BUILTIN_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id); +export const AGENT_PROVIDER_IDS = BUILTIN_PROVIDER_IDS; export const AgentProviderSchema = z.string(); -export function isValidAgentProvider(value: string): boolean { - return AGENT_PROVIDER_IDS.includes(value); +export function isValidAgentProvider( + value: string, + validIds: Iterable = BUILTIN_PROVIDER_IDS, +): boolean { + return Array.isArray(validIds) ? validIds.includes(value) : new Set(validIds).has(value); } -export function getModeVisuals(provider: string, modeId: string): AgentModeVisuals | undefined { - const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider); +export function getModeVisuals( + provider: string, + modeId: string, + definitions: AgentProviderDefinition[], +): AgentModeVisuals | undefined { + const definition = definitions.find((entry) => entry.id === provider); const mode = definition?.modes.find((m) => m.id === modeId); if (!mode) return undefined; return { icon: mode.icon, colorTier: mode.colorTier }; diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts new file mode 100644 index 000000000..90e6265aa --- /dev/null +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -0,0 +1,584 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { AgentModelDefinition } from "./agent-sdk-types.js"; + +const mockState = vi.hoisted(() => { + type ConstructorEntry = { + runtimeSettings?: unknown; + }; + + return { + constructorArgs: { + claude: [] as ConstructorEntry[], + codex: [] as ConstructorEntry[], + copilot: [] as ConstructorEntry[], + opencode: [] as ConstructorEntry[], + pi: [] as ConstructorEntry[], + genericAcp: [] as Array<{ + command: string[]; + env?: Record; + }>, + }, + runtimeModels: new Map(), + reset() { + for (const key of Object.keys(this.constructorArgs) as Array< + keyof typeof this.constructorArgs + >) { + this.constructorArgs[key] = []; + } + this.runtimeModels.clear(); + }, + }; +}); + +vi.mock("./providers/claude-agent.js", () => ({ + ClaudeAgentClient: class ClaudeAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "claude"; + readonly runtimeSettings?: unknown; + + constructor(options: { runtimeSettings?: unknown }) { + this.runtimeSettings = options.runtimeSettings; + mockState.constructorArgs.claude.push({ + runtimeSettings: options.runtimeSettings, + }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async listModels(): Promise { + return mockState.runtimeModels.get(this.provider) ?? []; + } + + async listModes(): Promise<[]> { + return []; + } + + async isAvailable(): Promise { + return true; + } + }, +})); + +vi.mock("./providers/codex-app-server-agent.js", () => ({ + CodexAppServerAgentClient: class CodexAppServerAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "codex"; + readonly runtimeSettings?: unknown; + + constructor(_logger: unknown, runtimeSettings?: unknown) { + this.runtimeSettings = runtimeSettings; + mockState.constructorArgs.codex.push({ runtimeSettings }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async listModels(): Promise { + return mockState.runtimeModels.get(this.provider) ?? []; + } + + async listModes(): Promise<[]> { + return []; + } + + async isAvailable(): Promise { + return true; + } + }, +})); + +vi.mock("./providers/copilot-acp-agent.js", () => ({ + CopilotACPAgentClient: class CopilotACPAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "copilot"; + readonly runtimeSettings?: unknown; + + constructor(options: { runtimeSettings?: unknown }) { + this.runtimeSettings = options.runtimeSettings; + mockState.constructorArgs.copilot.push({ + runtimeSettings: options.runtimeSettings, + }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async listModels(): Promise { + return mockState.runtimeModels.get(this.provider) ?? []; + } + + async listModes(): Promise<[]> { + return []; + } + + async isAvailable(): Promise { + return true; + } + }, +})); + +vi.mock("./providers/opencode-agent.js", () => ({ + OpenCodeAgentClient: class OpenCodeAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "opencode"; + readonly runtimeSettings?: unknown; + + constructor(_logger: unknown, runtimeSettings?: unknown) { + this.runtimeSettings = runtimeSettings; + mockState.constructorArgs.opencode.push({ runtimeSettings }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async listModels(): Promise { + return mockState.runtimeModels.get(this.provider) ?? []; + } + + async listModes(): Promise<[]> { + return []; + } + + async isAvailable(): Promise { + return true; + } + }, + OpenCodeServerManager: { + getInstance: vi.fn(() => ({ + shutdown: vi.fn(), + })), + }, +})); + +vi.mock("./providers/pi-acp-agent.js", () => ({ + PiACPAgentClient: class PiACPAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "pi"; + readonly runtimeSettings?: unknown; + + constructor(options: { runtimeSettings?: unknown }) { + this.runtimeSettings = options.runtimeSettings; + mockState.constructorArgs.pi.push({ + runtimeSettings: options.runtimeSettings, + }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async listModels(): Promise { + return mockState.runtimeModels.get(this.provider) ?? []; + } + + async listModes(): Promise<[]> { + return []; + } + + async isAvailable(): Promise { + return true; + } + }, +})); + +vi.mock("./providers/generic-acp-agent.js", () => ({ + GenericACPAgentClient: class GenericACPAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "acp"; + readonly runtimeSettings?: unknown; + + constructor(options: { command: string[]; env?: Record }) { + this.runtimeSettings = { + command: { + mode: "replace", + argv: options.command, + }, + env: options.env, + }; + mockState.constructorArgs.genericAcp.push({ + command: options.command, + env: options.env, + }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async listModels(): Promise { + return mockState.runtimeModels.get(this.provider) ?? []; + } + + async listModes(): Promise<[]> { + return []; + } + + async isAvailable(): Promise { + return true; + } + }, +})); + +import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry } from "./provider-registry.js"; + +describe("buildProviderRegistry", () => { + const logger = createTestLogger(); + + beforeEach(() => { + mockState.reset(); + }); + + test("builds registry with no overrides — same as built-in count", () => { + const registry = buildProviderRegistry(logger); + + expect(Object.keys(registry)).toHaveLength(AGENT_PROVIDER_DEFINITIONS.length); + }); + + test("built-in override applies command", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + command: ["/opt/custom-claude", "--verbose"], + }, + }, + }); + + expect(mockState.constructorArgs.claude[0]).toEqual({ + runtimeSettings: { + command: { + mode: "replace", + argv: ["/opt/custom-claude", "--verbose"], + }, + env: undefined, + }, + }); + }); + + test("built-in override applies env", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + env: { + CLAUDE_CONFIG_DIR: "/tmp/claude", + }, + }, + }, + }); + + expect(mockState.constructorArgs.claude[0]).toEqual({ + runtimeSettings: { + command: undefined, + env: { + CLAUDE_CONFIG_DIR: "/tmp/claude", + }, + }, + }); + }); + + test("new provider extending claude appears in registry", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + zai: { + extends: "claude", + label: "ZAI", + description: "Claude with ZAI defaults", + }, + }, + }); + + expect(registry.zai).toBeDefined(); + expect(registry.zai.label).toBe("ZAI"); + expect(registry.zai.description).toBe("Claude with ZAI defaults"); + expect(registry.zai.createClient(logger).provider).toBe("zai"); + }); + + test("new provider extending acp uses GenericACPAgentClient", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + "my-agent": { + extends: "acp", + label: "My Agent", + command: ["my-agent", "--acp"], + env: { + ACP_TOKEN: "secret", + }, + }, + }, + }); + + expect(registry["my-agent"].createClient(logger).provider).toBe("my-agent"); + expect(mockState.constructorArgs.genericAcp).toEqual([ + { + command: ["my-agent", "--acp"], + env: { + ACP_TOKEN: "secret", + }, + }, + { + command: ["my-agent", "--acp"], + env: { + ACP_TOKEN: "secret", + }, + }, + ]); + }); + + test('extends: "acp" without command throws', () => { + expect(() => + buildProviderRegistry(logger, { + providerOverrides: { + "my-agent": { + extends: "acp", + label: "My Agent", + }, + }, + }), + ).toThrowError("ACP provider 'my-agent' requires a command"); + }); + + test("custom provider without label throws", () => { + expect(() => + buildProviderRegistry(logger, { + providerOverrides: { + zai: { + extends: "claude", + }, + }, + }), + ).toThrowError("Custom provider 'zai' requires a label"); + }); + + test("enabled: false excludes provider from registry", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + enabled: false, + }, + }, + }); + + expect(registry.claude).toBeUndefined(); + }); + + test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + command: ["/opt/custom-claude"], + }, + zai: { + extends: "claude", + label: "ZAI", + }, + }, + }); + + expect(mockState.constructorArgs.claude).toHaveLength(2); + expect( + mockState.constructorArgs.claude.every( + (entry) => + (entry.runtimeSettings as { command?: { argv?: string[] } }).command?.argv?.[0] === + "/opt/custom-claude", + ), + ).toBe(true); + }); + + describe("model merging", () => { + test("profile models replace runtime models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { + id: "profile-fast", + label: "Profile Fast", + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels(); + + expect(models.map((model) => model.id)).toEqual(["profile-fast"]); + }); + + test("profile models exclude runtime models entirely", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "shared-model", + label: "Runtime Label", + }, + { + provider: "claude", + id: "runtime-only", + label: "Runtime Only", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { + id: "shared-model", + label: "Profile Label", + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels(); + + expect(models).toEqual([ + { + provider: "claude", + id: "shared-model", + label: "Profile Label", + }, + ]); + }); + + test("profile isDefault preserved without runtime models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels(); + + expect(models).toEqual([ + { + provider: "claude", + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ]); + }); + + test("no profile models — runtime models returned as-is", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + ]); + + const registry = buildProviderRegistry(logger); + const models = await registry.claude.fetchModels(); + + expect(models).toEqual([ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + ]); + }); + }); +}); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 94b183f71..0707a8534 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -1,22 +1,34 @@ +import type { Logger } from "pino"; + import type { AgentClient, AgentMode, AgentModelDefinition, + AgentPersistenceHandle, AgentProvider, + AgentRuntimeInfo, + AgentSession, + AgentStreamEvent, ListModelsOptions, ListModesOptions, + ListPersistedAgentsOptions, + PersistedAgentDescriptor, } from "./agent-sdk-types.js"; -import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js"; -import type { Logger } from "pino"; - +import type { + AgentProviderRuntimeSettingsMap, + ProviderOverride, + ProviderProfileModel, + ProviderRuntimeSettings, +} from "./provider-launch-config.js"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; -import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; +import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; +import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; import { PiACPAgentClient } from "./providers/pi-acp-agent.js"; - import { AGENT_PROVIDER_DEFINITIONS, + BUILTIN_PROVIDER_IDS, getAgentProviderDefinition, type AgentProviderDefinition, } from "./provider-manifest.js"; @@ -31,30 +43,42 @@ export interface ProviderDefinition extends AgentProviderDefinition { fetchModes: (options?: ListModesOptions) => Promise; } -type BuildProviderRegistryOptions = { +export type BuildProviderRegistryOptions = { runtimeSettings?: AgentProviderRuntimeSettingsMap; + providerOverrides?: Record; }; type ProviderClientFactory = ( logger: Logger, - runtimeSettings?: AgentProviderRuntimeSettingsMap, + runtimeSettings?: ProviderRuntimeSettings, ) => AgentClient; +type ResolvedProvider = { + definition: AgentProviderDefinition; + runtimeSettings?: ProviderRuntimeSettings; + profileModels: ProviderProfileModel[]; + enabled: boolean; + createBaseClient: (logger: Logger) => AgentClient; +}; + const PROVIDER_CLIENT_FACTORIES: Record = { claude: (logger, runtimeSettings) => new ClaudeAgentClient({ logger, - runtimeSettings: runtimeSettings?.claude, + runtimeSettings, }), - codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex), + codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings), copilot: (logger, runtimeSettings) => new CopilotACPAgentClient({ logger, - runtimeSettings: runtimeSettings?.copilot, + runtimeSettings, }), - opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode), + opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings), pi: (logger, runtimeSettings) => - new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }), + new PiACPAgentClient({ + logger, + runtimeSettings, + }), }; function getProviderClientFactory(provider: string): ProviderClientFactory { @@ -65,31 +89,366 @@ function getProviderClientFactory(provider: string): ProviderClientFactory { return factory; } +function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined { + if (!override?.command && !override?.env) { + return undefined; + } + + return { + command: override.command + ? { + mode: "replace", + argv: override.command, + } + : undefined, + env: override.env, + }; +} + +function mergeRuntimeSettings( + base: ProviderRuntimeSettings | undefined, + override: ProviderRuntimeSettings | undefined, +): ProviderRuntimeSettings | undefined { + if (!base && !override) { + return undefined; + } + + return { + command: override?.command ?? base?.command, + env: + base?.env || override?.env + ? { + ...(base?.env ?? {}), + ...(override?.env ?? {}), + } + : undefined, + }; +} + +function applyOverrideToDefinition( + definition: AgentProviderDefinition, + override?: ProviderOverride, +): AgentProviderDefinition { + if (!override) { + return definition; + } + + return { + ...definition, + label: override.label ?? definition.label, + description: override.description ?? definition.description, + }; +} + +function createDerivedDefinition( + providerId: string, + baseDefinition: AgentProviderDefinition, + override: ProviderOverride, +): AgentProviderDefinition { + if (!override.label) { + throw new Error(`Custom provider '${providerId}' requires a label`); + } + + return { + ...baseDefinition, + id: providerId, + label: override.label, + description: override.description ?? baseDefinition.description, + }; +} + +function mapPersistenceHandle( + provider: AgentProvider, + handle: AgentPersistenceHandle | null, +): AgentPersistenceHandle | null { + if (!handle) { + return null; + } + + return { + ...handle, + provider, + }; +} + +function mapRuntimeInfo(provider: AgentProvider, runtimeInfo: AgentRuntimeInfo): AgentRuntimeInfo { + return { + ...runtimeInfo, + provider, + }; +} + +function mapStreamEvent(provider: AgentProvider, event: AgentStreamEvent): AgentStreamEvent { + return { + ...event, + provider, + }; +} + +function mapPersistedAgentDescriptor( + provider: AgentProvider, + descriptor: PersistedAgentDescriptor, +): PersistedAgentDescriptor { + return { + ...descriptor, + provider, + persistence: { + ...descriptor.persistence, + provider, + }, + }; +} + +function mapModel(provider: AgentProvider, model: AgentModelDefinition): AgentModelDefinition { + return { + ...model, + provider, + }; +} + +function mergeModels( + provider: AgentProvider, + profileModels: ProviderProfileModel[], + runtimeModels: AgentModelDefinition[], +): AgentModelDefinition[] { + if (profileModels.length === 0) { + return runtimeModels.map((model) => mapModel(provider, model)); + } + + return profileModels.map((model) => ({ + ...model, + provider, + })); +} + +function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession { + return { + provider, + id: inner.id, + capabilities: inner.capabilities, + get features() { + return inner.features; + }, + run: (prompt, options) => inner.run(prompt, options), + startTurn: (prompt, options) => inner.startTurn(prompt, options), + subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))), + async *streamHistory() { + for await (const event of inner.streamHistory()) { + yield mapStreamEvent(provider, event); + } + }, + getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()), + getAvailableModes: () => inner.getAvailableModes(), + getCurrentMode: () => inner.getCurrentMode(), + setMode: (modeId) => inner.setMode(modeId), + getPendingPermissions: () => inner.getPendingPermissions(), + respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response), + describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), + interrupt: () => inner.interrupt(), + close: () => inner.close(), + listCommands: inner.listCommands?.bind(inner), + setModel: inner.setModel?.bind(inner), + setThinkingOption: inner.setThinkingOption?.bind(inner), + setFeature: inner.setFeature?.bind(inner), + }; +} + +function wrapClientProvider(provider: AgentProvider, inner: AgentClient): AgentClient { + const listPersistedAgents = inner.listPersistedAgents?.bind(inner); + + return { + provider, + capabilities: inner.capabilities, + createSession: async (config, launchContext) => + wrapSessionProvider( + provider, + await inner.createSession( + { + ...config, + provider: inner.provider, + }, + launchContext, + ), + ), + resumeSession: async (handle, overrides, launchContext) => + wrapSessionProvider( + provider, + await inner.resumeSession( + { + ...handle, + provider: inner.provider, + }, + overrides + ? { + ...overrides, + provider: inner.provider, + } + : undefined, + launchContext, + ), + ), + listModels: async (options) => + (await inner.listModels(options)).map((model) => mapModel(provider, model)), + listModes: inner.listModes?.bind(inner), + listPersistedAgents: listPersistedAgents + ? async (options?: ListPersistedAgentsOptions) => + (await listPersistedAgents(options)).map((descriptor) => + mapPersistedAgentDescriptor(provider, descriptor), + ) + : undefined, + isAvailable: () => inner.isAvailable(), + getDiagnostic: inner.getDiagnostic?.bind(inner), + }; +} + +function createRegistryEntry( + logger: Logger, + provider: AgentProvider, + resolved: ResolvedProvider, +): ProviderDefinition { + const modelClient = resolved.createBaseClient(logger); + + return { + ...resolved.definition, + createClient: (providerLogger: Logger) => { + const inner = resolved.createBaseClient(providerLogger); + return inner.provider === provider ? inner : wrapClientProvider(provider, inner); + }, + fetchModels: async (options?: ListModelsOptions) => + mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)), + fetchModes: async (options?: ListModesOptions) => { + const modes = modelClient.listModes + ? await modelClient.listModes(options) + : resolved.definition.modes; + return modes.map((mode) => { + if (mode.icon && mode.colorTier) return mode; + const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id); + if (!definitionMode) return mode; + return { + ...mode, + icon: mode.icon ?? definitionMode.icon, + colorTier: mode.colorTier ?? definitionMode.colorTier, + }; + }); + }, + }; +} + +function buildResolvedBuiltinProviders( + providerOverrides: Record, + runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, +): Map { + const resolvedProviders = new Map(); + + for (const definition of AGENT_PROVIDER_DEFINITIONS) { + const override = providerOverrides[definition.id]; + const factory = getProviderClientFactory(definition.id); + const mergedRuntimeSettings = mergeRuntimeSettings( + runtimeSettings?.[definition.id], + toRuntimeSettings(override), + ); + + resolvedProviders.set(definition.id, { + definition: applyOverrideToDefinition(definition, override), + runtimeSettings: mergedRuntimeSettings, + profileModels: override?.models ?? [], + enabled: override?.enabled !== false, + createBaseClient: (logger) => factory(logger, mergedRuntimeSettings), + }); + } + + return resolvedProviders; +} + +function addDerivedProviders( + resolvedProviders: Map, + providerOverrides: Record, +): void { + for (const [providerId, override] of Object.entries(providerOverrides)) { + if (BUILTIN_PROVIDER_IDS.includes(providerId)) { + continue; + } + + if (!override.extends) { + throw new Error(`Custom provider '${providerId}' requires an extends value`); + } + + if (override.extends === "acp") { + if (!override.command) { + throw new Error(`ACP provider '${providerId}' requires a command`); + } + + resolvedProviders.set(providerId, { + definition: createDerivedDefinition( + providerId, + { + id: providerId, + label: override.label ?? providerId, + description: override.description ?? "Custom ACP provider", + defaultModeId: null, + modes: [], + }, + override, + ), + runtimeSettings: toRuntimeSettings(override), + profileModels: override.models ?? [], + enabled: override.enabled !== false, + createBaseClient: (logger) => + new GenericACPAgentClient({ + logger, + command: override.command!, + env: override.env, + }), + }); + continue; + } + + const baseProvider = resolvedProviders.get(override.extends); + if (!baseProvider) { + throw new Error( + `Custom provider '${providerId}' extends unknown provider '${override.extends}'`, + ); + } + + const mergedRuntimeSettings = mergeRuntimeSettings( + baseProvider.runtimeSettings, + toRuntimeSettings(override), + ); + const baseDefinition = baseProvider.definition; + const baseFactory = getProviderClientFactory(override.extends); + + resolvedProviders.set(providerId, { + definition: createDerivedDefinition(providerId, baseDefinition, override), + runtimeSettings: mergedRuntimeSettings, + profileModels: override.models ?? [], + enabled: override.enabled !== false, + createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings), + }); + } +} + export function buildProviderRegistry( logger: Logger, options?: BuildProviderRegistryOptions, ): Record { const runtimeSettings = options?.runtimeSettings; + const providerOverrides = options?.providerOverrides ?? {}; + const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings); + addDerivedProviders(resolvedProviders, providerOverrides); + return Object.fromEntries( - AGENT_PROVIDER_DEFINITIONS.map((definition) => { - const createClient = getProviderClientFactory(definition.id); - const modelClient = createClient(logger, runtimeSettings); - return [ - definition.id, - { - ...definition, - createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings), - fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions), - fetchModes: (listOptions?: ListModesOptions) => - modelClient.listModes - ? modelClient.listModes(listOptions) - : Promise.resolve(definition.modes), - } satisfies ProviderDefinition, - ]; - }), + [...resolvedProviders.entries()] + .filter(([, resolved]) => resolved.enabled) + .map(([provider, resolved]) => [provider, createRegistryEntry(logger, provider, resolved)]), ) as Record; } +export function getProviderIds( + registry: Record, +): AgentProvider[] { + return Object.keys(registry); +} + // Deprecated: Use buildProviderRegistry instead export const PROVIDER_REGISTRY: Record = null as any; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 44110d40e..111637d19 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -19,6 +19,9 @@ type Deferred = { type MockProviderOptions = { provider: AgentProvider; + label?: string; + description?: string; + defaultModeId?: string | null; isAvailable?: () => Promise; fetchModels?: (cwd?: string) => Promise; fetchModes?: (cwd?: string) => Promise; @@ -58,10 +61,21 @@ describe("ProviderSnapshotManager", () => { const snapshot = manager.getSnapshot("/tmp/project"); - expect(snapshot).toEqual([ - { provider: "claude", status: "loading" }, - { provider: "codex", status: "loading" }, - ]); + expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "claude"]); + expect(getProviderEntry(snapshot, "claude")).toMatchObject({ + provider: "claude", + status: "loading", + label: "claude", + description: "claude test provider", + defaultModeId: null, + }); + expect(getProviderEntry(snapshot, "codex")).toMatchObject({ + provider: "codex", + status: "loading", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }); await vi.waitFor(() => { expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); @@ -101,12 +115,18 @@ describe("ProviderSnapshotManager", () => { status: "ready", models: [createModel("codex", "gpt-5.2")], modes: [createMode("auto")], + label: "codex", + description: "codex test provider", + defaultModeId: null, }); expect(getProviderEntry(snapshot, "claude")).toMatchObject({ provider: "claude", status: "ready", models: [createModel("claude", "sonnet")], modes: [createMode("default")], + label: "claude", + description: "claude test provider", + defaultModeId: null, }); expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String)); @@ -126,7 +146,13 @@ describe("ProviderSnapshotManager", () => { await vi.waitFor(() => { expect(manager.getSnapshot("/tmp/project")).toEqual([ - { provider: "codex", status: "unavailable" }, + { + provider: "codex", + status: "unavailable", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, ]); }); @@ -155,6 +181,9 @@ describe("ProviderSnapshotManager", () => { provider: "codex", status: "error", error: "model lookup failed", + label: "codex", + description: "codex test provider", + defaultModeId: null, }, ]); }); @@ -236,7 +265,15 @@ describe("ProviderSnapshotManager", () => { }); manager.refresh("/tmp/project"); - expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]); + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { + provider: "codex", + status: "loading", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, + ]); await vi.waitFor(() => { expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( @@ -265,7 +302,15 @@ describe("ProviderSnapshotManager", () => { manager.refresh("/tmp/project"); - expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]); + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { + provider: "codex", + status: "loading", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, + ]); manager.refresh("/tmp/project"); manager.refresh("/tmp/project"); @@ -361,6 +406,89 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + + test("snapshot includes user-defined providers from the registry", async () => { + const { registry } = createRegistry([ + createMockProvider({ provider: "claude" }), + createMockProvider({ + provider: "zai", + label: "ZAI", + description: "Custom Claude profile", + defaultModeId: "default", + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({ + provider: "zai", + status: "ready", + label: "ZAI", + description: "Custom Claude profile", + defaultModeId: "default", + }); + + manager.destroy(); + }); + + test("enabled false providers are omitted when absent from the registry", () => { + const { registry } = createRegistry([createMockProvider({ provider: "claude" })]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + const snapshot = manager.getSnapshot("/tmp/project"); + + expect(snapshot.map((entry) => entry.provider)).toEqual(["claude"]); + expect(getProviderEntry(snapshot, "zai")).toBeUndefined(); + + manager.destroy(); + }); + + test("snapshot entries include label and description from the registry", async () => { + const models = deferred(); + const modes = deferred(); + const { registry } = createRegistry([ + createMockProvider({ + provider: "zai", + label: "ZAI", + description: "Custom Claude profile", + defaultModeId: "plan", + fetchModels: async () => models.promise, + fetchModes: async () => modes.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { + provider: "zai", + status: "loading", + label: "ZAI", + description: "Custom Claude profile", + defaultModeId: "plan", + }, + ]); + + models.resolve([createModel("zai", "zai-fast")]); + modes.resolve([createMode("plan")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({ + provider: "zai", + status: "ready", + label: "ZAI", + description: "Custom Claude profile", + defaultModeId: "plan", + }); + }); + + manager.destroy(); + }); }); function deferred(): Deferred { @@ -403,9 +531,9 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle { const definition: ProviderDefinition = { id: options.provider, - label: options.provider, - description: `${options.provider} test provider`, - defaultModeId: null, + label: options.label ?? options.provider, + description: options.description ?? `${options.provider} test provider`, + defaultModeId: options.defaultModeId ?? null, modes: [], createClient: () => ({ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index b23a21d36..150cde42b 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -5,7 +5,6 @@ import type { Logger } from "pino"; import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"; import type { ProviderDefinition } from "./provider-registry.js"; -import { AGENT_PROVIDER_IDS } from "./provider-manifest.js"; const DEFAULT_CWD_KEY = "__default__"; @@ -63,9 +62,13 @@ export class ProviderSnapshotManager { private createLoadingEntries(): Map { const entries = new Map(); for (const provider of this.getProviderIds()) { + const definition = this.providerRegistry[provider]; entries.set(provider, { provider, status: "loading", + label: definition?.label, + description: definition?.description, + defaultModeId: definition?.defaultModeId ?? null, }); } return entries; @@ -107,6 +110,9 @@ export class ProviderSnapshotManager { snapshot.set(provider, { provider, status: "loading", + label: definition.label, + description: definition.description, + defaultModeId: definition.defaultModeId, }); try { @@ -116,6 +122,9 @@ export class ProviderSnapshotManager { snapshot.set(provider, { provider, status: "unavailable", + label: definition.label, + description: definition.description, + defaultModeId: definition.defaultModeId, }); this.emitChange(cwdKey); return; @@ -132,6 +141,9 @@ export class ProviderSnapshotManager { models, modes, fetchedAt: new Date().toISOString(), + label: definition.label, + description: definition.description, + defaultModeId: definition.defaultModeId, }); this.emitChange(cwdKey); } catch (error) { @@ -139,6 +151,9 @@ export class ProviderSnapshotManager { provider, status: "error", error: toErrorMessage(error), + label: definition.label, + description: definition.description, + defaultModeId: definition.defaultModeId, }); this.logger.warn( { err: error, provider, cwd: cwdKey }, @@ -180,7 +195,7 @@ export class ProviderSnapshotManager { } private getProviderIds(): AgentProvider[] { - return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]); + return Object.keys(this.providerRegistry) as AgentProvider[]; } } diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts new file mode 100644 index 000000000..ea9423df9 --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -0,0 +1,46 @@ +import type { Logger } from "pino"; + +import { isCommandAvailable } from "../../../utils/executable.js"; +import { ACPAgentClient } from "./acp-agent.js"; + +type GenericACPAgentClientOptions = { + logger: Logger; + command: string[]; + env?: Record; +}; + +export class GenericACPAgentClient extends ACPAgentClient { + private readonly command: [string, ...string[]]; + + constructor(options: GenericACPAgentClientOptions) { + if (options.command.length === 0) { + throw new Error("Generic ACP provider requires a non-empty command"); + } + + super({ + provider: "acp", + logger: options.logger, + runtimeSettings: { + command: { + mode: "replace", + argv: options.command, + }, + env: options.env, + }, + defaultCommand: options.command as [string, ...string[]], + }); + + this.command = options.command as [string, ...string[]]; + } + + protected override async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { + return { + command: this.command[0], + args: this.command.slice(1), + }; + } + + override async isAvailable(): Promise { + return isCommandAvailable(this.command[0]); + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 61e47c497..a2a6b7223 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -114,7 +114,10 @@ import { startRelayTransport, type RelayTransportController } from "./relay-tran import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; -import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import type { + AgentProviderRuntimeSettingsMap, + ProviderOverride, +} from "./agent/provider-launch-config.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; type AgentMcpTransportMap = Map; @@ -177,6 +180,7 @@ export type PaseoDaemonConfig = { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + providerOverrides?: Record; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; }; @@ -351,6 +355,7 @@ export async function createPaseoDaemon( clients: { ...createAllClients(logger, { runtimeSettings: config.agentProviderSettings, + providerOverrides: config.providerOverrides, }), ...config.agentClients, }, @@ -359,6 +364,7 @@ export async function createPaseoDaemon( }); const providerRegistry = buildProviderRegistry(logger, { runtimeSettings: config.agentProviderSettings, + providerOverrides: config.providerOverrides, }); const terminalManager = createTerminalManager(); @@ -598,6 +604,7 @@ export async function createPaseoDaemon( finalTimeoutMs: config.dictationFinalTimeoutMs, }, config.agentProviderSettings, + config.providerOverrides, daemonVersion, (intent) => { try { @@ -677,6 +684,7 @@ export async function createPaseoDaemon( await agentStorage.flush().catch(() => undefined); await shutdownProviders(logger, { runtimeSettings: config.agentProviderSettings, + providerOverrides: config.providerOverrides, }); terminalManager.killAll(); speechService.stop(); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 3043c3c17..02fc0cf8e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -4,6 +4,11 @@ import { z } from "zod"; import type { PaseoDaemonConfig } from "./bootstrap.js"; import { loadPersistedConfig } from "./persisted-config.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; +import type { + AgentProviderRuntimeSettingsMap, + ProviderOverride, +} from "./agent/provider-launch-config.js"; +import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "./agent/provider-manifest.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; import { @@ -52,6 +57,55 @@ function parseOptionalVoiceLlmProvider(value: unknown): AgentProvider | null { return parsed.success ? parsed.data : null; } +function extractProviderOverrides( + providers: Record | undefined, +): Record | undefined { + if (!providers) { + return undefined; + } + + const providerOverrides = Object.entries(providers).flatMap(([providerId, provider]) => { + const parsed = ProviderOverrideSchema.safeParse(provider); + return parsed.success ? [[providerId, parsed.data] as const] : []; + }); + + return providerOverrides.length > 0 ? Object.fromEntries(providerOverrides) : undefined; +} + +function extractAgentProviderSettings( + providerOverrides: Record | undefined, +): AgentProviderRuntimeSettingsMap | undefined { + if (!providerOverrides) { + return undefined; + } + + const runtimeSettings = Object.entries(providerOverrides).flatMap(([providerId, provider]) => { + const parsedProviderId = AgentProviderSchema.safeParse(providerId); + if (!parsedProviderId.success || (!provider.command && !provider.env)) { + return []; + } + + return [ + [ + parsedProviderId.data, + { + command: provider.command + ? { + mode: "replace" as const, + argv: provider.command, + } + : undefined, + env: provider.env, + }, + ] as const, + ]; + }); + + return runtimeSettings.length > 0 + ? (Object.fromEntries(runtimeSettings) as AgentProviderRuntimeSettingsMap) + : undefined; +} + export function loadConfig( paseoHome: string, options?: { @@ -117,6 +171,9 @@ export function loadConfig( const voiceLlmProviderExplicit = envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null; const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null; + const providerOverrides = extractProviderOverrides( + persisted.agents?.providers as Record | undefined, + ); return { listen, @@ -140,6 +197,7 @@ export function loadConfig( voiceLlmProvider, voiceLlmProviderExplicit, voiceLlmModel, - agentProviderSettings: persisted.agents?.providers, + agentProviderSettings: extractAgentProviderSettings(providerOverrides), + providerOverrides, }; } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 002478abc..66e4ff4eb 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -30,7 +30,11 @@ export { } from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; // Provider binary resolution -export { applyProviderEnv } from "./agent/provider-launch-config.js"; +export { + applyProviderEnv, + type ProviderOverride, + type ProviderProfileModel, +} from "./agent/provider-launch-config.js"; export { findExecutable, executableExists, @@ -42,6 +46,7 @@ export { execCommand, spawnProcess } from "../utils/spawn.js"; // Provider manifest (source of truth for provider definitions) export { AGENT_PROVIDER_DEFINITIONS, + BUILTIN_PROVIDER_IDS, type AgentProviderDefinition, } from "./agent/provider-manifest.js"; diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 75b5085ab..cbeea46ad 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { PersistedConfigSchema } from "./persisted-config.js"; describe("PersistedConfigSchema agent provider runtime settings", () => { - test("accepts provider command append args and env", () => { + test("legacy append entries are skipped during migration", () => { const parsed = PersistedConfigSchema.parse({ agents: { providers: { @@ -20,8 +20,7 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { }, }); - expect(parsed.agents?.providers?.claude?.command?.mode).toBe("append"); - expect(parsed.agents?.providers?.claude?.env?.FOO).toBe("bar"); + expect(parsed.agents?.providers).toEqual({}); }); test("accepts provider command replace argv", () => { @@ -38,7 +37,12 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { }, }); - expect(parsed.agents?.providers?.codex?.command?.mode).toBe("replace"); + expect(parsed.agents?.providers?.codex?.command).toEqual([ + "docker", + "run", + "--rm", + "my-codex-wrapper", + ]); }); test("rejects replace command without argv", () => { @@ -58,6 +62,286 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { }); }); +describe("provider overrides (new format)", () => { + test("override built-in provider with command and env", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + command: ["/opt/custom/claude"], + env: { + ANTHROPIC_API_KEY: "sk-test", + }, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.claude).toEqual({ + command: ["/opt/custom/claude"], + env: { + ANTHROPIC_API_KEY: "sk-test", + }, + }); + }); + + test("new provider extending claude with label", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + zai: { + extends: "claude", + label: "ZAI", + }, + }, + }, + }); + + expect(parsed.agents?.providers?.zai).toEqual({ + extends: "claude", + label: "ZAI", + }); + }); + + test("new provider extending acp with command", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + "my-agent": { + extends: "acp", + label: "My Agent", + command: ["my-agent", "--acp"], + }, + }, + }, + }); + + expect(parsed.agents?.providers?.["my-agent"]).toEqual({ + extends: "acp", + label: "My Agent", + command: ["my-agent", "--acp"], + }); + }); + + test("enabled: false accepted", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + enabled: false, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.claude?.enabled).toBe(false); + }); + + test("models array accepted", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + zai: { + extends: "claude", + label: "ZAI", + models: [ + { + id: "zai-fast", + label: "ZAI Fast", + isDefault: true, + }, + ], + }, + }, + }, + }); + + expect(parsed.agents?.providers?.zai?.models).toEqual([ + { + id: "zai-fast", + label: "ZAI Fast", + isDefault: true, + }, + ]); + }); + + test("order field accepted", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + order: 1, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.claude?.order).toBe(1); + }); + + test("new provider without extends → error", () => { + const result = PersistedConfigSchema.safeParse({ + agents: { + providers: { + zai: { + label: "ZAI", + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); + + test("new provider without label → error", () => { + const result = PersistedConfigSchema.safeParse({ + agents: { + providers: { + zai: { + extends: "claude", + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); + + test("extends: acp without command → error", () => { + const result = PersistedConfigSchema.safeParse({ + agents: { + providers: { + "my-agent": { + extends: "acp", + label: "My Agent", + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); + + test("extends unknown provider → error", () => { + const result = PersistedConfigSchema.safeParse({ + agents: { + providers: { + zai: { + extends: "unknown", + label: "ZAI", + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); + + test("invalid provider ID format → error", () => { + const result = PersistedConfigSchema.safeParse({ + agents: { + providers: { + ZAI: { + extends: "claude", + label: "ZAI", + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); + + test("old format with mode: replace auto-migrates", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + command: { + mode: "replace", + argv: ["docker", "run", "--rm", "claude"], + }, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.claude).toEqual({ + command: ["docker", "run", "--rm", "claude"], + }); + }); + + test("old format with mode: default auto-migrates", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + command: { + mode: "default", + }, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.claude).toEqual({}); + }); + + test("old format env preserved during migration", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + command: { + mode: "default", + }, + env: { + FOO: "bar", + }, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.claude).toEqual({ + env: { + FOO: "bar", + }, + }); + }); + + test("mixed old and new format entries both work", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + command: { + mode: "replace", + argv: ["custom-claude"], + }, + }, + zai: { + extends: "claude", + label: "ZAI", + command: ["zai"], + }, + }, + }, + }); + + expect(parsed.agents?.providers).toEqual({ + claude: { + command: ["custom-claude"], + }, + zai: { + extends: "claude", + label: "ZAI", + command: ["zai"], + }, + }); + }); +}); + describe("PersistedConfigSchema logging config", () => { test("accepts destination-specific logging config", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 34b9515e8..5fd0483a2 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -2,7 +2,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; -import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js"; +import { + AgentProviderRuntimeSettingsMapSchema, + migrateProviderSettings, + ProviderOverrideSchema, +} from "./agent/provider-launch-config.js"; +import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); const LogFormatSchema = z.enum(["pretty", "json"]); @@ -113,6 +118,107 @@ const FeatureVoiceModeSchema = z }) .strict(); +const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const; +const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/; + +const ProviderOverridesSchema = z + .record(z.string(), ProviderOverrideSchema) + .superRefine((providers, ctx) => { + const builtinProviderIdSet = new Set(BUILTIN_PROVIDER_IDS); + const validExtendsValues = new Set([...BUILTIN_PROVIDER_IDS, "acp"]); + + for (const [providerId, provider] of Object.entries(providers)) { + if (!PROVIDER_ID_PATTERN.test(providerId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [providerId], + message: `Provider ID "${providerId}" must match ${PROVIDER_ID_PATTERN}.`, + }); + } + + const isBuiltinProvider = builtinProviderIdSet.has(providerId); + if (!isBuiltinProvider && !provider.extends) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [providerId, "extends"], + message: `Custom provider "${providerId}" must declare extends.`, + }); + } + + if (!isBuiltinProvider && !provider.label) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [providerId, "label"], + message: `Custom provider "${providerId}" must declare label.`, + }); + } + + if (provider.extends && !validExtendsValues.has(provider.extends)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [providerId, "extends"], + message: `Provider "${providerId}" extends unknown provider "${provider.extends}".`, + }); + } + + if (provider.extends === "acp" && !provider.command) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [providerId, "command"], + message: `Provider "${providerId}" extending "acp" must declare command.`, + }); + } + } + }); + +function isLegacyProviderEntry(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const command = (value as Record).command; + if (!command || typeof command !== "object" || Array.isArray(command)) { + return false; + } + + return typeof (command as Record).mode === "string"; +} + +function normalizeAgentProviders(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + + const rawProviders = value as Record; + const hasLegacyEntries = Object.values(rawProviders).some((entry) => + isLegacyProviderEntry(entry), + ); + if (!hasLegacyEntries) { + return value; + } + + const legacyEntries: Record = {}; + const normalizedEntries: Record = {}; + + for (const [providerId, providerValue] of Object.entries(rawProviders)) { + if (isLegacyProviderEntry(providerValue)) { + legacyEntries[providerId] = providerValue; + continue; + } + normalizedEntries[providerId] = providerValue; + } + + const parsedLegacyEntries = AgentProviderRuntimeSettingsMapSchema.safeParse(legacyEntries); + if (!parsedLegacyEntries.success) { + return value; + } + + return { + ...normalizedEntries, + ...migrateProviderSettings(parsedLegacyEntries.data, [...BUILTIN_PROVIDER_IDS]), + }; +} + export const PersistedConfigSchema = z .object({ // v1 schema marker @@ -158,7 +264,7 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), agents: z .object({ - providers: AgentProviderRuntimeSettingsMapSchema.optional(), + providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), }) .strict() .optional(), @@ -174,10 +280,16 @@ export const PersistedConfigSchema = z }) .strict(); -export type PersistedConfig = z.infer; +type PersistedConfigSchemaOutput = z.infer; + +export type PersistedConfig = Omit & { + agents?: Omit, "providers"> & { + providers?: AgentProviderRuntimeSettingsMap; + }; +}; const CONFIG_FILENAME = "config.json"; -const DEFAULT_PERSISTED_CONFIG: PersistedConfig = PersistedConfigSchema.parse({ +const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({ version: 1, daemon: { listen: "127.0.0.1:6767", @@ -191,7 +303,7 @@ const DEFAULT_PERSISTED_CONFIG: PersistedConfig = PersistedConfigSchema.parse({ app: { baseUrl: "https://app.paseo.sh", }, -}); +}) as PersistedConfig; type LoggerLike = { child(bindings: Record): LoggerLike; @@ -275,7 +387,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per } log?.info(`Loaded from ${configPath}`); - return result.data; + return result.data as PersistedConfig; } export function savePersistedConfig( diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index 6a65ce3f7..1554b8ade 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -16,6 +16,7 @@ import type { const testLogger = { child: () => testLogger, error: vi.fn(), + warn: vi.fn(), } as any; type ManagedAgentOverrides = Omit< @@ -208,4 +209,22 @@ describe("persistence hooks", () => { }, }); }); + + test("buildSessionConfig skips records whose provider is missing from the registry", () => { + const record = createRecord({ + id: "agent-missing-provider", + provider: "zai", + }); + + expect( + buildSessionConfig(record, { + validProviders: ["claude", "codex"], + logger: testLogger, + }), + ).toBeNull(); + expect(testLogger.warn).toHaveBeenCalledWith( + { agentId: "agent-missing-provider", provider: "zai" }, + "Skipping persisted agent with unknown provider 'zai'", + ); + }); }); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index c197a1c2d..c14881b45 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -1,11 +1,11 @@ import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; -import { isValidAgentProvider } from "./agent/provider-manifest.js"; type LoggerLike = { child(bindings: Record): LoggerLike; error(...args: any[]): void; + warn(...args: any[]): void; }; function getLogger(logger: LoggerLike): LoggerLike { @@ -15,6 +15,11 @@ function getLogger(logger: LoggerLike): LoggerLike { type AgentStoragePersistence = Pick; type AgentManagerStateSource = Pick; +type BuildSessionConfigOptions = { + validProviders?: Iterable; + logger?: LoggerLike; +}; + /** * Attach AgentStorage persistence to an AgentManager instance so every * agent_state snapshot is flushed to disk. @@ -51,9 +56,18 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial>(); -const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; +const DEFAULT_AGENT_PROVIDER = "claude"; // TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. // Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject @@ -416,6 +418,7 @@ export type SessionOptions = { getSpeechReadiness?: () => SpeechReadinessSnapshot; }; agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; + providerOverrides?: Record; }; export type SessionLifecycleIntent = @@ -487,8 +490,20 @@ function convertPCMToWavBuffer( return wavBuffer; } -function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: string): AgentProvider { - if (isValidAgentProvider(value)) { +function isRegisteredProvider( + providerRegistry: ReturnType, + value: string, +): boolean { + return Object.prototype.hasOwnProperty.call(providerRegistry, value); +} + +function coerceAgentProvider( + logger: pino.Logger, + providerRegistry: ReturnType, + value: string, + agentId?: string, +): AgentProvider { + if (isRegisteredProvider(providerRegistry, value)) { return value; } logger.warn( @@ -500,13 +515,14 @@ function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: strin function toAgentPersistenceHandle( logger: pino.Logger, + providerRegistry: ReturnType, handle: StoredAgentRecord["persistence"], ): AgentPersistenceHandle | null { if (!handle) { return null; } const provider = handle.provider; - if (!isValidAgentProvider(provider)) { + if (!isRegisteredProvider(providerRegistry, provider)) { logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); return null; } @@ -619,6 +635,7 @@ export class Session { private readonly unregisterVoiceCallerContext?: (agentId: string) => void; private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; + private readonly providerOverrides: Record | undefined; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; @@ -652,6 +669,7 @@ export class Session { voiceBridge, dictation, agentProviderRuntimeSettings, + providerOverrides, } = options; this.clientId = clientId; this.appVersion = appVersion; @@ -707,6 +725,7 @@ export class Session { this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; this.getSpeechReadiness = dictation?.getSpeechReadiness; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; + this.providerOverrides = providerOverrides; this.abortController = new AbortController(); this.sessionLogger = logger.child({ module: "session", @@ -715,6 +734,7 @@ export class Session { }); this.providerRegistry = buildProviderRegistry(this.sessionLogger, { runtimeSettings: this.agentProviderRuntimeSettings, + providerOverrides: this.providerOverrides, }); // Initialize per-session managers @@ -1071,10 +1091,20 @@ export class Session { const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record)); const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; - const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id); + const provider = coerceAgentProvider( + this.sessionLogger, + this.providerRegistry, + record.provider, + record.id, + ); const runtimeInfo = record.runtimeInfo ? { - provider: coerceAgentProvider(this.sessionLogger, record.runtimeInfo.provider, record.id), + provider: coerceAgentProvider( + this.sessionLogger, + this.providerRegistry, + record.runtimeInfo.provider, + record.id, + ), sessionId: record.runtimeInfo.sessionId, ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model") ? { model: record.runtimeInfo.model ?? null } @@ -1107,7 +1137,11 @@ export class Session { currentModeId: record.lastModeId ?? null, availableModes: [], pendingPermissions: [], - persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence), + persistence: toAgentPersistenceHandle( + this.sessionLogger, + this.providerRegistry, + record.persistence, + ), lastUsage: undefined, lastError: undefined, title: record.title ?? record.config?.title ?? null, @@ -1153,7 +1187,11 @@ export class Session { throw new Error(`Agent not found: ${agentId}`); } - const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); + const handle = toAgentPersistenceHandle( + this.sessionLogger, + this.providerRegistry, + record.persistence, + ); let snapshot: ManagedAgent; if (handle) { snapshot = await this.agentManager.resumeAgentFromPersistence( @@ -1167,7 +1205,13 @@ export class Session { "Agent resumed from persistence", ); } else { - const config = buildSessionConfig(record); + const config = buildSessionConfig(record, { + validProviders: Object.keys(this.providerRegistry), + logger: this.sessionLogger, + }); + if (!config) { + throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); + } snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels }); this.sessionLogger.info( { agentId, provider: record.provider }, @@ -3065,7 +3109,11 @@ export class Session { if (!record) { throw new Error(`Agent not found: ${agentId}`); } - const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); + const handle = toAgentPersistenceHandle( + this.sessionLogger, + this.providerRegistry, + record.persistence, + ); if (!handle) { throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`); } diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index bf7f84a50..848881f0c 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -84,6 +84,7 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, undefined, + undefined, "1.2.3-test", undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index a6c70e40a..84ce0b535 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -180,6 +180,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu undefined, undefined, undefined, + undefined, TEST_DAEMON_VERSION, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 259c9966d..411c8dc7e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -27,7 +27,10 @@ import type { AllowedHostsConfig } from "./allowed-hosts.js"; import { isHostAllowed } from "./allowed-hosts.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; -import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import type { + AgentProviderRuntimeSettingsMap, + ProviderOverride, +} from "./agent/provider-launch-config.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -249,6 +252,7 @@ export class VoiceAssistantWebSocketServer { private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; + private readonly providerOverrides: Record | undefined; private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private serverCapabilities: ServerCapabilities | undefined; @@ -294,6 +298,7 @@ export class VoiceAssistantWebSocketServer { finalTimeoutMs?: number; }, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, + providerOverrides?: Record, daemonVersion?: string, onLifecycleIntent?: (intent: SessionLifecycleIntent) => void, projectRegistry?: ProjectRegistry, @@ -341,10 +346,12 @@ export class VoiceAssistantWebSocketServer { this.terminalManager = terminalManager ?? null; this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; + this.providerOverrides = providerOverrides; const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); this.providerSnapshotManager = new ProviderSnapshotManager( buildProviderRegistry(providerSnapshotLogger, { runtimeSettings: this.agentProviderRuntimeSettings, + providerOverrides: this.providerOverrides, }), providerSnapshotLogger, ); @@ -672,6 +679,7 @@ export class VoiceAssistantWebSocketServer { } : undefined, agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, + providerOverrides: this.providerOverrides, }); connection = { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 207523510..63fdd7e6b 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -93,6 +93,8 @@ const AgentModeSchema: z.ZodType = z.object({ id: z.string(), label: z.string(), description: z.string().optional(), + icon: z.string().optional(), + colorTier: z.string().optional(), }); const ProviderStatusSchema: z.ZodType = z.enum([ @@ -154,6 +156,9 @@ const ProviderSnapshotEntrySchema: z.ZodType = z.object({ models: z.array(AgentModelDefinitionSchema).optional(), modes: z.array(AgentModeSchema).optional(), fetchedAt: z.string().optional(), + label: z.string().optional(), + description: z.string().optional(), + defaultModeId: z.string().nullable().optional(), }); const AgentCapabilityFlagsSchema: z.ZodType = z.object({ From 95e112c1858bf6ba96cf3f07a92b6611cf9398c1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 20:14:33 +0700 Subject: [PATCH 13/21] fix: use PATH-aware resolution for provider availability and fix Windows cmd escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider isAvailable() was using executableExists() which only checks filesystem paths, not PATH. Commands like ["claude", "--flag"] would show as unavailable even though they'd launch fine. Switch to isCommandAvailable() which uses findExecutable() for proper PATH resolution. Un-export executableExists from the public API. Fix Windows cmd.exe metacharacter escaping — &, |, ^, <, >, (, ), ! are now properly escaped with ^, and % is doubled. Add shared escapeWindowsCmdValue helper used by both quoteWindowsCommand and quoteWindowsArgument. Replace local quoteForCmd in spawn.ts. Add server-tests-windows CI job to catch Windows-specific regressions. --- .github/workflows/ci.yml | 30 ++++++++++++++ .../server/agent/provider-registry.test.ts | 40 +++++++++++++++++++ .../server/agent/providers/claude-agent.ts | 4 +- .../agent/providers/codex-app-server-agent.ts | 4 +- .../server/agent/providers/opencode-agent.ts | 4 +- packages/server/src/server/exports.ts | 1 - packages/server/src/utils/executable.test.ts | 40 +++++++++++++++++++ packages/server/src/utils/executable.ts | 24 +++++++---- packages/server/src/utils/spawn.ts | 16 +++----- 9 files changed, 138 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 801e0c067..e5d8977d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,36 @@ jobs: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + server-tests-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Fetch origin/main (worktree tests) + run: git fetch --no-tags origin main:refs/remotes/origin/main + + - name: Install dependencies + run: npm install + + - name: Build highlight dependency + run: npm run build --workspace=@getpaseo/highlight + + - name: Build relay dependency + run: npm run build --workspace=@getpaseo/relay + + - name: Run server tests + run: npm run test --workspace=@getpaseo/server + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + app-tests: runs-on: ubuntu-latest steps: diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 90e6265aa..e19a087e7 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -20,6 +20,7 @@ const mockState = vi.hoisted(() => { env?: Record; }>, }, + isCommandAvailable: vi.fn(async (_command: string) => false), runtimeModels: new Map(), reset() { for (const key of Object.keys(this.constructorArgs) as Array< @@ -27,11 +28,17 @@ const mockState = vi.hoisted(() => { >) { this.constructorArgs[key] = []; } + this.isCommandAvailable.mockReset(); + this.isCommandAvailable.mockImplementation(async (_command: string) => false); this.runtimeModels.clear(); }, }; }); +vi.mock("../../utils/executable.js", () => ({ + isCommandAvailable: mockState.isCommandAvailable, +})); + vi.mock("./providers/claude-agent.js", () => ({ ClaudeAgentClient: class ClaudeAgentClient { readonly capabilities = { @@ -69,6 +76,12 @@ vi.mock("./providers/claude-agent.js", () => ({ } async isAvailable(): Promise { + const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } }) + ?.command; + if (command?.mode === "replace") { + const { isCommandAvailable } = await import("../../utils/executable.js"); + return await isCommandAvailable(command.argv?.[0] ?? ""); + } return true; } }, @@ -109,6 +122,12 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({ } async isAvailable(): Promise { + const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } }) + ?.command; + if (command?.mode === "replace") { + const { isCommandAvailable } = await import("../../utils/executable.js"); + return await isCommandAvailable(command.argv?.[0] ?? ""); + } return true; } }, @@ -151,6 +170,12 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({ } async isAvailable(): Promise { + const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } }) + ?.command; + if (command?.mode === "replace") { + const { isCommandAvailable } = await import("../../utils/executable.js"); + return await isCommandAvailable(command.argv?.[0] ?? ""); + } return true; } }, @@ -433,6 +458,21 @@ describe("buildProviderRegistry", () => { expect(registry.claude).toBeUndefined(); }); + test("provider override command can be PATH-resolved and still report available", async () => { + mockState.isCommandAvailable.mockResolvedValue(true); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + command: ["claude", "--flag"], + }, + }, + }); + + await expect(registry.claude.createClient(logger).isAvailable()).resolves.toBe(true); + expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude"); + }); + test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => { buildProviderRegistry(logger, { providerOverrides: { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 897e5a432..cf0f75c59 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -72,7 +72,7 @@ import type { PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { execCommand, spawnProcess } from "../../../utils/spawn.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; @@ -1128,7 +1128,7 @@ export class ClaudeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return executableExists(command.argv[0]) !== null; + return await isCommandAvailable(command.argv[0]); } return true; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ae793a462..d52d4df45 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -47,7 +47,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; @@ -4203,7 +4203,7 @@ export class CodexAppServerAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return executableExists(command.argv[0]) !== null; + return await isCommandAvailable(command.argv[0]); } return true; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9d2076cd1..cc3ca96e8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -43,7 +43,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { @@ -916,7 +916,7 @@ export class OpenCodeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return executableExists(command.argv[0]) !== null; + return await isCommandAvailable(command.argv[0]); } return true; } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 66e4ff4eb..48ffff923 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -37,7 +37,6 @@ export { } from "./agent/provider-launch-config.js"; export { findExecutable, - executableExists, quoteWindowsArgument, quoteWindowsCommand, } from "../utils/executable.js"; diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index dc19b4498..5f63d84ca 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -240,6 +240,46 @@ describe("quoteWindowsCommand", () => { expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); }); + test("escapes ampersands", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); + }); + + test("escapes pipes", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); + }); + + test("doubles percent signs", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("100%")).toBe("100%%"); + }); + + test("escapes carets", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); + }); + + test("escapes multiple metacharacters", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( + "build^&^(test^|deploy^)^!^", + ); + }); + + test("quotes commands with spaces after escaping metacharacters", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( + '"C:\\Program Files\\My Tool^&Stuff\\run 100%%.cmd"', + ); + }); + test("returns the command unchanged on non-Windows platforms", async () => { setPlatform("darwin"); const { quoteWindowsCommand } = await loadExecutableModule(); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 0c71db88e..8ed93e382 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -105,6 +105,20 @@ export async function isCommandAvailable(command: string): Promise { return (await findExecutable(command)) !== null; } +function escapeWindowsCmdValue(value: string): string { + if (process.platform !== "win32") return value; + + const isQuoted = value.startsWith('"') && value.endsWith('"'); + const unquoted = isQuoted ? value.slice(1, -1) : value; + const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1"); + + if (isQuoted || escaped.includes(" ")) { + return `"${escaped}"`; + } + + return escaped; +} + /** * When spawning with `shell: true` on Windows, the command is passed to * `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a @@ -112,10 +126,7 @@ export async function isCommandAvailable(command: string): Promise { * space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`. */ export function quoteWindowsCommand(command: string): string { - if (process.platform !== "win32") return command; - if (!command.includes(" ")) return command; - if (command.startsWith('"') && command.endsWith('"')) return command; - return `"${command}"`; + return escapeWindowsCmdValue(command); } /** @@ -124,8 +135,5 @@ export function quoteWindowsCommand(command: string): string { * child process sees it. */ export function quoteWindowsArgument(argument: string): string { - if (process.platform !== "win32") return argument; - if (!argument.includes(" ")) return argument; - if (argument.startsWith('"') && argument.endsWith('"')) return argument; - return `"${argument}"`; + return escapeWindowsCmdValue(argument); } diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 1168a59e6..bab7b047a 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,6 +1,8 @@ import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { promisify } from "node:util"; +import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; + const execFileAsync = promisify(execFile); interface ExecCommandOptions { @@ -16,12 +18,6 @@ interface ExecCommandResult { stderr: string; } -function quoteForCmd(value: string): string { - if (!value.includes(" ")) return value; - if (value.startsWith('"') && value.endsWith('"')) return value; - return `"${value}"`; -} - export function spawnProcess( command: string, args: string[], @@ -29,8 +25,8 @@ export function spawnProcess( ): ChildProcess { const isWindows = process.platform === "win32"; - const resolvedCommand = isWindows ? quoteForCmd(command) : command; - const resolvedArgs = isWindows ? args.map(quoteForCmd) : args; + const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; + const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; return spawn(resolvedCommand, resolvedArgs, { ...options, @@ -45,8 +41,8 @@ export async function execCommand( options?: ExecCommandOptions, ): Promise { const isWindows = process.platform === "win32"; - const resolvedCommand = isWindows ? quoteForCmd(command) : command; - const resolvedArgs = isWindows ? args.map(quoteForCmd) : args; + const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; + const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; return execFileAsync(resolvedCommand, resolvedArgs, { cwd: options?.cwd, From 6dfcb4daa98c2197fb89c66cf3330e59e7ef5d12 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 20:27:44 +0700 Subject: [PATCH 14/21] fix: Windows test compatibility for worktree, snapshot, and history paths - Replace `mkdir -p` shell calls with `mkdirSync({ recursive: true })` in worktree tests (cmd.exe doesn't support -p) - Normalize path assertions in provider-snapshot-manager tests for Windows drive-letter resolved paths - Add `:` to Claude history path sanitization so Windows drive letters (C:\) don't produce invalid directory names --- .../agent/provider-snapshot-manager.test.ts | 91 ++++++++++--------- .../server/agent/providers/claude-agent.ts | 7 +- ...ude-agent.voice-history-regression.test.ts | 6 +- packages/server/src/utils/worktree.test.ts | 22 +++-- 4 files changed, 71 insertions(+), 55 deletions(-) diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 111637d19..43d0cd095 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; @@ -44,6 +45,10 @@ const TEST_CAPABILITIES = { } as const; describe("ProviderSnapshotManager", () => { + const projectCwd = resolve("/tmp/project"); + const projectACwd = resolve("/tmp/project-a"); + const projectBCwd = resolve("/tmp/project-b"); + test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => { const codexModels = deferred(); const claudeModels = deferred(); @@ -59,7 +64,7 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot(projectCwd); expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "claude"]); expect(getProviderEntry(snapshot, "claude")).toMatchObject({ @@ -102,14 +107,14 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready"); - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); }); - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot(projectCwd); expect(getProviderEntry(snapshot, "codex")).toMatchObject({ provider: "codex", status: "ready", @@ -142,10 +147,10 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "unavailable", @@ -173,10 +178,10 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "error", @@ -215,7 +220,7 @@ describe("ProviderSnapshotManager", () => { }; manager.on("change", listener); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); claudeModels.resolve([createModel("claude", "sonnet")]); claudeModes.resolve([createMode("default")]); @@ -224,7 +229,7 @@ describe("ProviderSnapshotManager", () => { expect(changes).toHaveLength(1); }); - expect(changes[0]?.cwd).toBe("/tmp/project"); + expect(changes[0]?.cwd).toBe(projectCwd); expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready"); expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); @@ -256,16 +261,16 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( "gpt-5.1", ); }); - manager.refresh("/tmp/project"); - expect(manager.getSnapshot("/tmp/project")).toEqual([ + manager.refresh(projectCwd); + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "loading", @@ -276,7 +281,7 @@ describe("ProviderSnapshotManager", () => { ]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( "gpt-5.2", ); }); @@ -300,9 +305,9 @@ describe("ProviderSnapshotManager", () => { const changes: ProviderSnapshotEntry[][] = []; manager.on("change", (entries) => changes.push(entries)); - manager.refresh("/tmp/project"); + manager.refresh(projectCwd); - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "loading", @@ -312,9 +317,9 @@ describe("ProviderSnapshotManager", () => { }, ]); - manager.refresh("/tmp/project"); - manager.refresh("/tmp/project"); - manager.refresh("/tmp/project"); + manager.refresh(projectCwd); + manager.refresh(projectCwd); + manager.refresh(projectCwd); expect(changes).toHaveLength(1); expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); @@ -323,7 +328,7 @@ describe("ProviderSnapshotManager", () => { fetchModes.resolve([createMode("auto")]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({ + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ provider: "codex", status: "ready", models: [createModel("codex", "gpt-5.2")], @@ -351,9 +356,9 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); - manager.getSnapshot("/tmp/project"); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); + manager.getSnapshot(projectCwd); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); @@ -365,7 +370,7 @@ describe("ProviderSnapshotManager", () => { codexModels.resolve([createModel("codex", "gpt-5.2")]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); }); manager.destroy(); @@ -384,25 +389,21 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project-a"); - manager.getSnapshot("/tmp/project-b"); + manager.getSnapshot(projectACwd); + manager.getSnapshot(projectBCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe( - "ready", - ); - expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe( - "ready", - ); + expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready"); }); - expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe( - "model:/tmp/project-a", + expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe( + `model:${projectACwd}`, ); - expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe( - "model:/tmp/project-b", + expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe( + `model:${projectBCwd}`, ); - expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]); + expect(seenCwds).toEqual([projectACwd, projectBCwd]); manager.destroy(); }); @@ -420,13 +421,13 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("ready"); }); - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({ + expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({ provider: "zai", status: "ready", label: "ZAI", @@ -441,7 +442,7 @@ describe("ProviderSnapshotManager", () => { const { registry } = createRegistry([createMockProvider({ provider: "claude" })]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot(projectCwd); expect(snapshot.map((entry) => entry.provider)).toEqual(["claude"]); expect(getProviderEntry(snapshot, "zai")).toBeUndefined(); @@ -464,7 +465,7 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "zai", status: "loading", @@ -478,7 +479,7 @@ describe("ProviderSnapshotManager", () => { modes.resolve([createMode("plan")]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({ + expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({ provider: "zai", status: "ready", label: "ZAI", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index cf0f75c59..9ce93275a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -241,6 +241,10 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau return value === "low" || value === "medium" || value === "high" || value === "max"; } +function sanitizeClaudeProjectPath(cwd: string): string { + return cwd.replace(/[\\/._:]/g, "-"); +} + type ClaudeOptionsLogSummary = { cwd: string | null; permissionMode: string | null; @@ -3260,8 +3264,7 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - // Match Claude CLI's path sanitization: replace slashes, dots, and underscores with dashes - const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const sanitized = sanitizeClaudeProjectPath(cwd); const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const dir = path.join(configDir, "projects", sanitized); return path.join(dir, `${sessionId}.jsonl`); diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index 2d2eb583c..e10f1af97 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -21,6 +21,10 @@ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; +function sanitizeClaudeProjectPath(cwd: string): string { + return cwd.replace(/[\\/._:]/g, "-"); +} + function buildSdkQueryMock() { const events = [ { @@ -106,7 +110,7 @@ describe("ClaudeAgentSession history replay regression", () => { configDir = path.join(tempRoot, "claude-config"); mkdirSync(cwd, { recursive: true }); - const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const sanitized = sanitizeClaudeProjectPath(cwd); const historyDir = path.join(configDir, "projects", sanitized); mkdirSync(historyDir, { recursive: true }); const historyPath = path.join(historyDir, "history-session.jsonl"); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4f36680c3..5b2ff42c5 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -13,7 +13,15 @@ import { } from "./worktree"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { execSync } from "child_process"; -import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; +import { + mkdtempSync, + mkdirSync, + rmSync, + existsSync, + realpathSync, + writeFileSync, + readFileSync, +} from "fs"; import { dirname, join } from "path"; import { tmpdir } from "os"; import net from "node:net"; @@ -30,7 +38,7 @@ describe("createWorktree", () => { paseoHome = join(tempDir, "paseo-home"); // Create a git repo with an initial commit - execSync(`mkdir -p ${repoDir}`); + mkdirSync(repoDir, { recursive: true }); execSync("git init -b main", { cwd: repoDir }); execSync("git config user.email 'test@test.com'", { cwd: repoDir }); execSync("git config user.name 'Test'", { cwd: repoDir }); @@ -68,7 +76,7 @@ describe("createWorktree", () => { const privateTempDir = realpathSync(varTempDir); const varRepoDir = join(varTempDir, "test-repo"); const varPaseoHome = join(varTempDir, "paseo-home"); - execSync(`mkdir -p ${varRepoDir}`); + mkdirSync(varRepoDir, { recursive: true }); execSync("git init -b main", { cwd: varRepoDir }); execSync("git config user.email 'test@test.com'", { cwd: varRepoDir }); execSync("git config user.name 'Test'", { cwd: varRepoDir }); @@ -118,7 +126,7 @@ describe("createWorktree", () => { it("treats non-git directories as non-worktrees without throwing", async () => { const nonGitDir = join(tempDir, "not-a-repo"); - execSync(`mkdir -p ${nonGitDir}`); + mkdirSync(nonGitDir, { recursive: true }); const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome }); @@ -494,7 +502,7 @@ describe("paseo worktree manager", () => { repoDir = join(tempDir, "test-repo"); paseoHome = join(tempDir, "paseo-home"); - execSync(`mkdir -p ${repoDir}`); + mkdirSync(repoDir, { recursive: true }); execSync("git init -b main", { cwd: repoDir }); execSync("git config user.email 'test@test.com'", { cwd: repoDir }); execSync("git config user.name 'Test'", { cwd: repoDir }); @@ -512,7 +520,7 @@ describe("paseo worktree manager", () => { const repoB = join(tempDir, "team-b", "test-repo"); for (const repo of [repoA, repoB]) { - execSync(`mkdir -p ${repo}`); + mkdirSync(repo, { recursive: true }); execSync("git init -b main", { cwd: repo }); execSync("git config user.email 'test@test.com'", { cwd: repo }); execSync("git config user.name 'Test'", { cwd: repo }); @@ -584,7 +592,7 @@ describe("paseo worktree manager", () => { }); const nestedDir = join(created.worktreePath, "nested", "dir"); - execSync(`mkdir -p ${nestedDir}`); + mkdirSync(nestedDir, { recursive: true }); await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome }); expect(existsSync(created.worktreePath)).toBe(false); From 61201d68a61ed5081c2678fccc5fdca277298de6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 20:36:59 +0700 Subject: [PATCH 15/21] =?UTF-8?q?fix:=20Windows=20test=20compat=20?= =?UTF-8?q?=E2=80=94=20shell=20quoting,=20line=20endings,=20binary=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single-quoted git commit messages in worktree tests with double quotes (cmd.exe doesn't treat single quotes as delimiters) - Trim stdout in spawn tests to handle \r\n vs \n - Use nonexistent binary name in executable test not-found case --- packages/server/src/utils/executable.test.ts | 3 +- packages/server/src/utils/spawn.test.ts | 16 +++--- packages/server/src/utils/worktree.test.ts | 56 ++++++++++---------- 3 files changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index 5f63d84ca..94e0d6c4f 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -49,6 +49,7 @@ async function loadExecutableModule(params?: { describe("findExecutable", () => { const originalPlatform = process.platform; + const missingBinaryName = "nonexistent-binary-xyz-12345"; function setPlatform(value: string) { Object.defineProperty(process, "platform", { value, writable: true }); @@ -118,7 +119,7 @@ describe("findExecutable", () => { }, }); - await expect(findExecutable("codex")).resolves.toBeNull(); + await expect(findExecutable(missingBinaryName)).resolves.toBeNull(); }); test("on Windows, falls back to the first extensionless candidate when needed", async () => { diff --git a/packages/server/src/utils/spawn.test.ts b/packages/server/src/utils/spawn.test.ts index 648d01d22..117a57edb 100644 --- a/packages/server/src/utils/spawn.test.ts +++ b/packages/server/src/utils/spawn.test.ts @@ -16,10 +16,10 @@ describe("execCommand", () => { }); test("returns stdout and stderr for a successful command", async () => { - await expect(execCommand("echo", ["hello"])).resolves.toEqual({ - stdout: "hello\n", - stderr: "", - }); + const result = await execCommand("echo", ["hello"]); + + expect(result.stdout.trim()).toBe("hello"); + expect(result.stderr).toBe(""); }); test("rejects when the command times out", async () => { @@ -46,9 +46,9 @@ describe("execCommand", () => { } : { command: "pwd", args: [] }; - await expect(execCommand(command.command, command.args, { cwd })).resolves.toEqual({ - stdout: `${cwd}\n`, - stderr: "", - }); + const result = await execCommand(command.command, command.args, { cwd }); + + expect(result.stdout.trim()).toBe(cwd); + expect(result.stderr).toBe(""); }); }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 5b2ff42c5..c63592c73 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -40,11 +40,11 @@ describe("createWorktree", () => { // Create a git repo with an initial commit mkdirSync(repoDir, { recursive: true }); execSync("git init -b main", { cwd: repoDir }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir }); - execSync("git config user.name 'Test'", { cwd: repoDir }); - execSync("echo 'hello' > file.txt", { cwd: repoDir }); + execSync('git config user.email "test@test.com"', { cwd: repoDir }); + execSync('git config user.name "Test"', { cwd: repoDir }); + execSync('echo "hello" > file.txt', { cwd: repoDir }); execSync("git add .", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); + execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir }); }); afterEach(() => { @@ -78,11 +78,11 @@ describe("createWorktree", () => { const varPaseoHome = join(varTempDir, "paseo-home"); mkdirSync(varRepoDir, { recursive: true }); execSync("git init -b main", { cwd: varRepoDir }); - execSync("git config user.email 'test@test.com'", { cwd: varRepoDir }); - execSync("git config user.name 'Test'", { cwd: varRepoDir }); - execSync("echo 'hello' > file.txt", { cwd: varRepoDir }); + execSync('git config user.email "test@test.com"', { cwd: varRepoDir }); + execSync('git config user.name "Test"', { cwd: varRepoDir }); + execSync('echo "hello" > file.txt', { cwd: varRepoDir }); execSync("git add .", { cwd: varRepoDir }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: varRepoDir }); + execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: varRepoDir }); const result = await createWorktree({ branchName: "main", @@ -163,19 +163,19 @@ describe("createWorktree", () => { execSync("git push -u origin main", { cwd: repoDir }); execSync(`git clone ${remoteDir} ${remoteCloneDir}`); - execSync("git config user.email 'test@test.com'", { cwd: remoteCloneDir }); - execSync("git config user.name 'Test'", { cwd: remoteCloneDir }); + execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir }); + execSync('git config user.name "Test"', { cwd: remoteCloneDir }); execSync("git checkout -B main origin/main", { cwd: remoteCloneDir }); writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n"); execSync("git add file.txt", { cwd: remoteCloneDir }); - execSync("git -c commit.gpgsign=false commit -m 'advance origin main'", { + execSync('git -c commit.gpgsign=false commit -m "advance origin main"', { cwd: remoteCloneDir, }); execSync("git push origin main", { cwd: remoteCloneDir }); writeFileSync(join(repoDir, "file.txt"), "from-local\n"); execSync("git add file.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'advance local main'", { cwd: repoDir }); + execSync('git -c commit.gpgsign=false commit -m "advance local main"', { cwd: repoDir }); execSync("git fetch origin", { cwd: repoDir }); @@ -194,7 +194,7 @@ describe("createWorktree", () => { it("falls back to local {branch} when origin/{branch} does not exist", async () => { writeFileSync(join(repoDir, "file.txt"), "from-local-only\n"); execSync("git add file.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'advance local main only'", { cwd: repoDir }); + execSync('git -c commit.gpgsign=false commit -m "advance local main only"', { cwd: repoDir }); const result = await createWorktree({ branchName: "prefer-local-fallback-feature", @@ -286,7 +286,7 @@ describe("createWorktree", () => { }, }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { + execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', { cwd: repoDir, }); @@ -320,7 +320,7 @@ describe("createWorktree", () => { }, }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { + execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', { cwd: repoDir, }); @@ -344,7 +344,7 @@ describe("createWorktree", () => { }, }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'", { + execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add streaming setup"', { cwd: repoDir, }); @@ -434,7 +434,7 @@ describe("createWorktree", () => { }, }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { + execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', { cwd: repoDir, }); @@ -504,11 +504,11 @@ describe("paseo worktree manager", () => { mkdirSync(repoDir, { recursive: true }); execSync("git init -b main", { cwd: repoDir }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir }); - execSync("git config user.name 'Test'", { cwd: repoDir }); - execSync("echo 'hello' > file.txt", { cwd: repoDir }); + execSync('git config user.email "test@test.com"', { cwd: repoDir }); + execSync('git config user.name "Test"', { cwd: repoDir }); + execSync('echo "hello" > file.txt', { cwd: repoDir }); execSync("git add .", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); + execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir }); }); afterEach(() => { @@ -522,11 +522,11 @@ describe("paseo worktree manager", () => { for (const repo of [repoA, repoB]) { mkdirSync(repo, { recursive: true }); execSync("git init -b main", { cwd: repo }); - execSync("git config user.email 'test@test.com'", { cwd: repo }); - execSync("git config user.name 'Test'", { cwd: repo }); - execSync("echo 'hello' > file.txt", { cwd: repo }); + execSync('git config user.email "test@test.com"', { cwd: repo }); + execSync('git config user.name "Test"', { cwd: repo }); + execSync('echo "hello" > file.txt', { cwd: repo }); execSync("git add .", { cwd: repo }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repo }); + execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repo }); } const fromRepoA = await createWorktree({ @@ -615,7 +615,7 @@ describe("paseo worktree manager", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); execSync( - "git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'", + 'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown commands"', { cwd: repoDir, }, @@ -654,7 +654,7 @@ describe("paseo worktree manager", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); execSync( - "git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown port logging'", + 'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown port logging"', { cwd: repoDir }, ); @@ -683,7 +683,7 @@ describe("paseo worktree manager", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); execSync( - "git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing teardown commands'", + 'git add paseo.json && git -c commit.gpgsign=false commit -m "add failing teardown commands"', { cwd: repoDir }, ); From 4806cd9a51ad320b6b9fc6c8c120a281e08dc2c4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 20:46:10 +0700 Subject: [PATCH 16/21] fix: skip bash-dependent tests on Windows, fix platform-aware assertions - Skip worktree test suite on Windows (uses bash shell syntax) - Skip ~ home-root directory test on Windows - Make findExecutable mock assertions platform-aware (which vs where.exe) - Use nonexistent binary name for not-found test case --- packages/server/src/utils/directory-suggestions.test.ts | 4 +++- packages/server/src/utils/executable.test.ts | 6 +++--- packages/server/src/utils/worktree.test.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index a1b00e140..9eb01e864 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js"; +const isWindows = process.platform === "win32"; + describe("searchHomeDirectories", () => { let tempRoot: string; let homeDir: string; @@ -101,7 +103,7 @@ describe("searchHomeDirectories", () => { expect(earlierIndex).toBeLessThan(laterIndex); }); - it("returns home-root suggestions when query is '~'", async () => { + it.skipIf(isWindows)("returns home-root suggestions when query is '~'", async () => { const results = await searchHomeDirectories({ homeDir, query: "~", diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index 94e0d6c4f..53cd412f9 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -142,9 +142,9 @@ describe("findExecutable", () => { await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex"); expect(execFileMock).toHaveBeenCalledWith( - "which", + process.platform === "win32" ? "where.exe" : "which", ["codex"], - { encoding: "utf8" }, + process.platform === "win32" ? { encoding: "utf8", windowsHide: true } : { encoding: "utf8" }, expect.any(Function), ); }); @@ -170,7 +170,7 @@ describe("findExecutable", () => { }, }); - await expect(findExecutable("codex")).resolves.toBeNull(); + await expect(findExecutable(missingBinaryName)).resolves.toBeNull(); }); }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index c63592c73..d385e558d 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -26,7 +26,7 @@ import { dirname, join } from "path"; import { tmpdir } from "os"; import net from "node:net"; -describe("createWorktree", () => { +describe.skipIf(process.platform === "win32")("createWorktree", () => { let tempDir: string; let repoDir: string; let paseoHome: string; From 0d0a8a791a7e6366ee0726c5215954b40c9da1e3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 20:54:12 +0700 Subject: [PATCH 17/21] fix: scope Windows CI to Windows-critical test files only The full server test suite has deep Unix assumptions (hardcoded /tmp paths, mkdir -p, Unix sockets, bash variable expansion). Rather than porting the entire suite, run only the tests that exercise Windows-specific code paths: executable resolution, spawn/exec, git command handling, provider registry, and config loading. --- .github/workflows/ci.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5d8977d2..4b144c5e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,9 +88,6 @@ jobs: node-version: '20' cache: 'npm' - - name: Fetch origin/main (worktree tests) - run: git fetch --no-tags origin main:refs/remotes/origin/main - - name: Install dependencies run: npm install @@ -100,11 +97,9 @@ jobs: - name: Build relay dependency run: npm run build --workspace=@getpaseo/relay - - name: Run server tests - run: npm run test --workspace=@getpaseo/server - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + - name: Run Windows-critical server tests + working-directory: packages/server + run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts app-tests: runs-on: ubuntu-latest From bdb300b919cdbf7d5fbdc30da8ea45d850bc9167 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 21:01:28 +0700 Subject: [PATCH 18/21] fix: skip Unix-specific which path test on Windows The "warns when which returns non-absolute path" test exercises Unix which behavior. On Windows, where.exe path resolution works differently and the test expectation doesn't apply. --- packages/server/src/utils/executable.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index 53cd412f9..0d83189f6 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -149,7 +149,7 @@ describe("findExecutable", () => { ); }); - test("warns and returns null when the final which line is not an absolute path", async () => { + test.skipIf(process.platform === "win32")("warns and returns null when the final which line is not an absolute path", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { findExecutable } = await loadExecutableModule({ execFileImpl: (_command, _args, _options, callback) => { From 7ef47e9193ef8ec67ec83cf45d2d6fa1b983589f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 21:05:08 +0700 Subject: [PATCH 19/21] fix: format executable test --- packages/server/src/utils/executable.test.ts | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index 0d83189f6..92481a8e3 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -149,19 +149,22 @@ describe("findExecutable", () => { ); }); - test.skipIf(process.platform === "win32")("warns and returns null when the final which line is not an absolute path", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { findExecutable } = await loadExecutableModule({ - execFileImpl: (_command, _args, _options, callback) => { - callback(null, "codex\n", ""); - }, - }); + test.skipIf(process.platform === "win32")( + "warns and returns null when the final which line is not an absolute path", + async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "codex\n", ""); + }, + }); - await expect(findExecutable("codex")).resolves.toBeNull(); - expect(warnSpy).toHaveBeenCalledOnce(); + await expect(findExecutable("codex")).resolves.toBeNull(); + expect(warnSpy).toHaveBeenCalledOnce(); - warnSpy.mockRestore(); - }); + warnSpy.mockRestore(); + }, + ); test("returns null when which lookup fails", async () => { const { findExecutable } = await loadExecutableModule({ From 7ad448209616133d66c5b914a7073e0f6ee0bfd5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 21:24:53 +0700 Subject: [PATCH 20/21] chore(release): cut 0.1.55-rc.1 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 280d4c25b..98547d9c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.54", + "version": "0.1.55-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.54", + "version": "0.1.55-rc.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.54", + "version": "0.1.55-rc.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.54", - "@getpaseo/highlight": "0.1.54", - "@getpaseo/server": "0.1.54", + "@getpaseo/expo-two-way-audio": "0.1.55-rc.1", + "@getpaseo/highlight": "0.1.55-rc.1", + "@getpaseo/server": "0.1.55-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35056,11 +35056,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.54", + "version": "0.1.55-rc.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.54", - "@getpaseo/server": "0.1.54", + "@getpaseo/relay": "0.1.55-rc.1", + "@getpaseo/server": "0.1.55-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35101,11 +35101,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.54", + "version": "0.1.55-rc.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.54", - "@getpaseo/server": "0.1.54", + "@getpaseo/cli": "0.1.55-rc.1", + "@getpaseo/server": "0.1.55-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35139,7 +35139,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.54", + "version": "0.1.55-rc.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35340,7 +35340,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.54", + "version": "0.1.55-rc.1", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35366,7 +35366,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.54", + "version": "0.1.55-rc.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35382,14 +35382,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.54", + "version": "0.1.55-rc.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.54", - "@getpaseo/relay": "0.1.54", + "@getpaseo/highlight": "0.1.55-rc.1", + "@getpaseo/relay": "0.1.55-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35817,7 +35817,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.54", + "version": "0.1.55-rc.1", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 67cd4d73e..b86985f31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.54", + "version": "0.1.55-rc.1", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 0e028248b..3022abd71 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.54", + "version": "0.1.55-rc.1", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.54", - "@getpaseo/highlight": "0.1.54", - "@getpaseo/server": "0.1.54", + "@getpaseo/expo-two-way-audio": "0.1.55-rc.1", + "@getpaseo/highlight": "0.1.55-rc.1", + "@getpaseo/server": "0.1.55-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e5dbe0c1..8df36b332 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.54", + "version": "0.1.55-rc.1", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.54", - "@getpaseo/server": "0.1.54", + "@getpaseo/relay": "0.1.55-rc.1", + "@getpaseo/server": "0.1.55-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 7113458eb..7269b9949 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.54", + "version": "0.1.55-rc.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.54", - "@getpaseo/server": "0.1.54", + "@getpaseo/cli": "0.1.55-rc.1", + "@getpaseo/server": "0.1.55-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 00c8c1c40..7c3a05ca8 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.54", + "version": "0.1.55-rc.1", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 6fe583e3d..9b8cfb9cf 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.54", + "version": "0.1.55-rc.1", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index d4da0765f..87cf83025 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.54", + "version": "0.1.55-rc.1", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index e0b9b5120..55637d101 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.54", + "version": "0.1.55-rc.1", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.54", - "@getpaseo/relay": "0.1.54", + "@getpaseo/highlight": "0.1.55-rc.1", + "@getpaseo/relay": "0.1.55-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index be6e565fd..cbd5b70af 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.54", + "version": "0.1.55-rc.1", "private": true, "type": "module", "scripts": { From 8d1def4615f8611c3f8f0b397a0c404fc473b932 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 14:25:52 +0000 Subject: [PATCH 21/21] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 5f7783bcd..7781643dd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-bLM+tKHQiiu2ggZeYKFAO6OQFNGVxzXgxNQf+vNpTUQ="; + npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).