diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 801e0c067..4b144c5e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,31 @@ 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: 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 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 steps: 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/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/nix/package.nix b/nix/package.nix index 1ef702bf2..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-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU="; + 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). diff --git a/package-lock.json b/package-lock.json index f08ca897c..3348ae3bc 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": [ @@ -36169,16 +36169,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", @@ -36319,11 +36319,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", @@ -36364,11 +36364,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" @@ -36403,7 +36403,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", @@ -36604,7 +36604,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", @@ -36630,7 +36630,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", @@ -36646,14 +36646,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", @@ -36672,6 +36672,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", @@ -36949,6 +36950,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", @@ -37046,6 +37062,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", @@ -37057,7 +37085,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 4360930e5..28f82c417 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/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index c42861f64..a823d8a58 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -117,7 +117,6 @@ export async function clickTerminal(page: Page): Promise { await button.click(); } - // ─── Tab title assertions ────────────────────────────────────────────────── /** Wait for any tab in the bar to display the given title text. */ @@ -188,6 +187,8 @@ export async function sampleTabsDuringTransition( // ─── Workspace setup ─────────────────────────────────────────────────────── /** Create a temp git repo and return its path with a cleanup function. */ -export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType { +export async function createWorkspace( + prefix = "launcher-e2e-", +): ReturnType { return createTempGitRepo(prefix); } 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/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 026351e4f..aeab46838 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -8,9 +8,7 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; export type TerminalPerfDaemonClient = { connect(): Promise; close(): Promise; - openProject( - cwd: string, - ): Promise<{ + openProject(cwd: string): Promise<{ workspace: { id: number; name: string; projectRootPath: string } | null; error: string | null; }>; diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/helpers/workspace-lifecycle.ts index 4006e42d2..a462bbfd1 100644 --- a/packages/app/e2e/helpers/workspace-lifecycle.ts +++ b/packages/app/e2e/helpers/workspace-lifecycle.ts @@ -1,8 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { - clickNewChat, - clickTerminal, -} from "./launcher"; +import { clickNewChat, clickTerminal } from "./launcher"; import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf"; function terminalSurface(page: Page) { diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index de47cf3aa..7ec6f6252 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -10,9 +10,7 @@ import type { SessionOutboundMessage } from "@server/shared/messages"; type WorkspaceSetupDaemonClient = { connect(): Promise; close(): Promise; - openProject( - cwd: string, - ): Promise<{ + openProject(cwd: string): Promise<{ workspace: { id: number; name: string; @@ -21,9 +19,7 @@ type WorkspaceSetupDaemonClient = { } | null; error: string | null; }>; - createPaseoWorktree( - input: { cwd: string; worktreeSlug?: string }, - ): Promise<{ + createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{ workspace: { id: number; name: string; @@ -45,15 +41,11 @@ type WorkspaceSetupDaemonClient = { agent: { id: string; cwd: string; workspaceId?: string | null }; }>; }>; - fetchAgent( - agentId: string, - ): Promise<{ + fetchAgent(agentId: string): Promise<{ agent: { id: string; cwd: string } | null; project: unknown | null; } | null>; - listTerminals( - cwd: string, - ): Promise<{ + listTerminals(cwd: string): Promise<{ cwd?: string; terminals: Array<{ id: string; cwd: string; name: string }>; error?: string | null; @@ -77,7 +69,11 @@ function getDaemonWsUrl(): string { } async function loadDaemonClientConstructor(): Promise< - new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient + new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => WorkspaceSetupDaemonClient > { const repoRoot = path.resolve(process.cwd(), "../.."); const moduleUrl = pathToFileURL( @@ -149,7 +145,9 @@ export async function createWorkspaceFromSidebar(page: Page, repoPath: string): await expect(button).toBeEnabled({ timeout: 30_000 }); await button.click(); await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 }); - await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ + timeout: 30_000, + }); } export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise { @@ -193,7 +191,10 @@ export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Pr .click(); } -export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise { +export async function waitForWorkspaceSetupDialogToClose( + page: Page, + timeoutMs = 45_000, +): Promise { const dialog = workspaceSetupDialog(page); try { @@ -243,7 +244,9 @@ export async function expectSetupLogContains(page: Page, text: string): Promise< } export async function expectNoSetupMessage(page: Page): Promise { - await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({ + await expect( + page.getByText("No setup commands ran for this workspace.", { exact: true }), + ).toBeVisible({ timeout: 30_000, }); } @@ -276,7 +279,8 @@ export async function findWorktreeWorkspaceForProject( const workspace = payload.entries.find( (entry) => - entry.projectRootPath === normalizedRepoPath && entry.workspaceDirectory !== normalizedRepoPath, + entry.projectRootPath === normalizedRepoPath && + entry.workspaceDirectory !== normalizedRepoPath, ) ?? null; if (!workspace) { throw new Error(`Failed to find created worktree workspace for ${repoPath}`); diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts index 34211cf48..7d3202a59 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -63,9 +63,7 @@ test.describe("Tab creation", () => { const countAfterFirst = await countTabsOfKind(page, "draft"); await pressNewTabShortcut(page); - await expect - .poll(() => countTabsOfKind(page, "draft")) - .toBe(countAfterFirst + 1); + await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countAfterFirst + 1); }); test("clicking new agent tab creates a draft tab", async ({ page }) => { @@ -201,12 +199,7 @@ test.describe("Tab transitions (no flash)", () => { await gotoWorkspace(page, workspaceId); // Sample tabs at high frequency across the transition - const snapshots = await sampleTabsDuringTransition( - page, - () => clickNewChat(page), - 2_000, - 30, - ); + const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30); // Every snapshot should have at least one tab — no blank/zero-tab frames for (const snapshot of snapshots) { @@ -246,12 +239,7 @@ test.describe("Tab transitions (no flash)", () => { const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); - const elapsed = await measureTileTransition( - page, - () => clickNewChat(page), - composer, - 10_000, - ); + const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000); // Draft creation is fully in-memory — should be fast // We use a generous budget here because CI can be slow, but the key assertion 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/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index fd77319d9..19fbce129 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -57,7 +57,10 @@ async function openProjectViaDaemon( }; } -async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) { +async function openWorkspaceFromSidebar( + page: import("@playwright/test").Page, + workspaceId: string, +) { const row = page.getByTestId(getWorkspaceRowTestId(workspaceId)); await expect(row).toBeVisible({ timeout: 30_000 }); await row.click(); @@ -65,10 +68,7 @@ async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, w return row; } -async function waitForSidebarProject( - page: import("@playwright/test").Page, - projectName: string, -) { +async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) { const row = page .getByRole("button", { name: new RegExp(escapeRegex(projectName), "i"), diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts index d24a77421..2a8abe1d3 100644 --- a/packages/app/e2e/workspace-cwd.spec.ts +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -2,14 +2,8 @@ import { execSync } from "node:child_process"; import { realpathSync } from "node:fs"; import path from "node:path"; import { expect, test } from "./fixtures"; -import { - clickTerminal, - waitForTabBar, -} from "./helpers/launcher"; -import { - setupDeterministicPrompt, - waitForTerminalContent, -} from "./helpers/terminal-perf"; +import { clickTerminal, waitForTabBar } from "./helpers/launcher"; +import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf"; import { createTempGitRepo } from "./helpers/workspace"; import { connectWorkspaceSetupClient, @@ -88,10 +82,13 @@ test.describe("Workspace cwd correctness", () => { try { await seedProjectForWorkspaceSetup(client, repo.path); - execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { - cwd: repo.path, - stdio: "ignore", - }); + execSync( + `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, + { + cwd: repo.path, + stdio: "ignore", + }, + ); worktreeCreated = true; const workspaceResult = await client.openProject(worktreePath); @@ -132,5 +129,4 @@ test.describe("Workspace cwd correctness", () => { await repo.cleanup(); } }); - }); diff --git a/packages/app/e2e/workspace-hover-card.spec.ts b/packages/app/e2e/workspace-hover-card.spec.ts index f58ff4898..e58baf13e 100644 --- a/packages/app/e2e/workspace-hover-card.spec.ts +++ b/packages/app/e2e/workspace-hover-card.spec.ts @@ -45,17 +45,21 @@ async function expectScriptInCard(page: Page, scriptName: string): Promise /** Asserts the script status dot indicates "running". */ async function expectScriptRunning(page: Page, scriptName: string): Promise { const card = page.getByTestId("workspace-hover-card"); - await expect( - card.getByTestId(`hover-card-script-status-${scriptName}`), - ).toHaveAttribute("aria-label", "Running", { timeout: 10_000 }); + await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute( + "aria-label", + "Running", + { timeout: 10_000 }, + ); } /** Asserts the script lifecycle is stopped. */ async function expectScriptStopped(page: Page, scriptName: string): Promise { const card = page.getByTestId("workspace-hover-card"); - await expect( - card.getByTestId(`hover-card-script-status-${scriptName}`), - ).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 }); + await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute( + "aria-label", + "Stopped", + { timeout: 10_000 }, + ); } /** Asserts the script health label shown in the hover card. */ @@ -117,7 +121,8 @@ test.describe("Workspace hover card", () => { // Wait for setup completion via daemon (setup snapshots are per-session) const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && payload.detail.log.includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -182,7 +187,9 @@ test.describe("Workspace hover card", () => { } await openHomeWithProject(page, repo.path); - const wsRow = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspace.id}`); + const wsRow = page.getByTestId( + `sidebar-workspace-row-${getServerId()}:${workspace.workspace.id}`, + ); await expect(wsRow).toBeVisible({ timeout: 30_000 }); await expectHoverCard(page, workspace.workspace.name); diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/workspace-lifecycle.spec.ts index 86b058e8f..ce2591199 100644 --- a/packages/app/e2e/workspace-lifecycle.spec.ts +++ b/packages/app/e2e/workspace-lifecycle.spec.ts @@ -107,10 +107,13 @@ test.describe("Workspace lifecycle", () => { try { await seedProjectForWorkspaceSetup(client, repo.path); - execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { - cwd: repo.path, - stdio: "ignore", - }); + execSync( + `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, + { + cwd: repo.path, + stdio: "ignore", + }, + ); worktreeCreated = true; const workspaceResult = await client.openProject(worktreePath); @@ -154,10 +157,13 @@ test.describe("Workspace lifecycle", () => { try { await seedProjectForWorkspaceSetup(client, repo.path); - execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { - cwd: repo.path, - stdio: "ignore", - }); + execSync( + `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, + { + cwd: repo.path, + stdio: "ignore", + }, + ); worktreeCreated = true; const workspaceResult = await client.openProject(worktreePath); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts index 1f2820552..0ea8fe84f 100644 --- a/packages/app/e2e/workspace-setup-runtime.spec.ts +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -1,10 +1,7 @@ import { existsSync } from "node:fs"; import { expect, test } from "./fixtures"; import { createTempGitRepo } from "./helpers/workspace"; -import { - clickTerminal, - waitForTabBar, -} from "./helpers/launcher"; +import { clickTerminal, waitForTabBar } from "./helpers/launcher"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, @@ -82,15 +79,11 @@ test.describe("Workspace setup runtime authority", () => { // Verify terminal is listed under the worktree directory, not the original repo await expect - .poll( - async () => - (await client.listTerminals(workspaceDir)).terminals.length > 0, - { timeout: 30_000 }, - ) + .poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, { + timeout: 30_000, + }) .toBe(true); - expect( - (await client.listTerminals(repo.path)).terminals.length, - ).toBe(0); + expect((await client.listTerminals(repo.path)).terminals.length).toBe(0); } finally { await client.close(); await repo.cleanup(); diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index 72c8fa240..288d33ec1 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -36,7 +36,9 @@ test.describe("Workspace setup streaming", () => { const repo = await createTempGitRepo("setup-open-", { paseoConfig: { worktree: { - setup: ["sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'"], + setup: [ + "sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'", + ], }, }, }); @@ -77,7 +79,8 @@ test.describe("Workspace setup streaming", () => { // so the browser session won't receive progress events). const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && payload.detail.log.includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -138,7 +141,8 @@ test.describe("Workspace setup streaming", () => { ); const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && payload.detail.log.includes("setup complete"), ); await createWorkspaceThroughDaemon(client, { @@ -243,7 +247,8 @@ test.describe("Workspace setup streaming", () => { // Wait for setup completion via daemon (setup snapshots are per-session) const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && payload.detail.log.includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -296,7 +301,8 @@ test.describe("Workspace setup streaming", () => { await seedProjectForWorkspaceSetup(client, repo.path); const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && payload.detail.log.includes("setup complete"), ); const result = await client.createPaseoWorktree({ 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/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 27d6c740e..c212b7e87 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -83,6 +83,7 @@ import { decodeWorkspaceIdFromPathSegment, } from "@/utils/host-routes"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; +import { isWeb, isNative } from "@/constants/platform"; polyfillCrypto(); @@ -92,6 +93,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, @@ -103,7 +120,7 @@ function PushNotificationRouter() { const lastHandledIdRef = useRef(null); useEffect(() => { - if (Platform.OS === "web") { + if (isWeb) { let removeDesktopNotificationListener: (() => void) | null = null; let cancelled = false; @@ -392,10 +409,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]", @@ -580,7 +597,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { }, [settingsLoading, settings.theme]); useEffect(() => { - if (settingsLoading || Platform.OS !== "web") { + if (settingsLoading || isNative) { return; } @@ -789,7 +806,14 @@ function RootStack() { - + { + const serverId = getRouteParamValue(params?.serverId); + const workspaceId = getRouteParamValue(params?.workspaceId); + return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined; + }} + /> 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 ac38f8434..6962ef4ab 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,10 @@ import { useEffect, useRef, useState } from "react"; -import { useGlobalSearchParams, useLocalSearchParams, usePathname, useRootNavigationState } from "expo-router"; -import { Platform } from "react-native"; +import { + useGlobalSearchParams, + useLocalSearchParams, + usePathname, + useRootNavigationState, +} from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; @@ -11,6 +15,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") { @@ -91,7 +96,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 fb9882791..3bdda743f 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"; @@ -16,6 +16,7 @@ import { import { X } from "lucide-react-native"; import { FileDropZone } from "@/components/file-drop-zone"; import type { ImageAttachment } from "@/components/message-input"; +import { isWeb } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ desktopOverlay: { @@ -143,7 +144,7 @@ export function AdaptiveModalSheet({ const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); useEffect(() => { - if (isMobile || !visible || Platform.OS !== "web" || typeof window === "undefined") return; + if (isMobile || !visible || !isWeb || typeof window === "undefined") return; function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { e.preventDefault(); @@ -253,16 +254,16 @@ export function AdaptiveModalSheet({ /> {onFilesDropped ? ( - - {cardInner} - - ) : cardInner} + {cardInner} + ) : ( + cardInner + )} ); // 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..d8a12afd3 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); @@ -556,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-list.tsx b/packages/app/src/components/agent-list.tsx index 9245ae1f6..9db2e94c2 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -262,23 +262,20 @@ export function AgentList({ [isActionSheetVisible, onAgentSelect], ); - const handleAgentLongPress = useCallback( - (agent: AggregatedAgent) => { - const isRunning = agent.status === "running" || agent.status === "initializing"; - if (isRunning) { - setActionAgent(agent); - return; - } + const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { + const isRunning = agent.status === "running" || agent.status === "initializing"; + if (isRunning) { + setActionAgent(agent); + return; + } - const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null; - if (!client) { - setActionAgent(agent); - return; - } - void client.archiveAgent(agent.id); - }, - [], - ); + const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null; + if (!client) { + setActionAgent(agent); + return; + } + void client.archiveAgent(agent.id); + }, []); const handleCloseActionSheet = useCallback(() => { setActionAgent(null); @@ -288,7 +285,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/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 501963f7a..f79392b9d 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"; @@ -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, @@ -51,6 +51,7 @@ import { getStatusSelectorHint, resolveAgentModelSelection, } from "@/components/agent-status-bar.utils"; +import { isWeb as platformIsWeb } from "@/constants/platform"; type StatusOption = { id: string; @@ -59,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[]; @@ -80,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; @@ -222,7 +219,6 @@ function ControlledStatusBar({ onModelSelectorOpen, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); - const isWeb = Platform.OS === "web"; const [prefsOpen, setPrefsOpen] = useState(false); const [openSelector, setOpenSelector] = useState(null); @@ -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( @@ -356,7 +352,7 @@ function ControlledStatusBar({ return ( - {isWeb ? ( + {platformIsWeb ? ( <> {providerOptions && providerOptions.length > 0 ? ( <> @@ -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(); @@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({ onModelSelectorOpen, disabled = false, }: DraftAgentStatusBarProps) { - const isWeb = Platform.OS === "web"; const { preferences, updatePreferences } = useFormPreferences(); const mappedModeOptions = useMemo(() => { @@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({ const effectiveSelectedThinkingOption = selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; - if (isWeb) { + if (platformIsWeb) { return ( item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -222,7 +223,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(""); @@ -601,7 +600,7 @@ export function CombinedModelSelector({ }, [selectedModelLabel]); useEffect(() => { - if (isWeb) { + if (platformIsWeb) { return; } @@ -615,7 +614,7 @@ export function CombinedModelSelector({ }); return () => cancelAnimationFrame(frame); - }, [isOpen, isWeb]); + }, [isOpen, platformIsWeb]); return ( <> @@ -678,7 +677,7 @@ export function CombinedModelSelector({ ) : undefined @@ -793,8 +792,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, - level2Header: { - }, + level2Header: {}, backButton: { flexDirection: "row", alignItems: "center", 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/composer.tsx b/packages/app/src/components/composer.tsx index 5d43cd0eb..1ec7fe397 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.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; @@ -126,7 +127,7 @@ export function Composer({ }: ComposerProps) { markScrollInvestigationRender(`Composer:${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); @@ -164,7 +165,7 @@ export function Composer({ 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; @@ -225,7 +226,7 @@ export function Composer({ }, [addImages, onAddImages]); const focusInput = useCallback(() => { - if (Platform.OS !== "web") return; + if (isNative) return; focusWithRetries({ focus: () => messageInputRef.current?.focus(), isFocused: () => { @@ -438,7 +439,7 @@ export function Composer({ 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; } @@ -632,14 +633,19 @@ export function Composer({ style={({ hovered }) => [ styles.realtimeVoiceButton as any, (hovered ? styles.iconButtonHovered : undefined) as any, - (!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any, + (!isConnected || voice?.isVoiceSwitching + ? styles.buttonDisabled + : undefined) as any, ]} > {({ hovered }) => voice?.isVoiceSwitching ? ( ) : ( - + ) } 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 7a6a308ff..6a416cc40 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -7,7 +7,6 @@ import { ActivityIndicator, Pressable, FlatList, - Platform, type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, @@ -80,6 +79,7 @@ import { formatDiffGutterText, hasVisibleDiffTokens, } from "@/utils/diff-rendering"; +import { isWeb, isNative } from "@/constants/platform"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -100,7 +100,7 @@ type WrappedWebTextStyle = TextStyle & { }; function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { - if (Platform.OS !== "web") { + if (isNative) { return undefined; } return wrapLines @@ -454,7 +454,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/icons/aider-icon.tsx b/packages/app/src/components/icons/aider-icon.tsx index 1deb4a218..6b2fc25e6 100644 --- a/packages/app/src/components/icons/aider-icon.tsx +++ b/packages/app/src/components/icons/aider-icon.tsx @@ -8,9 +8,7 @@ interface AiderIconProps { export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) { return ( - + ); } 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 5bbb0ea3d..9fe2400d7 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; @@ -121,14 +121,16 @@ export interface MessageInputRef { const MIN_INPUT_HEIGHT_MOBILE = 30; const MIN_INPUT_HEIGHT_DESKTOP = 46; const MAX_INPUT_HEIGHT = 160; -const IS_WEB = Platform.OS === "web"; -const MIN_INPUT_HEIGHT = IS_WEB ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; +const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; 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; } >; @@ -232,7 +234,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(); @@ -303,7 +305,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; @@ -338,7 +340,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: () => { @@ -381,7 +383,7 @@ export const MessageInput = forwardRef(funct onChangeText(nextValue); } - if (IS_WEB && typeof requestAnimationFrame === "function") { + if (isWeb && typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { measureWebInputHeight("dictation"); }); @@ -637,13 +639,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 => { @@ -657,7 +659,7 @@ export const MessageInput = forwardRef(funct }, []); useEffect(() => { - if (!IS_WEB || !onAddImages) { + if (!isWeb || !onAddImages) { return; } @@ -710,7 +712,7 @@ export const MessageInput = forwardRef(funct ]); useEffect(() => { - if (!IS_WEB || typeof ResizeObserver === "undefined") { + if (!isWeb || typeof ResizeObserver === "undefined") { return; } @@ -764,7 +766,7 @@ export const MessageInput = forwardRef(funct }, [getWebElement, getWebTextArea]); useEffect(() => { - if (!IS_WEB) { + if (!isWeb) { return; } const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; @@ -802,7 +804,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; @@ -856,7 +858,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, }); @@ -876,7 +878,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(), @@ -890,12 +892,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({ @@ -930,7 +937,8 @@ export const MessageInput = forwardRef(funct const hasRealContent = value.trim().length > 0 || hasImages; const hasSendableContent = hasRealContent || allowEmptySubmit; const shouldShowSendButton = hasSendableContent || isSubmitLoading; - const showEmptySubmitLabel = allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading; + const showEmptySubmitLabel = + allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); @@ -947,7 +955,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, @@ -960,7 +968,10 @@ export const MessageInput = forwardRef(funct return ( {/* Regular input */} - + {/* Image preview pills */} {hasImages && ( @@ -978,7 +989,7 @@ export const MessageInput = forwardRef(funct @@ -1010,7 +1021,7 @@ export const MessageInput = forwardRef(funct }} style={[ styles.textInput, - IS_WEB + isWeb ? { height: inputHeight, minHeight: MIN_INPUT_HEIGHT, @@ -1022,12 +1033,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} @@ -1051,7 +1062,10 @@ export const MessageInput = forwardRef(funct ]} > {({ hovered }) => ( - + )} @@ -1091,9 +1105,15 @@ export const MessageInput = forwardRef(funct isDictating ? ( ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( - + ) : ( - + ) } @@ -1150,7 +1170,9 @@ export const MessageInput = forwardRef(funct ({ xs: theme.spacing[3], md: theme.spacing[4], }, - ...(IS_WEB + ...(isWeb ? { transitionProperty: "border-color", transitionDuration: "200ms", @@ -1252,7 +1274,7 @@ const styles = StyleSheet.create(((theme: any) => ({ borderWidth: 1, borderColor: theme.colors.borderAccent, overflow: "hidden", - ...(IS_WEB + ...(isWeb ? { cursor: "pointer", } @@ -1277,7 +1299,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", @@ -1296,7 +1318,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, @@ -1318,7 +1340,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 785a7add1..a4bd68c61 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/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 06f6fd7ea..d0e2fffac 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react"; import { View, Text, ActivityIndicator, ScrollView } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { resolveProviderLabel } from "@/utils/provider-definitions"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; interface ProviderDiagnosticSheetProps { provider: string; @@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({ }: ProviderDiagnosticSheetProps) { const { theme } = useUnistyles(); const client = useHostRuntimeClient(serverId); + const { entries: snapshotEntries } = useProvidersSnapshot(serverId); const [diagnostic, setDiagnostic] = useState(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/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 6778dbc0b..b182f82b5 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -50,7 +50,10 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types"; import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { useIsCompactFormFactor } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; -import { buildHostNewWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; +import { + buildHostNewWorkspaceRoute, + parseHostWorkspaceRouteFromPathname, +} from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { type SidebarProjectEntry, @@ -99,6 +102,7 @@ import { import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { GitHubIcon } from "@/components/icons/github-icon"; import { createNameId } from "mnemonic-id"; +import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -214,18 +218,12 @@ export function PrBadge({ hint }: { hint: PrHint }) { hitSlop={4} onPressIn={handlePressIn} onPress={handlePress} - onPointerEnter={() => setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - style={({ pressed }) => [ - prBadgeStyles.badge, - pressed && prBadgeStyles.badgePressed, - ]} + onHoverIn={() => setIsHovered(true)} + onHoverOut={() => setIsHovered(false)} + style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]} > - + #{hint.number} @@ -281,7 +279,6 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({ }, })); - function WorkspaceStatusIndicator({ bucket, workspaceKind, @@ -613,7 +610,7 @@ function useLongPressDragInteraction(input: { input.drag(); }, DRAG_ARM_DELAY_MS); - if (!input.menuController || Platform.OS === "web") { + if (!input.menuController || platformIsWeb) { return; } @@ -778,7 +775,9 @@ function ProjectHeaderRow({ if (!serverId) { return; } - router.navigate(buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any); + router.navigate( + buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any, + ); onWorkspacePress?.(); }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); @@ -836,15 +835,18 @@ function ProjectHeaderRow({ ) : null} {onRemoveProject ? ( } - trailing={archiveShortcutKeys ? : null} + trailing={ + archiveShortcutKeys ? : null + } status={archiveStatus} pendingLabel={archivePendingLabel} onSelect={onArchive} @@ -1870,7 +1875,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()); @@ -2104,7 +2108,7 @@ export function SidebarWorkspaceList({ drag={drag} isDragging={isActive} dragHandleProps={dragHandleProps} - useNestable={isNative} + useNestable={platformIsNative} creatingWorkspaceIds={creatingWorkspaceIds} /> ); @@ -2121,7 +2125,7 @@ export function SidebarWorkspaceList({ serverId, shortcutIndexByWorkspaceKey, showShortcutBadges, - isNative, + platformIsNative, creatingWorkspaceIds, ], ); @@ -2145,7 +2149,7 @@ export function SidebarWorkspaceList({ onDragEnd={handleProjectDragEnd} scrollEnabled={false} useDragHandle - nestable={isNative} + nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} containerStyle={styles.projectListContainer} /> @@ -2156,7 +2160,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 39a06b5e0..a646bac20 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,9 +267,8 @@ export function Combobox({ }: ComboboxProps): ReactElement { const isMobile = useIsCompactFormFactor(); const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; - const isDesktopAboveSearch = - !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; - const { height: windowHeight } = useWindowDimensions(); + const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; + const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const bottomSheetRef = useRef(null); const hasPresentedBottomSheetRef = useRef(false); const snapPoints = useMemo(() => ["60%", "90%"], []); @@ -274,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); @@ -322,8 +326,8 @@ export function Combobox({ const middleware = useMemo( () => [ - floatingOffset(Platform.OS === "web" ? 5 : 4), - ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), + floatingOffset(isWeb ? 5 : 4), + ...(isWeb ? [] : [flip({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ padding: collisionPadding, @@ -336,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; }); @@ -346,7 +353,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: { @@ -357,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; @@ -379,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; + }); }); }; @@ -396,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; @@ -626,6 +657,7 @@ export function Combobox({ onChangeText={setSearchQueryWithCallback} onSubmitEditing={handleSubmitSearch} autoFocus={!isMobile} + useBottomSheetInput={isMobile} /> ); @@ -725,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/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 a3a0618d0..6bd314599 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}; @@ -257,10 +247,9 @@ export function TooltipTrigger({ triggerRefProp = "ref", ...props }: PressableProps & { - asChild?: boolean; - triggerRefProp?: string; - } -): ReactElement { + asChild?: boolean; + triggerRefProp?: string; +}): ReactElement { const ctx = useTooltipContext("TooltipTrigger"); const openTimerRef = useRef | null>(null); @@ -353,7 +342,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, @@ -472,7 +461,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 dde92399f..d74d87d35 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"; @@ -242,8 +243,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); useEffect(() => { - const currentPathname = - typeof window === "undefined" ? null : (window.location.pathname || null); + const currentPathname = typeof window === "undefined" ? null : window.location.pathname || null; if (currentPathname && currentPathname !== "/welcome") { return; } @@ -260,52 +260,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; @@ -326,7 +325,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/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index fef2f586c..98c7adc3e 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -289,10 +289,7 @@ function WorkspaceHoverCardContent({ <> [ - styles.checksSummaryRow, - hovered && styles.listRowHovered, - ]} + style={({ hovered }) => [styles.checksSummaryRow, hovered && styles.listRowHovered]} onPress={() => void openExternalUrl(`${prHint.url}/checks`)} > {({ hovered }) => { @@ -333,10 +330,7 @@ function WorkspaceHoverCardContent({ }, ]} > - + ) : null} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index b59ffbc6d..b509ee4bb 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -14,9 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { - requireWorkspaceExecutionAuthority, -} from "@/utils/workspace-execution"; +import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ImageAttachment, MessagePayload } from "./message-input"; @@ -174,7 +172,9 @@ export function WorkspaceSetupDialog() { } const encodedImages = await encodeImages(images); - const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; + const workspaceDirectory = requireWorkspaceExecutionAuthority({ + workspace, + }).workspaceDirectory; const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, @@ -222,7 +222,6 @@ export function WorkspaceSetupDialog() { ], ); - const workspaceTitle = workspace?.name || workspace?.projectDisplayName || 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 79c30c209..1107570b5 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"; @@ -55,6 +55,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; +import { isNative } from "@/constants/platform"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -301,12 +302,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const previousAgentStatusRef = useRef>(new Map()); const sendAgentMessageRef = useRef< - (( - agentId: string, - message: string, - images?: AttachmentMetadata[], - attachments?: AgentAttachment[], - ) => Promise) | null + | (( + agentId: string, + message: string, + images?: AttachmentMetadata[], + attachments?: AgentAttachment[], + ) => Promise) + | null >(null); const sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); @@ -562,7 +564,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/contexts/session-stream-reducers.test.ts b/packages/app/src/contexts/session-stream-reducers.test.ts index 45f7a06ed..cac018e35 100644 --- a/packages/app/src/contexts/session-stream-reducers.test.ts +++ b/packages/app/src/contexts/session-stream-reducers.test.ts @@ -154,10 +154,7 @@ describe("processTimelineResponse", () => { direction: "before", startSeq: 1, endSeq: 2, - entries: [ - makeTimelineEntry(1, "hello", "user_message"), - makeTimelineEntry(2, "older"), - ], + entries: [makeTimelineEntry(1, "hello", "user_message"), makeTimelineEntry(2, "older")], }, }); 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-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index 57df59356..1ea3fcd31 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(""); @@ -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 945f4e02b..ec00a73d3 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 @@ export 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 }; @@ -374,10 +367,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( @@ -386,17 +379,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/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 20df64bcd..310231a04 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-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 00d8dec50..fe500724e 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -123,7 +123,9 @@ function buildDraftComposerCommandConfig(input: { return { provider: input.provider, cwd, - ...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}), + ...(input.modeOptions.length > 0 && input.selectedMode !== "" + ? { modeId: input.selectedMode } + : {}), ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), ...(input.effectiveThinkingOptionId ? { thinkingOptionId: input.effectiveThinkingOptionId } 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-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index a76804b1f..1bd2de9cb 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -135,18 +135,8 @@ describe("useArchiveAgent", () => { it("can apply archived agent close results without invalidating cached lists", () => { const queryClient = new QueryClient(); - useSessionStore - .getState() - .initializeSession("server-a", {} as DaemonClient); - useSessionStore.getState().setAgents( - "server-a", - new Map([ - [ - "agent-1", - makeAgent(), - ], - ]), - ); + useSessionStore.getState().initializeSession("server-a", {} as DaemonClient); + useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]])); queryClient.setQueryData(["sidebarAgentsList", "server-a"], { entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], }); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 36d635e0a..5fc56a78c 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -181,7 +181,11 @@ function restoreArchivedAgentListCacheSnapshot( serverId: string, snapshot: ArchivedAgentListCacheSnapshot, ): void { - restoreCachedListSnapshot(queryClient, ["sidebarAgentsList", serverId], snapshot.sidebarAgentsList); + restoreCachedListSnapshot( + queryClient, + ["sidebarAgentsList", serverId], + snapshot.sidebarAgentsList, + ); restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents); } 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..9031d2b0c 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,11 +1,11 @@ import { useEffect, useMemo, useRef } from "react"; -import { Platform } from "react-native"; -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, @@ -26,6 +26,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, @@ -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(); @@ -65,7 +67,7 @@ export function useKeyboardShortcuts({ useEffect(() => { if (!enabled) return; - if (Platform.OS !== "web") return; + if (isNative) return; if (isMobile) return; const isDesktopApp = getIsElectronRuntime(); @@ -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/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 3ddd8315b..415919c4a 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -93,7 +93,9 @@ describe("openProjectDirectly", () => { expect(result).toBe(true); expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true); - expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([ + expect( + Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []), + ).toEqual([ expect.objectContaining({ id: "1", projectId: "1", diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index e3ea6b238..2d5fa3603 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -2,7 +2,11 @@ import { useCallback } from "react"; import { router } from "expo-router"; import type { DaemonClient } from "@server/client/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { + normalizeWorkspaceDescriptor, + type WorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore, 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/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 598dddf56..ee4df2558 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -236,7 +236,6 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor { return normalizeWorkspaceDescriptor(payload); - } export function useSidebarWorkspacesList(options?: { 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 78d4770fd..554a1ea5e 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" }; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 62e1d6da0..28e72240d 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"; @@ -46,6 +46,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) { @@ -242,7 +243,7 @@ function AgentPanelBody({ ); const agentState = useSessionStore( useShallow((state) => { - const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null; + const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null; return { serverId: agent?.serverId ?? null, id: agent?.id ?? null, @@ -690,7 +691,14 @@ function ChatAgentContent({ projectPlacement, } : null, - [agentState.serverId, agentState.id, agentState.status, agentState.cwd, agentState.lastError, projectPlacement], + [ + agentState.serverId, + agentState.id, + agentState.status, + agentState.cwd, + agentState.lastError, + projectPlacement, + ], ); const placeholderAgent: AgentScreenAgent | null = useMemo(() => { @@ -791,7 +799,7 @@ function ChatAgentContent({ if (!isConnected || !hasSession) { return; } - const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; + const shouldSyncOnEntry = needsAuthoritativeSync || isNative; if (!shouldSyncOnEntry) { return; } diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index fe1d94aa3..3931ab2ec 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -18,7 +18,7 @@ function useSetupPanelDescriptor( serverId: context.serverId, workspaceId: target.workspaceId, }); - const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); if (snapshot?.status === "completed") { return { @@ -98,7 +98,7 @@ function SetupPanel() { serverId, workspaceId: target.workspaceId, }); - const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress); // On mount, if no snapshot in the store, request cached status from server @@ -160,13 +160,14 @@ function SetupPanel() { return null; })(); - const statusLabel = snapshot?.status === "running" - ? "Running" - : snapshot?.status === "completed" - ? "Completed" - : snapshot?.status === "failed" - ? "Failed" - : "Waiting for setup output"; + const statusLabel = + snapshot?.status === "running" + ? "Running" + : snapshot?.status === "completed" + ? "Completed" + : snapshot?.status === "failed" + ? "Failed" + : "Waiting for setup output"; return ( {/* Hidden element for status — preserves testID for E2E */} - {statusLabel} + + {statusLabel} + {isWaiting ? ( @@ -241,17 +241,12 @@ function SetupPanel() { {command.command} {command.durationMs != null ? ( - - {formatDuration(command.durationMs)} - + {formatDuration(command.durationMs)} ) : null} {showDetail ? ( diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index b6f9a2bf5..020d46479 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -39,7 +39,9 @@ function useTerminalPanelDescriptor( queryFn: async (): Promise => { if (!client || !workspaceDirectory) { throw new Error( - workspaceAuthority.ok ? "Workspace execution directory not found" : workspaceAuthority.message, + workspaceAuthority.ok + ? "Workspace execution directory not found" + : workspaceAuthority.message, ); } return client.listTerminals(workspaceDirectory); @@ -76,7 +78,9 @@ function TerminalPanel() { return ( - {workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message} + {workspaceAuthority.ok + ? "Workspace execution directory not found." + : workspaceAuthority.message} ); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index f817838f8..c8fb069f4 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,8 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; 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 = { @@ -67,10 +69,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(); @@ -91,16 +89,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 = { @@ -205,22 +201,20 @@ function DraftAgentScreenContent({ const draftIdRef = useRef(generateDraftId()); const draftAgentIdRef = useRef(generateDraftId()); - const draftInput = useAgentInputDraft( - { - draftKey: ({ selectedServerId }) => - buildDraftStoreKey({ - serverId: selectedServerId ?? "", - agentId: draftAgentIdRef.current, - draftId: draftIdRef.current, - }), - composer: { - initialServerId: resolvedServerId ?? null, - initialValues, - isVisible, - onlineServerIds, - }, + const draftInput = useAgentInputDraft({ + draftKey: ({ selectedServerId }) => + buildDraftStoreKey({ + serverId: selectedServerId ?? "", + agentId: draftAgentIdRef.current, + draftId: draftIdRef.current, + }), + composer: { + initialServerId: resolvedServerId ?? null, + initialValues, + isVisible, + onlineServerIds, }, - ); + }); const composerState = draftInput.composerState; if (!composerState) { throw new Error("Draft agent composer state is required"); @@ -636,7 +630,9 @@ function DraftAgentScreenContent({ useCallback( (state) => resolveWorkspaceIdByExecutionDirectory({ - workspaces: selectedServerId ? state.sessions[selectedServerId]?.workspaces?.values() : null, + workspaces: selectedServerId + ? state.sessions[selectedServerId]?.workspaces?.values() + : null, workspaceDirectory: explorerCwd, }), [explorerCwd, selectedServerId], @@ -802,7 +798,7 @@ function DraftAgentScreenContent({ }, onBeforeSubmit: () => { void persistFormPreferences(); - if (Platform.OS === "web") { + if (isWeb) { (document.activeElement as HTMLElement | null)?.blur?.(); } Keyboard.dismiss(); @@ -868,9 +864,7 @@ function DraftAgentScreenContent({ cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), ...(effectiveModelId ? { model: effectiveModelId } : {}), - ...(effectiveThinkingOptionId - ? { thinkingOptionId: effectiveThinkingOptionId } - : {}), + ...(effectiveThinkingOptionId ? { thinkingOptionId: effectiveThinkingOptionId } : {}), }; const effectiveBaseBranch = baseBranch.trim(); @@ -932,9 +926,7 @@ function DraftAgentScreenContent({ }, onCreateSuccess: ({ result }) => { if (!result.workspaceId) { - router.replace( - buildHostAgentDetailRoute(selectedServerId as string, result.id) as any, - ); + router.replace(buildHostAgentDetailRoute(selectedServerId as string, result.id) as any); return; } const route = prepareWorkspaceTab({ diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 2d4034f48..4771dad16 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -3,7 +3,13 @@ import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { CircleDot, ChevronDown, ExternalLink, GitBranch, GitPullRequest } from "lucide-react-native"; +import { + CircleDot, + ChevronDown, + ExternalLink, + GitBranch, + GitPullRequest, +} from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; import { Composer } from "@/components/composer"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; @@ -26,9 +32,7 @@ import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments"; -import { - requireWorkspaceExecutionAuthority, -} from "@/utils/workspace-execution"; +import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ImageAttachment, MessagePayload } from "@/components/message-input"; import type { GitHubSearchItem } from "@server/shared/messages"; @@ -123,14 +127,13 @@ export function NewWorkspaceScreen({ const githubSearchOptions: ComboboxOptionType[] = useMemo(() => { const items = githubSearchResultsQuery.data?.items ?? []; - return items - .map((item) => ({ - id: `${item.kind}:${item.number}`, - label: `#${item.number} ${item.title}`, - // Include search query so the Combobox's client-side filter doesn't - // discard server-searched results that match on body but not title. - description: githubSearchQuery_trimmed, - })); + return items.map((item) => ({ + id: `${item.kind}:${item.number}`, + label: `#${item.number} ${item.title}`, + // Include search query so the Combobox's client-side filter doesn't + // discard server-searched results that match on body but not title. + description: githubSearchQuery_trimmed, + })); }, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]); const handleSelectGithubItem = useCallback( @@ -198,7 +201,9 @@ export function NewWorkspaceScreen({ const initialPrompt = text.trim(); const encodedImages = await encodeImages(images); const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem); - const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; + const workspaceDirectory = requireWorkspaceExecutionAuthority({ + workspace, + }).workspaceDirectory; const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, @@ -234,7 +239,15 @@ export function NewWorkspaceScreen({ setPendingAction(null); } }, - [composerState, ensureWorkspace, selectedGithubItem, serverId, setAgents, toast, withConnectedClient], + [ + composerState, + ensureWorkspace, + selectedGithubItem, + serverId, + setAgents, + toast, + withConnectedClient, + ], ); const workspaceTitle = @@ -337,10 +350,7 @@ export function NewWorkspaceScreen({ active={active} onPress={onPress} leadingSlot={ - + } /> )} @@ -366,7 +376,10 @@ export function NewWorkspaceScreen({ <> {selectedGithubItem.kind === "pr" ? ( - + ) : ( )} @@ -400,7 +413,11 @@ export function NewWorkspaceScreen({ { const item = (githubSearchResultsQuery.data?.items ?? []).find( @@ -452,7 +467,9 @@ export function NewWorkspaceScreen({ {({ hovered }) => ( )} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index e09292295..baa7a3608 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"; @@ -68,10 +68,11 @@ 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"; // --------------------------------------------------------------------------- // Section definitions @@ -522,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; @@ -557,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); @@ -1672,22 +1674,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 30146504a..78a69f117 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", @@ -145,7 +146,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 306d02bd0..b22302403 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, @@ -110,6 +110,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; @@ -171,17 +172,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 ( @@ -196,7 +196,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 30ed45c23..bd4ddb440 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, 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 invariant from "tiny-invariant"; import { Composer } from "@/components/composer"; @@ -17,6 +17,7 @@ import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import type { AgentCapabilityFlags } 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 = { @@ -63,18 +64,16 @@ export function WorkspaceDraftAgentTab({ }), [draftId, serverId, tabId], ); - const draftInput = useAgentInputDraft( - { - draftKey: draftStoreKey, - composer: { - initialServerId: serverId, - initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, - isVisible: true, - onlineServerIds: isConnected ? [serverId] : [], - lockedWorkingDir: workspaceDirectory ?? undefined, - }, + const draftInput = useAgentInputDraft({ + draftKey: draftStoreKey, + composer: { + initialServerId: serverId, + initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, + isVisible: true, + onlineServerIds: isConnected ? [serverId] : [], + lockedWorkingDir: workspaceDirectory ?? undefined, }, - ); + }); const composerState = draftInput.composerState; if (!composerState) { throw new Error("Workspace draft composer state is required"); @@ -112,7 +111,7 @@ export function WorkspaceDraftAgentTab({ }, onBeforeSubmit: () => { void composerState.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 57dfab969..98b12eda6 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 { DiffStat } from "@/components/diff-stat"; @@ -125,6 +117,7 @@ import { import { findAdjacentPane } from "@/utils/split-navigation"; import { isAbsolutePath } from "@/utils/path"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; +import { isWeb, isNative } from "@/constants/platform"; const TERMINALS_QUERY_STALE_TIME = 5_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; @@ -263,7 +256,7 @@ function WorkspaceDocumentTitleEffect({ titleState: "ready" | "loading"; }) { useEffect(() => { - if (Platform.OS !== "web" || typeof document === "undefined") { + if (isNative || typeof document === "undefined") { return; } const resolvedLabel = label.trim(); @@ -659,9 +652,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) type ListTerminalsPayload = ListTerminalsResponse["payload"]; const terminalsQuery = useQuery({ queryKey: terminalsQueryKey, - enabled: - Boolean(client && isConnected) && - Boolean(workspaceDirectory), + enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory), queryFn: async () => { if (!client || !workspaceDirectory) { throw new Error("Host is not connected"); @@ -763,9 +754,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) normalizedServerId, workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`, ), - enabled: - Boolean(client && isConnected) && - Boolean(workspaceDirectory), + enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory), queryFn: async () => { if (!client || !workspaceDirectory) { throw new Error("Host is not connected"); @@ -840,7 +829,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }); useEffect(() => { - if (Platform.OS === "web" || !isExplorerOpen) { + if (isWeb || !isExplorerOpen) { return; } @@ -868,7 +857,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); const workspaceSetupSnapshot = useWorkspaceSetupStore((state) => - persistenceKey ? state.snapshots[persistenceKey] ?? null : null, + persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null, ); const uiTabs = useMemo( () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), @@ -880,7 +869,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); - const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent); + const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore( + (state) => state.convertDraftToAgent, + ); const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); @@ -1079,8 +1070,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const hasSetupTab = useMemo( () => uiTabs.some( - (tab) => - tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId, + (tab) => tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId, ), [normalizedWorkspaceId, uiTabs], ); @@ -1350,7 +1340,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) if (isRunning) { const confirmed = await confirmDialog({ title: "Archive running agent?", - message: "This agent is still running. Archiving it will stop the agent and close the tab.", + message: + "This agent is still running. Archiving it will stop the agent and close the tab.", confirmLabel: "Archive", cancelLabel: "Cancel", destructive: true, @@ -1369,7 +1360,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], @@ -1803,7 +1795,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"; @@ -2038,7 +2030,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return ( - {Platform.OS === "web" && activeTabDescriptor ? ( + {isWeb && activeTabDescriptor ? ( - {(!isFocusModeEnabled || isMobile) && ( - workspaceDirectory ? ( + {(!isFocusModeEnabled || isMobile) && + (workspaceDirectory ? ( - ) : null - )} + ) : null)} ); diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index 852378495..54dbcfd36 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -1,7 +1,14 @@ import { type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { useMutation } from "@tanstack/react-query"; -import { ChevronDown, ExternalLink, Globe, LoaderCircle, Play, SquareTerminal } from "lucide-react-native"; +import { + ChevronDown, + ExternalLink, + Globe, + LoaderCircle, + Play, + SquareTerminal, +} from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; @@ -58,10 +65,9 @@ export function WorkspaceScriptsButton({ return result; }, onError: (error, scriptName) => { - toast.show( - error instanceof Error ? error.message : `Failed to start ${scriptName}`, - { variant: "error" }, - ); + toast.show(error instanceof Error ? error.message : `Failed to start ${scriptName}`, { + variant: "error", + }); }, }); @@ -88,9 +94,7 @@ export function WorkspaceScriptsButton({ diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 0acf67107..3c9bec08c 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; -import { Platform } from "react-native"; import { File as FSFile, Paths } from "expo-file-system"; import * as LegacyFileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; import type { HostProfile } from "@/types/host-connection"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; import { openExternalUrl } from "@/utils/open-external-url"; +import { isWeb } from "@/constants/platform"; interface DownloadProgress { percent: number; @@ -97,10 +97,10 @@ export const useDownloadStore = create()((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/stores/provider-recency-store.test.ts b/packages/app/src/stores/provider-recency-store.test.ts index f9476f56b..3bb8d0b97 100644 --- a/packages/app/src/stores/provider-recency-store.test.ts +++ b/packages/app/src/stores/provider-recency-store.test.ts @@ -46,10 +46,7 @@ describe("provider-recency-store", () => { useProviderRecencyStore.getState().recordUsage("opencode"); useProviderRecencyStore.getState().recordUsage("codex"); - expect(useProviderRecencyStore.getState().recentProviderIds).toEqual([ - "codex", - "opencode", - ]); + expect(useProviderRecencyStore.getState().recentProviderIds).toEqual(["codex", "opencode"]); }); it("filters invalid and duplicate providers during migration", () => { diff --git a/packages/app/src/stores/provider-recency-store.ts b/packages/app/src/stores/provider-recency-store.ts index 7ffd8a82f..5dc48ac58 100644 --- a/packages/app/src/stores/provider-recency-store.ts +++ b/packages/app/src/stores/provider-recency-store.ts @@ -67,7 +67,9 @@ export function sortProvidersByRecency( .map((entry) => entry.provider); } -function migratePersistedState(state: unknown): Pick { +function migratePersistedState( + state: unknown, +): Pick { const record = state as { recentProviderIds?: string[] } | null | undefined; return { recentProviderIds: sanitizeRecentProviderIds(record?.recentProviderIds), diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 6bb7b8673..a2a21f100 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -110,7 +110,6 @@ interface OpenTabInLayoutResult { tabId: string; } - interface RetargetTabInLayoutInput { layout: WorkspaceLayout; tabId: string; @@ -802,7 +801,8 @@ function replaceTabInTree( } : tab, ), - focusedTabId: node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId, + focusedTabId: + node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId, }), }; }); @@ -1388,7 +1388,9 @@ function isEntityTarget( return target.kind === "agent" || target.kind === "terminal"; } -function isAgentTab(tab: WorkspaceTab): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } { +function isAgentTab( + tab: WorkspaceTab, +): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } { return tab.target.kind === "agent"; } @@ -1398,7 +1400,10 @@ function isTerminalTab( return tab.target.kind === "terminal"; } -function openEntityTabWithoutFocusing(layout: WorkspaceLayout, target: WorkspaceTabTarget): WorkspaceLayout { +function openEntityTabWithoutFocusing( + layout: WorkspaceLayout, + target: WorkspaceTabTarget, +): WorkspaceLayout { const internalLayout = asInternalLayout(layout); const focusedPane = findPaneById(internalLayout.root, internalLayout.focusedPaneId) ?? @@ -1445,7 +1450,9 @@ export function reconcileWorkspaceTabs( } const initialTabs = collectAllTabs(nextLayout.root); - const representedAgentIds = new Set(initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId)); + const representedAgentIds = new Set( + initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId), + ); const entityGroups = new Map< string, @@ -1482,10 +1489,7 @@ export function reconcileWorkspaceTabs( if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) { reconciledFocusedTabId = canonicalTabId; } - if ( - keeper.tabId !== canonicalTabId || - !workspaceTabTargetsEqual(keeper.target, group.target) - ) { + if (keeper.tabId !== canonicalTabId || !workspaceTabTargetsEqual(keeper.target, group.target)) { nextLayout = { root: replaceTabInTree(asInternalLayout(nextLayout).root, { tabId: keeper.tabId, @@ -1515,7 +1519,11 @@ export function reconcileWorkspaceTabs( tabId: tab.tabId, }) ?? nextLayout; } - if (isTerminalTab(tab) && snapshot.terminalsHydrated && !standaloneTerminalIds.has(tab.target.terminalId)) { + if ( + isTerminalTab(tab) && + snapshot.terminalsHydrated && + !standaloneTerminalIds.has(tab.target.terminalId) + ) { nextLayout = closeTabInLayout({ layout: nextLayout, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 35b407c16..07ecbdc45 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -393,8 +393,9 @@ describe("workspace-layout-store actions", () => { }); it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("55555555-5555-5555-5555-555555555555"); + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce( + "55555555-5555-5555-5555-555555555555", + ); const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); @@ -432,12 +433,18 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); - const firstDraftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-agent-1" }); + const firstDraftTabId = store.openTab(workspaceKey, { + kind: "draft", + draftId: "draft-agent-1", + }); const firstAgentTabId = store.retargetTab(workspaceKey, firstDraftTabId!, { kind: "agent", agentId: "agent-1", }); - const secondDraftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-agent-2" }); + const secondDraftTabId = store.openTab(workspaceKey, { + kind: "draft", + draftId: "draft-agent-2", + }); const nextTabId = store.retargetTab(workspaceKey, secondDraftTabId!, { kind: "agent", diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index b8e5cf7fd..6ca4763d2 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -33,10 +33,7 @@ interface WorkspaceSetupStoreState { clearServer: (serverId: string) => void; } -function buildWorkspaceSetupKey(input: { - serverId: string; - workspaceId: string; -}): string | null { +function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string }): string | null { return buildWorkspaceTabPersistenceKey(input); } 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/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 82f115837..ffad6176b 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -80,9 +80,7 @@ describe("workspace route parsing", () => { it("parses workspace open intent from pathname query", () => { expect( - parseHostWorkspaceOpenIntentFromPathname( - "/h/local/workspace/164?open=agent%3Aagent-1", - ), + parseHostWorkspaceOpenIntentFromPathname("/h/local/workspace/164?open=agent%3Aagent-1"), ).toEqual({ kind: "agent", agentId: "agent-1", diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 263dd043d..b8326da9b 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -321,7 +321,11 @@ export function buildHostAgentDetailRoute(serverId: string, agentId: string, wor if (!normalizedAgentId) { return "/" as const; } - return buildHostWorkspaceOpenRoute(serverId, normalizedWorkspaceId, `agent:${normalizedAgentId}`); + return buildHostWorkspaceOpenRoute( + serverId, + normalizedWorkspaceId, + `agent:${normalizedAgentId}`, + ); } const normalizedServerId = trimNonEmpty(serverId); const normalizedAgentId = trimNonEmpty(agentId); @@ -359,7 +363,7 @@ export function buildHostNewWorkspaceRoute( serverId: string, sourceDirectory: string, options?: { displayName?: string }, -){ +) { const base = buildHostRootRoute(serverId); if (base === "/") { return "/" as const; 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/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/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}`); diff --git a/packages/app/src/utils/workspace-script-links.ts b/packages/app/src/utils/workspace-script-links.ts index a903df173..5d096c876 100644 --- a/packages/app/src/utils/workspace-script-links.ts +++ b/packages/app/src/utils/workspace-script-links.ts @@ -9,7 +9,9 @@ export type ResolvedWorkspaceScriptLink = { function isLoopbackHost(host: string): boolean { const normalizedHost = host.trim().toLowerCase(); - return normalizedHost === "localhost" || normalizedHost === "127.0.0.1" || normalizedHost === "::1"; + return ( + normalizedHost === "localhost" || normalizedHost === "127.0.0.1" || normalizedHost === "::1" + ); } function buildDirectServiceUrl(endpoint: string, port: number): string | null { 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/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/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 61b0803f9..ea3056082 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 ?? "-", modes: def.modes.length > 0 ? 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/desktop/package.json b/packages/desktop/package.json index 487e4fb99..dd4772dbe 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", @@ -13,8 +13,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/desktop/src/main.ts b/packages/desktop/src/main.ts index c2b460cc9..c57654955 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -70,7 +70,6 @@ if (!app.isPackaged) { } } - // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). // Must run before app.whenReady(). 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 ee08617f9..800a4740e 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 5b64d658f..a8528c34d 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": { @@ -61,8 +61,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", @@ -81,6 +81,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/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/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index a1b320e10..81019f555 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -136,7 +136,7 @@ export type DaemonEvent = agentId: string; payload: Extract["payload"]; } - | { + | { type: "workspace_update"; workspaceId: string; payload: Extract["payload"]; @@ -1335,7 +1335,9 @@ export class DaemonClient { workspaceId: string, scriptName: string, requestId?: string, - ): Promise["payload"]> { + ): Promise< + Extract["payload"] + > { return this.sendCorrelatedSessionRequest({ requestId, message: { 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-loading-service.ts b/packages/server/src/server/agent-loading-service.ts index 313aef944..c24abd85f 100644 --- a/packages/server/src/server/agent-loading-service.ts +++ b/packages/server/src/server/agent-loading-service.ts @@ -16,10 +16,7 @@ const pendingAgentBootstrapLoads = new Map>(); export type AgentLoadingServiceOptions = { agentManager: Pick< AgentManager, - | "createAgent" - | "getAgent" - | "reloadAgentSession" - | "resumeAgentFromPersistence" + "createAgent" | "getAgent" | "reloadAgentSession" | "resumeAgentFromPersistence" >; agentStorage: Pick; logger: pino.Logger; @@ -114,7 +111,13 @@ export class AgentLoadingService { "Agent resumed from persistence", ); } else { - snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, { + const sessionConfig = buildSessionConfig(record); + if (!sessionConfig) { + throw new Error( + `Agent ${options.agentId} has an invalid provider '${record.provider}' and cannot be loaded`, + ); + } + snapshot = await this.agentManager.createAgent(sessionConfig, options.agentId, { labels: record.labels, }); this.logger.info( diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 119c1d2e0..81421d1ad 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, @@ -838,7 +838,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 361119607..0efa776f3 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -378,6 +378,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 f8cc7c7a4..4071039ba 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -45,7 +45,7 @@ import type { AgentTimelineRow, AgentTimelineStore, } from "./agent-timeline-store-types.js"; -import { AGENT_PROVIDER_IDS, getAgentProviderDefinition } from "./provider-manifest.js"; +import { getAgentProviderDefinition } from "./provider-manifest.js"; export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus }; export type { @@ -334,6 +334,10 @@ export class AgentManager { this.clients.set(provider, client); } + getRegisteredProviderIds(): AgentProvider[] { + return Array.from(this.clients.keys()); + } + setAgentAttentionCallback(callback: AgentAttentionCallback): void { this.onAgentAttention = callback; } @@ -477,8 +481,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 { @@ -787,6 +790,7 @@ export class AgentManager { async setAgentMode(agentId: string, modeId: string): Promise { const agent = this.requireSessionAgent(agentId); await agent.session.setMode(modeId); + agent.config.modeId = modeId; agent.currentModeId = modeId; // Update runtimeInfo to reflect the new mode if (agent.runtimeInfo) { @@ -1847,10 +1851,14 @@ export class AgentManager { const durableTimelineSeed = shouldSeedFromDurable ? await this.loadCommittedTimelineSeed(resolvedAgentId, now) : null; - const durableTimelineHasRows = durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1; + const durableTimelineHasRows = + durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1; const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed; if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) { - this.timelineStore.initialize(resolvedAgentId, timelineSeed ?? { timestamp: now.toISOString() }); + this.timelineStore.initialize( + resolvedAgentId, + timelineSeed ?? { timestamp: now.toISOString() }, + ); } if (options?.timelineRows?.length) { this.enqueueDurableTimelineBulkInsert(resolvedAgentId, options.timelineRows); @@ -2157,9 +2165,7 @@ export class AgentManager { } } - private async hydrateTimelineFromLegacyProviderHistory( - agent: ActiveManagedAgent, - ): Promise { + private async hydrateTimelineFromLegacyProviderHistory(agent: ActiveManagedAgent): Promise { if (agent.historyPrimed) { return; } @@ -2726,5 +2732,4 @@ export class AgentManager { } return agent; } - } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index cf8b4a093..f2d31e9ce 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -44,6 +44,8 @@ export type AgentMode = { id: string; label: string; description?: string; + icon?: string; + colorTier?: string; }; export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; @@ -74,6 +76,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/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 67cb533e3..20a6a8643 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -185,8 +185,7 @@ export class AgentStorage implements AgentSnapshotStore { workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean }, options?: { title?: string | null; internal?: boolean }, ): Promise { - const nextOptions = - typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; + const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; await this.load(); await this.waitForPendingWrite(agent.id); const existing = (await this.get(agent.id)) ?? null; @@ -197,7 +196,9 @@ export class AgentStorage implements AgentSnapshotStore { const record = toStoredAgentRecord(agent, { title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null), createdAt: existing?.createdAt, - internal: hasInternalOverride ? nextOptions?.internal : (agent.internal ?? existing?.internal), + internal: hasInternalOverride + ? nextOptions?.internal + : (agent.internal ?? existing?.internal), }); // Preserve soft-delete/archive status across snapshot flushes. diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 1a966de8c..4ac8531ed 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -43,8 +43,7 @@ export class InMemoryAgentTimelineStore { const rows = options?.rows?.length ? options.rows.map(cloneRow) : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); - const nextSeq = - options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1); + const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1); this.states.set(agentId, { rows, nextSeq, @@ -94,7 +93,9 @@ export class InMemoryAgentTimelineStore { if (direction === "tail") { const selected = - selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit); + selectAll || limit >= state.rows.length + ? state.rows + : state.rows.slice(state.rows.length - limit); return { direction, window, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 92654052f..4bc477635 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 { AgentSnapshotStore } from "./agent-snapshot-store.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 da3c1e432..fdcbca344 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, @@ -1366,7 +1366,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) => ({ @@ -1473,7 +1473,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom content: [], structuredContent: ensureValidJson({ branchName, - worktreePath: worktree.worktreePath, + worktreePath: worktree.worktree.worktreePath, }), }; }, diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index a018b80ae..b818bd190 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 type { AgentSnapshotStore } from "./agent-snapshot-store.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({ @@ -257,7 +253,7 @@ export function sanitizePermissionRequest( } export async function resolveAgentTitle( - agentStorage: AgentStorage, + agentStorage: AgentSnapshotStore, agentId: string, logger: Logger, ): Promise { @@ -271,7 +267,7 @@ export async function resolveAgentTitle( } export async function serializeSnapshotWithMetadata( - agentStorage: AgentStorage, + agentStorage: AgentSnapshotStore, snapshot: ManagedAgent, logger: Logger, ) { 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 c6aebc772..78e0462d0 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -1,6 +1,8 @@ import { z } from "zod"; -import { findExecutableSync, isCommandAvailable } from "../../utils/executable.js"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { isCommandAvailable } from "../../utils/executable.js"; import type { AgentProvider } from "./agent-sdk-types.js"; import { AgentProviderSchema } from "./provider-manifest.js"; @@ -37,6 +39,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 +78,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 >; @@ -87,6 +123,48 @@ export function resolveShellEnv(): Record { return cachedShellEnv; } +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. @@ -113,7 +191,25 @@ export function applyProviderEnv( } export function findExecutable(name: string): string | null { - return findExecutableSync(name); + const trimmed = name.trim(); + if (!trimmed) return null; + if (trimmed.includes("/") || trimmed.includes("\\")) { + try { + const { existsSync } = require("node:fs"); + return existsSync(trimmed) ? trimmed : null; + } catch { + return null; + } + } + try { + const cmd = process.platform === "win32" ? "where.exe" : "which"; + const result = execFileSync(cmd, [trimmed], { encoding: "utf8" }).trim(); + const lines = result.split(/\r?\n/).filter((l: string) => l.trim()); + const candidate = lines.at(-1)?.trim() ?? null; + return candidate && path.isAbsolute(candidate) ? candidate : null; + } catch { + return null; + } } export async function isProviderCommandAvailable( diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 4f18542ec..4eed75478 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 @@ -189,24 +189,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..e19a087e7 --- /dev/null +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -0,0 +1,624 @@ +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; + }>, + }, + isCommandAvailable: vi.fn(async (_command: string) => false), + runtimeModels: new Map(), + reset() { + for (const key of Object.keys(this.constructorArgs) as Array< + keyof typeof this.constructorArgs + >) { + 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 = { + 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 { + 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; + } + }, +})); + +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 { + 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; + } + }, +})); + +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 { + 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; + } + }, +})); + +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("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: { + 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 726c56514..baf42c3a3 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -1,13 +1,25 @@ +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 { AiderAgentClient } from "./providers/aider-agent.js"; import { AmpAgentClient } from "./providers/amp-agent.js"; @@ -15,11 +27,12 @@ import { ClaudeAgentClient } from "./providers/claude-agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { GeminiAgentClient } from "./providers/gemini-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"; @@ -34,33 +47,45 @@ 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), - gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings?.gemini), - amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings?.amp), - aider: (_logger, runtimeSettings) => new AiderAgentClient(runtimeSettings?.aider), + codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings), + gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings), + amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings), + aider: (_logger, runtimeSettings) => new AiderAgentClient(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 { @@ -71,31 +96,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; @@ -105,7 +465,10 @@ export function createAllClients( ): Record { const registry = buildProviderRegistry(logger, options); return Object.fromEntries( - Object.entries(registry).map(([provider, definition]) => [provider, definition.createClient(logger)]), + Object.entries(registry).map(([provider, definition]) => [ + provider, + definition.createClient(logger), + ]), ) as Record; } 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..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"; @@ -19,6 +20,9 @@ type Deferred = { type MockProviderOptions = { provider: AgentProvider; + label?: string; + description?: string; + defaultModeId?: string | null; isAvailable?: () => Promise; fetchModels?: (cwd?: string) => Promise; fetchModes?: (cwd?: string) => Promise; @@ -41,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(); @@ -56,12 +64,23 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot(projectCwd); - 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); @@ -88,25 +107,31 @@ 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", 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)); @@ -122,11 +147,17 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(manager.getSnapshot("/tmp/project")).toEqual([ - { provider: "codex", status: "unavailable" }, + expect(manager.getSnapshot(projectCwd)).toEqual([ + { + provider: "codex", + status: "unavailable", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, ]); }); @@ -147,14 +178,17 @@ 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", error: "model lookup failed", + label: "codex", + description: "codex test provider", + defaultModeId: null, }, ]); }); @@ -186,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")]); @@ -195,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"); @@ -227,19 +261,27 @@ 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([{ provider: "codex", status: "loading" }]); + manager.refresh(projectCwd); + expect(manager.getSnapshot(projectCwd)).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( + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( "gpt-5.2", ); }); @@ -263,13 +305,21 @@ 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([{ provider: "codex", status: "loading" }]); + expect(manager.getSnapshot(projectCwd)).toEqual([ + { + provider: "codex", + status: "loading", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, + ]); - 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); @@ -278,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")], @@ -306,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); @@ -320,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(); @@ -339,25 +389,104 @@ 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(); + }); + + 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(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "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(projectCwd); + + 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(projectCwd)).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(projectCwd), "zai")).toMatchObject({ + provider: "zai", + status: "ready", + label: "ZAI", + description: "Custom Claude profile", + defaultModeId: "plan", + }); + }); manager.destroy(); }); @@ -403,9 +532,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/__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 dc111c902..4ea40c370 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"; @@ -74,12 +73,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 { findExecutable, isCommandAvailable } 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"; @@ -244,6 +242,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; @@ -1131,7 +1133,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 await isCommandAvailable(command.argv[0]); } return true; } @@ -1187,14 +1189,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; } @@ -1204,11 +1202,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; @@ -3273,8 +3267,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/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/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[] = [ 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 b2640dd65..54138334e 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"; @@ -48,7 +48,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { 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"; @@ -4213,7 +4213,7 @@ export class CodexAppServerAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return existsSync(command.argv[0]); + return await isCommandAvailable(command.argv[0]); } 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/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/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 304ba7e68..3299794a4 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 { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { @@ -922,7 +921,7 @@ export class OpenCodeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return existsSync(command.argv[0]); + return await isCommandAvailable(command.argv[0]); } 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/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 26c6dacc4..6097afc22 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -596,11 +596,23 @@ function writeLegacyProjectWorkspaceJson( ): void { const projectsDir = path.join(paseoHome, "projects"); mkdirSync(projectsDir, { recursive: true }); - writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify(input.projects, null, 2), "utf8"); - writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify(input.workspaces, null, 2), "utf8"); + writeFileSync( + path.join(projectsDir, "projects.json"), + JSON.stringify(input.projects, null, 2), + "utf8", + ); + writeFileSync( + path.join(projectsDir, "workspaces.json"), + JSON.stringify(input.workspaces, null, 2), + "utf8", + ); } -function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload: Record): void { +function writeLegacyAgentJson( + paseoHome: string, + relativePath: string, + payload: Record, +): void { const absolutePath = path.join(paseoHome, relativePath); mkdirSync(path.dirname(absolutePath), { recursive: true }); writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8"); @@ -608,7 +620,10 @@ function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload: function initializeGitRepo(directory: string): void { execFileSync("git", ["init", "-b", "main"], { cwd: directory, stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], { cwd: directory, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], { + cwd: directory, + stdio: "pipe", + }); execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: directory, stdio: "pipe" }); writeFileSync(path.join(directory, "README.md"), "bootstrap fixture\n", "utf8"); execFileSync("git", ["add", "README.md"], { cwd: directory, stdio: "pipe" }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2ee0f7e5f..74cd7c967 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -120,7 +120,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"; import { ScriptRouteStore, @@ -130,11 +133,6 @@ import { import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import { - createVoiceMcpSocketBridgeManager, - type VoiceMcpSocketBridgeManager, -} from "./voice-mcp-bridge.js"; -import { resolveVoiceMcpBridgeFromRuntime } from "./voice-mcp-bridge-command.js"; type AgentMcpTransportMap = Map; @@ -196,6 +194,7 @@ export type PaseoDaemonConfig = { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + providerOverrides?: Record; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; }; @@ -283,9 +282,7 @@ export async function createPaseoDaemon( // and forwards them to the corresponding local script port. Placed after // the host allowlist (*.localhost is already allowed) but before CORS and // the rest of the routes so proxied requests skip unnecessary middleware. - app.use( - createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }), - ); + app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger })); // CORS - allow same-origin + configured origins const allowedOrigins = new Set([ @@ -412,6 +409,7 @@ export async function createPaseoDaemon( clients: { ...createAllClients(logger, { runtimeSettings: config.agentProviderSettings, + providerOverrides: config.providerOverrides, }), ...config.agentClients, }, @@ -422,6 +420,7 @@ export async function createPaseoDaemon( }); const providerRegistry = buildProviderRegistry(logger, { runtimeSettings: config.agentProviderSettings, + providerOverrides: config.providerOverrides, }); const projectRegistry = new DbProjectRegistry(database.db); @@ -486,32 +485,6 @@ export async function createPaseoDaemon( "Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)", ); logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); - let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null; - - // Create in-memory transport for Session's Agent MCP client (voice assistant tools) - const createInMemoryAgentMcpTransport = async (): Promise => { - const agentMcpServer = await createAgentMcpServer({ - agentManager, - agentStorage, - terminalManager, - getDaemonTcpPort: () => - boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, - paseoHome: config.paseoHome, - enableVoiceTools: false, - resolveSpeakHandler: (callerAgentId) => - wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null, - resolveCallerContext: (callerAgentId) => - wsServer?.resolveVoiceCallerContext(callerAgentId) ?? null, - logger, - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await agentMcpServer.connect(serverTransport); - - return clientTransport; - }; - const mcpEnabled = config.mcpEnabled ?? true; let agentMcpBaseUrl: string | null = null; if (mcpEnabled) { @@ -637,27 +610,6 @@ export async function createPaseoDaemon( logger.info("Agent MCP HTTP endpoint disabled"); } - const voiceMcpSocketDir = path.join(config.paseoHome, "runtime", "voice-mcp"); - const voiceMcpBridgeCommand = resolveVoiceMcpBridgeCommand(logger); - voiceMcpBridgeManager = createVoiceMcpSocketBridgeManager({ - runtimeDir: voiceMcpSocketDir, - logger, - createAgentMcpServerForCaller: async (callerAgentId) => { - return createAgentMcpServer({ - agentManager, - agentStorage, - terminalManager, - getDaemonTcpPort: () => - boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, - paseoHome: config.paseoHome, - callerAgentId, - voiceOnly: true, - resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null, - resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null, - logger, - }); - }, - }); const speechService = createSpeechService({ logger, openaiConfig: config.openai, @@ -718,27 +670,11 @@ export async function createPaseoDaemon( { allowedOrigins, allowedHosts: config.allowedHosts }, speechService, terminalManager, - { - voiceAgentMcpStdio: voiceMcpBridgeCommand - ? { - command: voiceMcpBridgeCommand.command, - baseArgs: [...voiceMcpBridgeCommand.baseArgs], - env: { - ELECTRON_RUN_AS_NODE: "1", - PASEO_HOME: config.paseoHome, - }, - } - : null, - ensureVoiceMcpSocketForAgent: (agentId) => - voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ?? - Promise.reject(new Error("Voice MCP bridge manager is not initialized")), - removeVoiceMcpSocketForAgent: (agentId) => - voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(), - }, { finalTimeoutMs: config.dictationFinalTimeoutMs, }, config.agentProviderSettings, + config.providerOverrides, daemonVersion, (intent) => { try { @@ -825,6 +761,7 @@ export async function createPaseoDaemon( await agentManager.flush().catch(() => undefined); await shutdownProviders(logger, { runtimeSettings: config.agentProviderSettings, + providerOverrides: config.providerOverrides, }); terminalManager.killAll(); speechService.stop(); @@ -833,9 +770,6 @@ export async function createPaseoDaemon( if (wsServer) { await wsServer.close(); } - if (voiceMcpBridgeManager) { - await voiceMcpBridgeManager.stop().catch(() => undefined); - } await database?.close().catch(() => undefined); await new Promise((resolve) => { httpServer.close(() => resolve()); 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/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/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 6ab400712..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,96 +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, - }); - 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, - }); - 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 e4db483ea..bf624bb83 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,13 +1,12 @@ -import { 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"; import pino from "pino"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import WebSocket from "ws"; - -import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { DaemonClient } from "../test-utils/daemon-client.js"; import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { @@ -353,51 +352,132 @@ 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, }); + 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"); @@ -419,380 +499,210 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 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, - }); - 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, - }); - - // 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, - }); - 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); @@ -803,7 +713,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); @@ -815,14 +725,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); @@ -831,253 +764,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, - }); - - // 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, - }); - 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, - }); - 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 1aeed1d4f..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,53 +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, - }); + 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/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index 26cbf55e2..5fe1683d7 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -3,7 +3,11 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { createDaemonTestContext, type DaemonTestContext, DaemonClient } from "../test-utils/index.js"; +import { + createDaemonTestContext, + type DaemonTestContext, + DaemonClient, +} from "../test-utils/index.js"; import { createMessageCollector } from "../test-utils/message-collector.js"; import type { SessionOutboundMessage } from "../messages.js"; 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 24aebe765..7829efb65 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"; @@ -455,92 +455,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/db/db-agent-snapshot-store.test.ts b/packages/server/src/server/db/db-agent-snapshot-store.test.ts index 4d31866b7..52771d392 100644 --- a/packages/server/src/server/db/db-agent-snapshot-store.test.ts +++ b/packages/server/src/server/db/db-agent-snapshot-store.test.ts @@ -227,7 +227,10 @@ describe("DbAgentSnapshotStore", () => { test("upsert is idempotent for the same agent ID", async () => { const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); - await store.upsert(createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }), workspaceId); + await store.upsert( + createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }), + workspaceId, + ); await store.upsert( createStoredAgentRecord({ id: "agent-idempotent", @@ -254,23 +257,29 @@ async function seedWorkspace( database: PaseoDatabaseHandle, options: { directory: string }, ): Promise { - const [project] = await database.db.insert(projects).values({ - directory: options.directory, - kind: "git", - displayName: "project-1", - gitRemote: null, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }).returning(); - const [workspace] = await database.db.insert(workspaces).values({ - projectId: project.id, - directory: options.directory, - kind: "checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }).returning(); + const [project] = await database.db + .insert(projects) + .values({ + directory: options.directory, + kind: "git", + displayName: "project-1", + gitRemote: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }) + .returning(); + const [workspace] = await database.db + .insert(workspaces) + .values({ + projectId: project.id, + directory: options.directory, + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }) + .returning(); return workspace.id; } diff --git a/packages/server/src/server/db/db-agent-snapshot-store.ts b/packages/server/src/server/db/db-agent-snapshot-store.ts index a89f7ec11..6552d85c1 100644 --- a/packages/server/src/server/db/db-agent-snapshot-store.ts +++ b/packages/server/src/server/db/db-agent-snapshot-store.ts @@ -116,11 +116,14 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore { async upsert(record: StoredAgentRecord, workspaceId: number): Promise; async upsert(record: StoredAgentRecord, workspaceId?: number): Promise { const nextWorkspaceId = - workspaceId ?? (await this.db - .select({ workspaceId: agentSnapshots.workspaceId }) - .from(agentSnapshots) - .where(eq(agentSnapshots.agentId, record.id)) - .limit(1))[0]?.workspaceId; + workspaceId ?? + ( + await this.db + .select({ workspaceId: agentSnapshots.workspaceId }) + .from(agentSnapshots) + .where(eq(agentSnapshots.agentId, record.id)) + .limit(1) + )[0]?.workspaceId; if (nextWorkspaceId === undefined) { throw new Error(`Workspace ID required for agent ${record.id}`); } @@ -159,13 +162,14 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore { const nextWorkspaceId = typeof workspaceIdOrOptions === "number" ? workspaceIdOrOptions - : (await this.db - .select({ workspaceId: agentSnapshots.workspaceId }) - .from(agentSnapshots) - .where(eq(agentSnapshots.agentId, agent.id)) - .limit(1))[0]?.workspaceId; - const nextOptions = - typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; + : ( + await this.db + .select({ workspaceId: agentSnapshots.workspaceId }) + .from(agentSnapshots) + .where(eq(agentSnapshots.agentId, agent.id)) + .limit(1) + )[0]?.workspaceId; + const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; const existing = await this.get(agent.id); const hasTitleOverride = nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title"); diff --git a/packages/server/src/server/db/db-agent-timeline-store.test.ts b/packages/server/src/server/db/db-agent-timeline-store.test.ts index 2bba18087..65f951672 100644 --- a/packages/server/src/server/db/db-agent-timeline-store.test.ts +++ b/packages/server/src/server/db/db-agent-timeline-store.test.ts @@ -104,7 +104,10 @@ describe("DbAgentTimelineStore", () => { }); test("fetchCommitted tail returns the last N rows", async () => { - await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq))); + await store.bulkInsert( + "agent-1", + [1, 2, 3, 4, 5].map((seq) => createRow(seq)), + ); await expect( store.fetchCommitted("agent-1", { @@ -125,7 +128,10 @@ describe("DbAgentTimelineStore", () => { }); test("fetchCommitted after-cursor returns rows after a given seq", async () => { - await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq))); + await store.bulkInsert( + "agent-1", + [1, 2, 3, 4, 5].map((seq) => createRow(seq)), + ); await expect( store.fetchCommitted("agent-1", { @@ -147,7 +153,10 @@ describe("DbAgentTimelineStore", () => { }); test("fetchCommitted before-cursor returns rows before a given seq", async () => { - await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq))); + await store.bulkInsert( + "agent-1", + [1, 2, 3, 4, 5].map((seq) => createRow(seq)), + ); await expect( store.fetchCommitted("agent-1", { diff --git a/packages/server/src/server/db/db-agent-timeline-store.ts b/packages/server/src/server/db/db-agent-timeline-store.ts index 1cda2de34..25e6a96f8 100644 --- a/packages/server/src/server/db/db-agent-timeline-store.ts +++ b/packages/server/src/server/db/db-agent-timeline-store.ts @@ -123,12 +123,16 @@ export class DbAgentTimelineStore implements AgentTimelineStore { ? await this.db .select() .from(agentTimelineRows) - .where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq))) + .where( + and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)), + ) .orderBy(asc(agentTimelineRows.seq)) : await this.db .select() .from(agentTimelineRows) - .where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq))) + .where( + and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)), + ) .orderBy(asc(agentTimelineRows.seq)) .limit(limit) ).map(toTimelineRow); @@ -159,13 +163,17 @@ export class DbAgentTimelineStore implements AgentTimelineStore { ? await this.db .select() .from(agentTimelineRows) - .where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq))) + .where( + and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)), + ) .orderBy(asc(agentTimelineRows.seq)) : ( await this.db .select() .from(agentTimelineRows) - .where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq))) + .where( + and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)), + ) .orderBy(desc(agentTimelineRows.seq)) .limit(limit) ).reverse() diff --git a/packages/server/src/server/db/db-workspace-registry.test.ts b/packages/server/src/server/db/db-workspace-registry.test.ts index c90c9d71d..499c99ba2 100644 --- a/packages/server/src/server/db/db-workspace-registry.test.ts +++ b/packages/server/src/server/db/db-workspace-registry.test.ts @@ -5,7 +5,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js"; -import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from "../workspace-registry.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from "../workspace-registry.js"; import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; import { DbProjectRegistry } from "./db-project-registry.js"; import { DbWorkspaceRegistry } from "./db-workspace-registry.js"; @@ -24,7 +27,9 @@ function createProjectRecord(input: Partial = {}): Persi }); } -function createWorkspaceRecord(input: Partial = {}): PersistedWorkspaceRecord { +function createWorkspaceRecord( + input: Partial = {}, +): PersistedWorkspaceRecord { return createPersistedWorkspaceRecord({ id: 1, projectId: 1, diff --git a/packages/server/src/server/db/db-workspace-registry.ts b/packages/server/src/server/db/db-workspace-registry.ts index f5e2ea7a9..dda4008b7 100644 --- a/packages/server/src/server/db/db-workspace-registry.ts +++ b/packages/server/src/server/db/db-workspace-registry.ts @@ -33,11 +33,7 @@ export class DbWorkspaceRegistry implements WorkspaceRegistry { } async get(id: number): Promise { - const rows = await this.db - .select() - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); + const rows = await this.db.select().from(workspaces).where(eq(workspaces.id, id)).limit(1); const row = rows[0]; return row ? toPersistedWorkspaceRecord(row) : null; } diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts index 48590a573..b0382e926 100644 --- a/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts +++ b/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts @@ -251,7 +251,8 @@ describe("importLegacyAgentSnapshots", () => { }); const batchLogs = infoSpy.mock.calls.filter( - ([context, message]) => message === "Importing agent snapshot batch" && typeof context === "object", + ([context, message]) => + message === "Importing agent snapshot batch" && typeof context === "object", ); expect(batchLogs.length).toBeGreaterThan(1); expect(batchLogs[0]?.[0]).toMatchObject({ diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.ts index 5a9538128..73e266002 100644 --- a/packages/server/src/server/db/legacy-agent-snapshot-import.ts +++ b/packages/server/src/server/db/legacy-agent-snapshot-import.ts @@ -82,7 +82,9 @@ export async function importLegacyAgentSnapshots(options: { .select({ id: projects.id, directory: projects.directory, gitRemote: projects.gitRemote }) .from(projects) .all(); - const projectIdsByDirectory = new Map(projectRows.map((row) => [row.directory, row.id] as const)); + const projectIdsByDirectory = new Map( + projectRows.map((row) => [row.directory, row.id] as const), + ); const projectIdsByRemote = new Map( projectRows .filter((row): row is typeof row & { gitRemote: string } => row.gitRemote !== null) @@ -99,18 +101,21 @@ export async function importLegacyAgentSnapshots(options: { const resolvedDirectory = gitInfo?.toplevel ? normalizeWorkspaceId(gitInfo.toplevel) : normalizedDirectory; - const projectDisplayName = gitInfo?.metadata.projectDisplayName - ?? resolvedDirectory.split(/[\\/]/).filter(Boolean).at(-1) - ?? resolvedDirectory; + const projectDisplayName = + gitInfo?.metadata.projectDisplayName ?? + resolvedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? + resolvedDirectory; const projectKind = gitInfo?.metadata.projectKind ?? "directory"; const gitRemote = gitInfo?.metadata.gitRemote ?? null; const workspaceKind = gitInfo?.metadata.isWorktree ? "worktree" : "checkout"; - const workspaceDisplayName = gitInfo?.metadata.workspaceDisplayName - ?? normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) - ?? normalizedDirectory; + const workspaceDisplayName = + gitInfo?.metadata.workspaceDisplayName ?? + normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? + normalizedDirectory; - let projectId = projectIdsByDirectory.get(resolvedDirectory) - ?? (gitRemote !== null ? projectIdsByRemote.get(gitRemote) : undefined); + let projectId = + projectIdsByDirectory.get(resolvedDirectory) ?? + (gitRemote !== null ? projectIdsByRemote.get(gitRemote) : undefined); if (projectId === undefined) { const projectRow = tx .insert(projects) @@ -152,13 +157,18 @@ export async function importLegacyAgentSnapshots(options: { if (workspaceId === undefined) { return []; } - const clampedRecord = (record.lastStatus === "running" || record.lastStatus === "initializing") - ? { ...record, lastStatus: "closed" as const } - : record; + const clampedRecord = + record.lastStatus === "running" || record.lastStatus === "initializing" + ? { ...record, lastStatus: "closed" as const } + : record; return [toAgentSnapshotRowValues({ record: clampedRecord, workspaceId })]; }); const totalBatches = Math.ceil(rows.length / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT); - for (let startIndex = 0; startIndex < rows.length; startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) { + for ( + let startIndex = 0; + startIndex < rows.length; + startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT + ) { const batch = rows.slice(startIndex, startIndex + MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT); const batchNum = Math.floor(startIndex / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) + 1; const rowsProcessed = startIndex + batch.length; @@ -181,7 +191,10 @@ export async function importLegacyAgentSnapshots(options: { }; } -async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<{ +async function readLegacyAgentRecords( + baseDir: string, + logger: Logger, +): Promise<{ records: StoredAgentRecord[]; skippedCount: number; }> { diff --git a/packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts b/packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts index 8ff8a0e91..1e14030d8 100644 --- a/packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts +++ b/packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts @@ -98,9 +98,7 @@ describe("legacy import from real ~/.paseo data", () => { const paseoProject = paseoProjects[0]!; // 4. All paseo workspaces (worktrees + main checkout + subdirs) should be under that one project - const paseoWorkspaces = allWorkspaces.filter( - (w) => w.projectId === paseoProject.id, - ); + const paseoWorkspaces = allWorkspaces.filter((w) => w.projectId === paseoProject.id); console.log(`Paseo project id=${paseoProject.id}, directory=${paseoProject.directory}`); console.log(`Paseo workspaces: ${paseoWorkspaces.length}`); @@ -108,9 +106,7 @@ describe("legacy import from real ~/.paseo data", () => { expect(paseoWorkspaces.length).toBeGreaterThanOrEqual(10); // 5. Subdirectory workspaces (packages/server, packages/app) should be under the same project - const subdirWorkspaces = paseoWorkspaces.filter((w) => - w.directory.includes("/packages/"), - ); + const subdirWorkspaces = paseoWorkspaces.filter((w) => w.directory.includes("/packages/")); console.log(`Paseo subdirectory workspaces: ${subdirWorkspaces.length}`); for (const w of subdirWorkspaces) { console.log(` ${w.directory} (projectId=${w.projectId})`); diff --git a/packages/server/src/server/db/legacy-project-workspace-import.test.ts b/packages/server/src/server/db/legacy-project-workspace-import.test.ts index f5799f797..6d6a70692 100644 --- a/packages/server/src/server/db/legacy-project-workspace-import.test.ts +++ b/packages/server/src/server/db/legacy-project-workspace-import.test.ts @@ -313,7 +313,10 @@ function writeLegacyJson(input: { const projectsPath = path.join(input.paseoHome, "projects", "projects.json"); const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json"); mkdirSync(path.dirname(projectsPath), { recursive: true }); - writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { encoding: "utf8", flag: "w" }); + writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { + encoding: "utf8", + flag: "w", + }); writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), { encoding: "utf8", flag: "w", diff --git a/packages/server/src/server/db/legacy-project-workspace-import.ts b/packages/server/src/server/db/legacy-project-workspace-import.ts index 8b9b55f86..bc8ba2b46 100644 --- a/packages/server/src/server/db/legacy-project-workspace-import.ts +++ b/packages/server/src/server/db/legacy-project-workspace-import.ts @@ -62,7 +62,9 @@ export async function importLegacyProjectWorkspaceJson(options: { const databaseHasRows = await hasAnyProjectWorkspaceRows(options.db); if (databaseHasRows) { - options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty"); + options.logger.info( + "Skipping legacy project/workspace JSON import because the DB is not empty", + ); return { status: "skipped", reason: "database-not-empty", @@ -74,7 +76,9 @@ export async function importLegacyProjectWorkspaceJson(options: { pathExists(workspacesPath), ]); if (!projectsExists && !workspacesExists) { - options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist"); + options.logger.info( + "Skipping legacy project/workspace JSON import because no legacy files exist", + ); return { status: "skipped", reason: "no-legacy-files", @@ -94,7 +98,9 @@ export async function importLegacyProjectWorkspaceJson(options: { ]); if (projectRows.length === 0 && workspaceRows.length === 0) { - options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist"); + options.logger.info( + "Skipping legacy project/workspace JSON import because no legacy files exist", + ); return { status: "skipped", reason: "no-legacy-files", @@ -144,10 +150,11 @@ export async function importLegacyProjectWorkspaceJson(options: { for (const legacy of workspaceRows) { const projectId = legacyProjectIdToNewId.get(legacy.projectId); if (projectId === undefined) { - throw new Error(`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`); + throw new Error( + `Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`, + ); } - tx - .insert(workspaces) + tx.insert(workspaces) .values({ projectId, directory: legacy.cwd, @@ -252,7 +259,10 @@ async function backupLegacyProjectWorkspaceJson(options: { await fs.copyFile(options.workspacesPath, path.join(backupDir, "workspaces.json")); } - options.logger.info({ backupPath: backupDir }, "Backed up legacy project/workspace JSON before migration"); + options.logger.info( + { backupPath: backupDir }, + "Backed up legacy project/workspace JSON before migration", + ); } async function pathExists(targetPath: string): Promise { diff --git a/packages/server/src/server/db/schema.ts b/packages/server/src/server/db/schema.ts index 31c505522..0b193b808 100644 --- a/packages/server/src/server/db/schema.ts +++ b/packages/server/src/server/db/schema.ts @@ -1,6 +1,10 @@ import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import type { AgentPersistenceHandle, AgentRuntimeInfo, AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import type { + AgentPersistenceHandle, + AgentRuntimeInfo, + AgentTimelineItem, +} from "../agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "../agent/agent-storage.js"; export const projects = sqliteTable("projects", { @@ -65,9 +69,7 @@ export const agentTimelineRows = sqliteTable( item: text("item", { mode: "json" }).$type().notNull(), itemKind: text("item_kind"), }, - (table) => [ - primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" }), - ], + (table) => [primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" })], ); export const paseoDbSchema = { diff --git a/packages/server/src/server/db/sqlite-contract.test.ts b/packages/server/src/server/db/sqlite-contract.test.ts index 040748fc4..b2b174f72 100644 --- a/packages/server/src/server/db/sqlite-contract.test.ts +++ b/packages/server/src/server/db/sqlite-contract.test.ts @@ -8,12 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { openPaseoDatabase } from "./sqlite-database.js"; import { runPaseoDbMigrations } from "./migrations.js"; -import { - agentSnapshots, - agentTimelineRows, - projects, - workspaces, -} from "./schema.js"; +import { agentSnapshots, agentTimelineRows, projects, workspaces } from "./schema.js"; function createTimestamp(day: number): string { return `2026-03-${String(day).padStart(2, "0")}T00:00:00.000Z`; @@ -45,15 +40,18 @@ describe("SQLite database contract", () => { test("creates, migrates, closes, and reopens a persistent database", async () => { const database = await openPaseoDatabase(dataDir); - const [project] = await database.db.insert(projects).values({ - directory: "/tmp/project-1", - kind: "git", - displayName: "Project One", - gitRemote: "git@github.com:acme/project-1.git", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }).returning(); + const [project] = await database.db + .insert(projects) + .values({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + gitRemote: "git@github.com:acme/project-1.git", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }) + .returning(); await database.close(); @@ -77,24 +75,30 @@ describe("SQLite database contract", () => { test("supports project and workspace linkage plus archive field updates", async () => { const database = await openPaseoDatabase(dataDir); - const [project] = await database.db.insert(projects).values({ - directory: "/tmp/project-1", - kind: "git", - displayName: "Project One", - gitRemote: null, - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }).returning(); - const [workspace] = await database.db.insert(workspaces).values({ - projectId: project.id, - directory: "/tmp/project-1", - kind: "checkout", - displayName: "main", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }).returning(); + const [project] = await database.db + .insert(projects) + .values({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + gitRemote: null, + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }) + .returning(); + const [workspace] = await database.db + .insert(workspaces) + .values({ + projectId: project.id, + directory: "/tmp/project-1", + kind: "checkout", + displayName: "main", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }) + .returning(); await database.db .update(workspaces) @@ -123,24 +127,30 @@ describe("SQLite database contract", () => { test("supports snapshot insert, get, update, and project-delete cascade with integer workspace IDs", async () => { const database = await openPaseoDatabase(dataDir); - const [project] = await database.db.insert(projects).values({ - directory: "/tmp/project-1", - kind: "git", - displayName: "Project One", - gitRemote: null, - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }).returning(); - const [workspace] = await database.db.insert(workspaces).values({ - projectId: project.id, - directory: "/tmp/project-1", - kind: "checkout", - displayName: "main", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }).returning(); + const [project] = await database.db + .insert(projects) + .values({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + gitRemote: null, + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }) + .returning(); + const [workspace] = await database.db + .insert(workspaces) + .values({ + projectId: project.id, + directory: "/tmp/project-1", + kind: "checkout", + displayName: "main", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }) + .returning(); await database.db.insert(agentSnapshots).values({ agentId: "agent-1", diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 828c60f45..48ffff923 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -30,18 +30,22 @@ 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, - findExecutableSync, 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 { AGENT_PROVIDER_DEFINITIONS, + BUILTIN_PROVIDER_IDS, type AgentProviderDefinition, } from "./agent/provider-manifest.js"; 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/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 88c0047c8..3878e5648 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -1,6 +1,82 @@ import { describe, expect, test } from "vitest"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; -import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js"; +import { + attachAgentStoragePersistence, + buildConfigOverrides, + buildSessionConfig, +} from "./persistence-hooks.js"; +import type { + AgentPermissionRequest, + AgentSession, + AgentSessionConfig, +} from "./agent/agent-sdk-types.js"; + +const testLogger = { + child: () => testLogger, + error: vi.fn(), + warn: vi.fn(), +} as any; + +type ManagedAgentOverrides = Omit< + Partial, + "config" | "pendingPermissions" | "session" | "activeForegroundTurnId" +> & { + config?: Partial; + pendingPermissions?: Map; + session?: AgentSession | null; + activeForegroundTurnId?: string | null; +}; + +function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { + const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z"); + const provider = overrides.provider ?? "claude"; + const cwd = overrides.cwd ?? "/tmp/project"; + const lifecycle = overrides.lifecycle ?? "idle"; + const configOverrides = overrides.config ?? {}; + const config: AgentSessionConfig = { + provider, + cwd, + modeId: configOverrides.modeId ?? "plan", + model: configOverrides.model ?? "claude-3.5-sonnet", + extra: configOverrides.extra ?? { claude: { tone: "focused" } }, + }; + const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); + const activeForegroundTurnId = + overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); + + const agent: ManagedAgent = { + id: overrides.id ?? "agent-1", + provider, + cwd, + session, + capabilities: overrides.capabilities ?? { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + config, + lifecycle, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + availableModes: overrides.availableModes ?? [], + currentModeId: overrides.currentModeId ?? config.modeId ?? null, + pendingPermissions: overrides.pendingPermissions ?? new Map(), + activeForegroundTurnId, + foregroundTurnWaiters: new Set(), + unsubscribeSession: null, + timeline: overrides.timeline ?? [], + persistence: overrides.persistence ?? null, + historyPrimed: overrides.historyPrimed ?? true, + lastUserMessageAt: overrides.lastUserMessageAt ?? now, + lastUsage: overrides.lastUsage, + lastError: overrides.lastError, + }; + + return agent; +} function createRecord(overrides?: Partial): StoredAgentRecord { const now = new Date().toISOString(); @@ -110,4 +186,22 @@ describe("persistence hooks", () => { cwd: "/tmp/project", }); }); + + 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 2f92da58f..d8d2ec314 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -1,13 +1,14 @@ import type pino from "pino"; 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 { @@ -17,6 +18,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. @@ -56,9 +62,18 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial { } as any, }); - await expect( - service.refreshAgent({ agentId: "agent-compat-no-persistence" }), - ).rejects.toThrow("Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence"); + await expect(service.refreshAgent({ agentId: "agent-compat-no-persistence" })).rejects.toThrow( + "Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence", + ); }); }); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 3687bd5e4..e3024ff66 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -470,7 +470,14 @@ export class ScheduleService { extractTimestamps(record), ); } else { - snapshot = await this.agentManager.createAgent(buildSessionConfig(record), agentId, { + const config = buildSessionConfig(record, { + validProviders: this.agentManager.getRegisteredProviderIds(), + logger: this.logger, + }); + if (!config) { + throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); + } + snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels, }); } diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index 116366e63..141e634c1 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -6,10 +6,7 @@ import path from "node:path"; import { scheduler } from "node:timers/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import { findFreePort, ScriptRouteStore } from "./script-proxy.js"; -import { - ScriptHealthMonitor, - type ScriptHealthEntry, -} from "./script-health-monitor.js"; +import { ScriptHealthMonitor, type ScriptHealthEntry } from "./script-health-monitor.js"; import { spawnWorktreeScripts } from "./worktree-bootstrap.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; @@ -51,11 +48,7 @@ function createStubTerminalManager( async getTerminals() { return []; }, - async createTerminal(options: { - cwd: string; - name?: string; - env?: Record; - }) { + async createTerminal(options: { cwd: string; name?: string; env?: Record }) { createTerminalCalls.push(options); return { id: `term-${options.name ?? "terminal"}`, diff --git a/packages/server/src/server/script-health-monitor.ts b/packages/server/src/server/script-health-monitor.ts index 9370169f1..38332d902 100644 --- a/packages/server/src/server/script-health-monitor.ts +++ b/packages/server/src/server/script-health-monitor.ts @@ -19,10 +19,7 @@ type RouteHealthState = { export class ScriptHealthMonitor { private readonly routeStore: ScriptRouteStore; - private readonly onChange: ( - workspaceId: string, - scripts: ScriptHealthEntry[], - ) => void; + private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void; private readonly pollIntervalMs: number; private readonly probeTimeoutMs: number; private readonly graceMs: number; @@ -172,15 +169,13 @@ export class ScriptHealthMonitor { } private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] { - return this.routeStore - .listRoutesForWorkspace(workspaceId) - .flatMap((route) => { - const state = this.routeStates.get(route.hostname); - if (!state) { - return []; - } - return [this.toScriptHealthEntry(route, state.health)]; - }); + return this.routeStore.listRoutesForWorkspace(workspaceId).flatMap((route) => { + const state = this.routeStates.get(route.hostname); + if (!state) { + return []; + } + return [this.toScriptHealthEntry(route, state.health)]; + }); } getHealthForHostname(hostname: string): ScriptHealthState | null { diff --git a/packages/server/src/server/script-proxy.test.ts b/packages/server/src/server/script-proxy.test.ts index 8b89990ad..bef928d0c 100644 --- a/packages/server/src/server/script-proxy.test.ts +++ b/packages/server/src/server/script-proxy.test.ts @@ -253,9 +253,7 @@ describe("HTTP proxy", () => { res.end("upstream-ok"); }); - await new Promise((resolve) => - server.listen(port, "127.0.0.1", resolve), - ); + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); servers.push(server); return { @@ -281,9 +279,7 @@ describe("HTTP proxy", () => { } const server = http.createServer(app); - await new Promise((resolve) => - server.listen(port, "127.0.0.1", resolve), - ); + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); servers.push(server); return { port, server }; @@ -296,16 +292,11 @@ describe("HTTP proxy", () => { path = "/", ): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { - const req = http.get( - { hostname: "127.0.0.1", port, path, headers: { host } }, - (res) => { - let body = ""; - res.on("data", (chunk: Buffer) => (body += chunk.toString())); - res.on("end", () => - resolve({ status: res.statusCode ?? 0, body }), - ); - }, - ); + const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => (body += chunk.toString())); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + }); req.on("error", reject); }); } @@ -316,10 +307,7 @@ describe("HTTP proxy", () => { routeStore.addRoute("test-service.localhost", upstream.port); const proxy = await startProxy(routeStore); - const res = await httpGet( - proxy.port, - `test-service.localhost:${proxy.port}`, - ); + const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`); expect(res.status).toBe(200); expect(res.body).toBe("upstream-ok"); @@ -333,10 +321,7 @@ describe("HTTP proxy", () => { const routeStore = new ScriptRouteStore(); const proxy = await startProxy(routeStore, { fallback: true }); - const res = await httpGet( - proxy.port, - `unknown.localhost:${proxy.port}`, - ); + const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`); expect(res.status).toBe(404); expect(res.body).toBe("no route"); @@ -350,10 +335,7 @@ describe("HTTP proxy", () => { routeStore.addRoute("dead-service.localhost", deadPort); const proxy = await startProxy(routeStore); - const res = await httpGet( - proxy.port, - `dead-service.localhost:${proxy.port}`, - ); + const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`); expect(res.status).toBe(502); expect(res.body).toBe("502 Bad Gateway"); @@ -397,9 +379,7 @@ describe("WebSocket proxy", () => { }); }); - await new Promise((resolve) => - upstreamServer.listen(upstreamPort, "127.0.0.1", resolve), - ); + await new Promise((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve)); httpServers.push(upstreamServer); // 2. Create the proxy server with the upgrade handler @@ -418,9 +398,7 @@ describe("WebSocket proxy", () => { }); proxyServer.on("upgrade", upgradeHandler); - await new Promise((resolve) => - proxyServer.listen(proxyPort, "127.0.0.1", resolve), - ); + await new Promise((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve)); httpServers.push(proxyServer); // 3. Connect a WebSocket client through the proxy @@ -469,9 +447,7 @@ describe("findFreePort", () => { const addr = server.address(); expect(addr).not.toBeNull(); - expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe( - port, - ); + expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port); await new Promise((resolve) => server.close(() => resolve())); }); diff --git a/packages/server/src/server/script-proxy.ts b/packages/server/src/server/script-proxy.ts index c980c89a2..936fe48a2 100644 --- a/packages/server/src/server/script-proxy.ts +++ b/packages/server/src/server/script-proxy.ts @@ -184,8 +184,7 @@ export function createScriptProxyMiddleware({ } const forwardedHeaders = stripHopByHopHeaders(req.headers); - forwardedHeaders["x-forwarded-for"] = - req.socket.remoteAddress ?? "127.0.0.1"; + forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1"; forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, ""); forwardedHeaders["x-forwarded-proto"] = req.protocol; @@ -241,47 +240,41 @@ export function createScriptProxyUpgradeHandler({ return; } - const targetSocket = net.connect( - { host: "127.0.0.1", port: route.port }, - () => { - // Reconstruct the raw HTTP upgrade request to send to the target - const forwardedHeaders = stripHopByHopHeaders(req.headers); - forwardedHeaders["x-forwarded-for"] = - req.socket.remoteAddress ?? "127.0.0.1"; - forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, ""); - forwardedHeaders["x-forwarded-proto"] = "http"; + const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => { + // Reconstruct the raw HTTP upgrade request to send to the target + const forwardedHeaders = stripHopByHopHeaders(req.headers); + forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1"; + forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, ""); + forwardedHeaders["x-forwarded-proto"] = "http"; - // Re-include upgrade and connection headers — they are required for - // WebSocket handshake even though they are hop-by-hop. - forwardedHeaders["connection"] = "Upgrade"; - forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket"; + // Re-include upgrade and connection headers — they are required for + // WebSocket handshake even though they are hop-by-hop. + forwardedHeaders["connection"] = "Upgrade"; + forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket"; - const headerLines: string[] = []; - headerLines.push( - `${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`, - ); - for (const [key, value] of Object.entries(forwardedHeaders)) { - if (Array.isArray(value)) { - for (const v of value) { - headerLines.push(`${key}: ${v}`); - } - } else { - headerLines.push(`${key}: ${value}`); + const headerLines: string[] = []; + headerLines.push(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`); + for (const [key, value] of Object.entries(forwardedHeaders)) { + if (Array.isArray(value)) { + for (const v of value) { + headerLines.push(`${key}: ${v}`); } + } else { + headerLines.push(`${key}: ${value}`); } - headerLines.push("\r\n"); + } + headerLines.push("\r\n"); - targetSocket.write(headerLines.join("\r\n")); + targetSocket.write(headerLines.join("\r\n")); - if (head.length > 0) { - targetSocket.write(head); - } + if (head.length > 0) { + targetSocket.write(head); + } - // Pipe in both directions - targetSocket.pipe(socket); - socket.pipe(targetSocket); - }, - ); + // Pipe in both directions + targetSocket.pipe(socket); + socket.pipe(targetSocket); + }); targetSocket.on("error", (err) => { logger.warn( diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 60ce72a09..c828a5c0e 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -109,7 +109,7 @@ export function buildWorkspaceScriptPayloads( type, branchName, daemonPort: options.daemonPort, - configuredPort: isServiceScript(config) ? config.port ?? null : null, + configuredPort: isServiceScript(config) ? (config.port ?? null) : null, }); payloads.push({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a7d7558f6..53e48ddeb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,8 +1,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; -import { watch, type FSWatcher } from "node:fs"; -import { readFile, stat } from "fs/promises"; -import { exec, execFile } from "node:child_process"; +import type { FSWatcher } from "node:fs"; +import { exec } from "node:child_process"; import { promisify } from "util"; import { resolve, sep } from "path"; import { homedir } from "node:os"; @@ -63,24 +62,22 @@ import { createVoiceTurnController, type VoiceTurnController, } from "./voice/voice-turn-controller.js"; -import { - toAgentPersistenceHandle, -} from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; +import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { readGitCommand } from "./workspace-git-metadata.js"; -import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; -import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import type { + AgentProviderRuntimeSettingsMap, + ProviderOverride, +} from "./agent/provider-launch-config.js"; import { AgentManager } from "./agent/agent-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { @@ -114,7 +111,7 @@ import type { } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; -import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; +import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import type { PersistedProjectRecord, @@ -151,7 +148,6 @@ import { pullCurrentBranch, pushCurrentBranch, createPullRequest, - getPullRequestStatus, searchGitHubIssuesAndPrs, warmCheckoutShortstatInBackground, } from "../utils/checkout-git.js"; @@ -170,10 +166,11 @@ 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, - createPaseoWorktreeInBackground as createWorktreeInBackgroundSession, + runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, @@ -182,9 +179,14 @@ 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]; +const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; + +// 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 +// the entire session message if they encounter an unknown provider. const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50"; @@ -213,20 +215,17 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { } const MAX_TERMINAL_STREAM_SLOTS = 256; -const pendingAgentInitializations = new Map>(); type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & { beginDelete(agentId: string): void; }; - function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void { if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") { (agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId); } } - function deriveInitialAgentTitle(prompt: string): string | null { const firstContentLine = prompt .split(/\r?\n/) @@ -382,6 +381,16 @@ interface AudioBufferState { totalPCMBytes: number; } +// Stub types for features under development (modules not yet available) +type BackgroundGitFetchManager = { + subscribe( + opts: { repoGitRoot: string; cwd: string }, + callback: () => void, + ): Promise<{ unsubscribe: () => void }>; +}; + +type AgentMcpTransportFactory = () => Promise; + type VoiceTranscriptionResultPayload = { text: string; requestId: string; @@ -413,8 +422,8 @@ export type SessionOptions = { loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; agentLoadingService?: AgentLoadingService; - backgroundGitFetchManager: BackgroundGitFetchManager; - createAgentMcpTransport: AgentMcpTransportFactory; + backgroundGitFetchManager?: BackgroundGitFetchManager; + createAgentMcpTransport?: AgentMcpTransportFactory; workspaceGitService: WorkspaceGitService; daemonConfigStore: DaemonConfigStore; mcpBaseUrl?: string | null; @@ -447,6 +456,7 @@ export type SessionOptions = { getSpeechReadiness?: () => SpeechReadinessSnapshot; }; agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; + providerOverrides?: Record; }; export type SessionLifecycleIntent = @@ -518,8 +528,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( @@ -529,6 +551,31 @@ function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: strin return DEFAULT_AGENT_PROVIDER; } +function toAgentPersistenceHandle( + logger: pino.Logger, + providerRegistry: ReturnType, + handle: StoredAgentRecord["persistence"], +): AgentPersistenceHandle | null { + if (!handle) { + return null; + } + const provider = handle.provider; + if (!isRegisteredProvider(providerRegistry, provider)) { + logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); + return null; + } + if (!handle.sessionId) { + logger.warn("Ignoring persistence handle missing sessionId"); + return null; + } + return { + provider, + sessionId: handle.sessionId, + nativeHandle: handle.nativeHandle, + metadata: handle.metadata, + } satisfies AgentPersistenceHandle; +} + /** * Session represents a single connected client session. * It owns all state management, orchestration logic, and message processing. @@ -585,8 +632,6 @@ export class Session { private readonly loopService: LoopService; private readonly checkoutDiffManager: CheckoutDiffManager; private readonly agentLoadingService: AgentLoadingService; - private readonly backgroundGitFetchManager: BackgroundGitFetchManager; - private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly workspaceGitService: WorkspaceGitService; private readonly daemonConfigStore: DaemonConfigStore; private readonly mcpBaseUrl: string | null; @@ -616,9 +661,7 @@ export class Session { ) => void; private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpHost: (() => string | null) | null; - private readonly resolveScriptHealth: - | ((hostname: string) => ScriptHealthState | null) - | null; + private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null; private readonly subscribedTerminalDirectories = new Set(); private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalExitSubscriptions: Map void> = new Map(); @@ -631,7 +674,6 @@ export class Session { private readonly workspaceGitWatchTargets = new Map(); private readonly workspaceSetupSnapshots = new Map(); private readonly workspaceGitFetchSubscriptions = new Map void>(); - private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; private readonly workspaceGitSubscriptions = new Map void>(); private readonly registerVoiceSpeakHandler?: ( agentId: string, @@ -645,6 +687,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; @@ -668,8 +711,6 @@ export class Session { loopService, checkoutDiffManager, agentLoadingService, - backgroundGitFetchManager, - createAgentMcpTransport, workspaceGitService, daemonConfigStore, mcpBaseUrl, @@ -687,6 +728,7 @@ export class Session { voiceBridge, dictation, agentProviderRuntimeSettings, + providerOverrides, } = options; this.clientId = clientId; this.appVersion = appVersion ?? null; @@ -717,8 +759,6 @@ export class Session { agentStorage: this.agentStorage, logger: this.sessionLogger, }); - this.backgroundGitFetchManager = backgroundGitFetchManager; - this.createAgentMcpTransport = createAgentMcpTransport; this.workspaceGitService = workspaceGitService; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl ?? null; @@ -762,9 +802,11 @@ export class Session { this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; this.getSpeechReadiness = dictation?.getSpeechReadiness; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; + this.providerOverrides = providerOverrides; this.abortController = new AbortController(); this.providerRegistry = buildProviderRegistry(this.sessionLogger, { runtimeSettings: this.agentProviderRuntimeSettings, + providerOverrides: this.providerOverrides, }); // Initialize per-session managers @@ -1130,10 +1172,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 } @@ -1166,7 +1218,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: record.lastError ?? undefined, title: record.title ?? record.config?.title ?? null, @@ -1892,241 +1948,241 @@ export class Session { await this.handleChatWaitRequest(msg); break; - case "github_search_request": - await this.handleGitHubSearchRequest(msg); - break; + case "github_search_request": + await this.handleGitHubSearchRequest(msg); + break; - case "directory_suggestions_request": - await this.handleDirectorySuggestionsRequest(msg); - break; + case "directory_suggestions_request": + await this.handleDirectorySuggestionsRequest(msg); + break; - case "checkout_pr_create_request": - await this.handleCheckoutPrCreateRequest(msg); - break; + case "checkout_pr_create_request": + await this.handleCheckoutPrCreateRequest(msg); + break; - case "checkout_pr_status_request": - await this.handleCheckoutPrStatusRequest(msg); - break; + case "checkout_pr_status_request": + await this.handleCheckoutPrStatusRequest(msg); + break; - case "paseo_worktree_list_request": - await this.handlePaseoWorktreeListRequest(msg); - break; + case "paseo_worktree_list_request": + await this.handlePaseoWorktreeListRequest(msg); + break; - case "paseo_worktree_archive_request": - await this.handlePaseoWorktreeArchiveRequest(msg); - break; + case "paseo_worktree_archive_request": + await this.handlePaseoWorktreeArchiveRequest(msg); + break; - case "create_paseo_worktree_request": - await this.handleCreatePaseoWorktreeRequest(msg); - break; + case "create_paseo_worktree_request": + await this.handleCreatePaseoWorktreeRequest(msg); + break; - case "workspace_setup_status_request": - await this.handleWorkspaceSetupStatusRequest(msg); - break; + case "workspace_setup_status_request": + await this.handleWorkspaceSetupStatusRequest(msg); + break; - case "list_available_editors_request": - await this.handleListAvailableEditorsRequest(msg); - break; + case "list_available_editors_request": + await this.handleListAvailableEditorsRequest(msg); + break; - case "open_in_editor_request": - await this.handleOpenInEditorRequest(msg); - break; + case "open_in_editor_request": + await this.handleOpenInEditorRequest(msg); + break; - case "open_project_request": - await this.handleOpenProjectRequest(msg); - break; + case "open_project_request": + await this.handleOpenProjectRequest(msg); + break; - case "archive_workspace_request": - await this.handleArchiveWorkspaceRequest(msg); - break; + case "archive_workspace_request": + await this.handleArchiveWorkspaceRequest(msg); + break; - case "file_explorer_request": - await this.handleFileExplorerRequest(msg); - break; + case "file_explorer_request": + await this.handleFileExplorerRequest(msg); + break; - case "project_icon_request": - await this.handleProjectIconRequest(msg); - break; + case "project_icon_request": + await this.handleProjectIconRequest(msg); + break; - case "file_download_token_request": - await this.handleFileDownloadTokenRequest(msg); - break; + case "file_download_token_request": + await this.handleFileDownloadTokenRequest(msg); + break; - case "list_provider_models_request": - await this.handleListProviderModelsRequest(msg); - break; + case "list_provider_models_request": + await this.handleListProviderModelsRequest(msg); + break; - case "list_provider_modes_request": - await this.handleListProviderModesRequest(msg); - break; + case "list_provider_modes_request": + await this.handleListProviderModesRequest(msg); + break; - case "list_provider_features_request": - await this.handleListProviderFeaturesRequest(msg); - break; + case "list_provider_features_request": + await this.handleListProviderFeaturesRequest(msg); + break; - case "list_available_providers_request": - await this.handleListAvailableProvidersRequest(msg); - break; + case "list_available_providers_request": + await this.handleListAvailableProvidersRequest(msg); + break; - case "get_providers_snapshot_request": - await this.handleGetProvidersSnapshotRequest(msg); - break; + case "get_providers_snapshot_request": + await this.handleGetProvidersSnapshotRequest(msg); + break; - case "refresh_providers_snapshot_request": - await this.handleRefreshProvidersSnapshotRequest(msg); - break; + case "refresh_providers_snapshot_request": + await this.handleRefreshProvidersSnapshotRequest(msg); + break; - case "provider_diagnostic_request": - await this.handleProviderDiagnosticRequest(msg); - break; + case "provider_diagnostic_request": + await this.handleProviderDiagnosticRequest(msg); + break; - case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId); - break; + case "clear_agent_attention": + await this.handleClearAgentAttention(msg.agentId); + break; - case "client_heartbeat": - this.handleClientHeartbeat(msg); - break; + case "client_heartbeat": + this.handleClientHeartbeat(msg); + break; - case "ping": { - const now = Date.now(); - this.emit({ - type: "pong", - payload: { - requestId: msg.requestId, - clientSentAt: msg.clientSentAt, - serverReceivedAt: now, - serverSentAt: now, - }, - }); - break; + case "ping": { + const now = Date.now(); + this.emit({ + type: "pong", + payload: { + requestId: msg.requestId, + clientSentAt: msg.clientSentAt, + serverReceivedAt: now, + serverSentAt: now, + }, + }); + break; + } + + case "list_commands_request": + await this.handleListCommandsRequest(msg); + break; + + case "register_push_token": + this.handleRegisterPushToken(msg.token); + break; + + case "subscribe_terminals_request": + this.handleSubscribeTerminalsRequest(msg); + break; + + case "unsubscribe_terminals_request": + this.handleUnsubscribeTerminalsRequest(msg); + break; + + case "list_terminals_request": + await this.handleListTerminalsRequest(msg); + break; + + case "create_terminal_request": + await this.handleCreateTerminalRequest(msg); + break; + + case "start_workspace_script_request": + await this.handleStartWorkspaceScriptRequest(msg); + break; + + case "subscribe_terminal_request": + await this.handleSubscribeTerminalRequest(msg); + break; + + case "unsubscribe_terminal_request": + this.handleUnsubscribeTerminalRequest(msg); + break; + + case "terminal_input": + this.handleTerminalInput(msg); + break; + + case "kill_terminal_request": + await this.handleKillTerminalRequest(msg); + break; + + case "capture_terminal_request": + await this.handleCaptureTerminalRequest(msg); + break; + + case "chat/create": + await this.handleChatCreateRequest(msg); + break; + + case "chat/list": + await this.handleChatListRequest(msg); + break; + + case "chat/inspect": + await this.handleChatInspectRequest(msg); + break; + + case "chat/delete": + await this.handleChatDeleteRequest(msg); + break; + + case "chat/post": + await this.handleChatPostRequest(msg); + break; + + case "chat/read": + await this.handleChatReadRequest(msg); + break; + + case "chat/wait": + await this.handleChatWaitRequest(msg); + break; + + case "schedule/create": + await this.handleScheduleCreateRequest(msg); + break; + + case "schedule/list": + await this.handleScheduleListRequest(msg); + break; + + case "schedule/inspect": + await this.handleScheduleInspectRequest(msg); + break; + + case "schedule/logs": + await this.handleScheduleLogsRequest(msg); + break; + + case "schedule/pause": + await this.handleSchedulePauseRequest(msg); + break; + + case "schedule/resume": + await this.handleScheduleResumeRequest(msg); + break; + + case "schedule/delete": + await this.handleScheduleDeleteRequest(msg); + break; + + case "loop/run": + await this.handleLoopRunRequest(msg); + break; + + case "loop/list": + await this.handleLoopListRequest(msg); + break; + + case "loop/inspect": + await this.handleLoopInspectRequest(msg); + break; + + case "loop/logs": + await this.handleLoopLogsRequest(msg); + break; + + case "loop/stop": + await this.handleLoopStopRequest(msg); + break; } - - case "list_commands_request": - await this.handleListCommandsRequest(msg); - break; - - case "register_push_token": - this.handleRegisterPushToken(msg.token); - break; - - case "subscribe_terminals_request": - this.handleSubscribeTerminalsRequest(msg); - break; - - case "unsubscribe_terminals_request": - this.handleUnsubscribeTerminalsRequest(msg); - break; - - case "list_terminals_request": - await this.handleListTerminalsRequest(msg); - break; - - case "create_terminal_request": - await this.handleCreateTerminalRequest(msg); - break; - - case "start_workspace_script_request": - await this.handleStartWorkspaceScriptRequest(msg); - break; - - case "subscribe_terminal_request": - await this.handleSubscribeTerminalRequest(msg); - break; - - case "unsubscribe_terminal_request": - this.handleUnsubscribeTerminalRequest(msg); - break; - - case "terminal_input": - this.handleTerminalInput(msg); - break; - - case "kill_terminal_request": - await this.handleKillTerminalRequest(msg); - break; - - case "capture_terminal_request": - await this.handleCaptureTerminalRequest(msg); - break; - - case "chat/create": - await this.handleChatCreateRequest(msg); - break; - - case "chat/list": - await this.handleChatListRequest(msg); - break; - - case "chat/inspect": - await this.handleChatInspectRequest(msg); - break; - - case "chat/delete": - await this.handleChatDeleteRequest(msg); - break; - - case "chat/post": - await this.handleChatPostRequest(msg); - break; - - case "chat/read": - await this.handleChatReadRequest(msg); - break; - - case "chat/wait": - await this.handleChatWaitRequest(msg); - break; - - case "schedule/create": - await this.handleScheduleCreateRequest(msg); - break; - - case "schedule/list": - await this.handleScheduleListRequest(msg); - break; - - case "schedule/inspect": - await this.handleScheduleInspectRequest(msg); - break; - - case "schedule/logs": - await this.handleScheduleLogsRequest(msg); - break; - - case "schedule/pause": - await this.handleSchedulePauseRequest(msg); - break; - - case "schedule/resume": - await this.handleScheduleResumeRequest(msg); - break; - - case "schedule/delete": - await this.handleScheduleDeleteRequest(msg); - break; - - case "loop/run": - await this.handleLoopRunRequest(msg); - break; - - case "loop/list": - await this.handleLoopListRequest(msg); - break; - - case "loop/inspect": - await this.handleLoopInspectRequest(msg); - break; - - case "loop/logs": - await this.handleLoopLogsRequest(msg); - break; - - case "loop/stop": - await this.handleLoopStopRequest(msg); - break; - } - } catch (error: any) { + } catch (error: any) { const err = error instanceof Error ? error : new Error(String(error)); this.sessionLogger.error({ err }, "Error handling message"); @@ -2285,10 +2341,7 @@ export class Session { await this.agentStorage.remove(agentId); await this.agentManager.deleteCommittedTimeline(agentId); } catch (error: any) { - this.sessionLogger.error( - { err: error, agentId }, - `Failed to fully delete agent ${agentId}`, - ); + this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`); } this.emit({ @@ -3008,11 +3061,10 @@ export class Session { worktreeName, attachments, ); - const resolvedWorkspace = - msg.workspaceId - ? await this.resolveWorkspaceByIdOrDirectory(msg.workspaceId) - : (await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? - (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)); + const resolvedWorkspace = msg.workspaceId + ? await this.resolveWorkspaceByIdOrDirectory(msg.workspaceId) + : ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? + (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd))); if (!resolvedWorkspace) { throw new Error(`Workspace not found: ${msg.workspaceId}`); } @@ -3682,7 +3734,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}`); } @@ -3696,7 +3748,7 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execFileAsync("git", ["checkout", branch], { cwd }); + await execCommand("git", ["checkout", branch], { cwd }); } private async createBranchFromBase(params: { @@ -3709,7 +3761,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}`); } @@ -3720,7 +3772,7 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], { + await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], { cwd, }); } @@ -3728,7 +3780,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; @@ -4190,7 +4242,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, }); @@ -4211,7 +4263,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, }); @@ -4358,6 +4410,30 @@ export class Session { } } + private closeWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): void { + if (target.debounceTimer) { + clearTimeout(target.debounceTimer); + target.debounceTimer = null; + } + for (const watcher of target.watchers) { + try { + watcher.close(); + } catch { + // Ignore watcher close errors + } + } + target.watchers.length = 0; + } + + private async removeWorkspaceGitWatchTarget(cwd: string): Promise { + const workspaceId = normalizePersistedWorkspaceId(cwd); + const target = this.workspaceGitWatchTargets.get(workspaceId); + if (target) { + this.closeWorkspaceGitWatchTarget(target); + this.workspaceGitWatchTargets.delete(workspaceId); + } + } + private removeWorkspaceGitSubscription(cwd: string): void { const workspaceId = normalizePersistedWorkspaceId(cwd); const target = this.workspaceGitWatchTargets.get(workspaceId); @@ -4425,100 +4501,6 @@ export class Session { } } - private scheduleWorkspaceGitWatchRefresh(target: WorkspaceGitWatchTarget): void { - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - } - target.debounceTimer = setTimeout(() => { - target.debounceTimer = null; - void this.refreshWorkspaceGitWatchTarget(target); - }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); - } - - private async refreshWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): Promise { - if (target.refreshPromise) { - target.refreshQueued = true; - return; - } - - target.refreshPromise = (async () => { - do { - target.refreshQueued = false; - await this.emitWorkspaceUpdateForCwd(target.cwd, { - dedupeGitState: true, - }); - } while (target.refreshQueued); - })(); - - try { - await target.refreshPromise; - } finally { - target.refreshPromise = null; - } - } - - private async ensureWorkspaceGitWatchTarget(cwd: string): Promise { - const workspaceId = normalizePersistedWorkspaceId(cwd); - if (this.workspaceGitWatchTargets.has(workspaceId)) { - return; - } - - const gitDir = await resolveCheckoutGitDir(cwd); - if (!gitDir) { - return; - } - - const refsRoot = await this.resolveWorkspaceGitRefsRoot(gitDir); - const target: WorkspaceGitWatchTarget = { - cwd: workspaceId, - watchers: [], - debounceTimer: null, - refreshPromise: null, - refreshQueued: false, - latestFingerprint: null, - lastBranchName: null, - }; - - for (const watchPath of new Set([join(gitDir, "HEAD"), join(refsRoot, "refs", "heads")])) { - let watcher: FSWatcher | null = null; - try { - watcher = watch(watchPath, { recursive: false }, () => { - this.scheduleWorkspaceGitWatchRefresh(target); - }); - } catch (error) { - this.sessionLogger.warn( - { err: error, cwd, watchPath }, - "Failed to start workspace git watcher", - ); - } - - if (!watcher) { - continue; - } - - watcher.on("error", (error) => { - this.sessionLogger.warn({ err: error, cwd, watchPath }, "Workspace git watcher error"); - }); - target.watchers.push(watcher); - } - - if (target.watchers.length === 0) { - return; - } - - this.workspaceGitWatchTargets.set(workspaceId, target); - const subscription = await this.backgroundGitFetchManager.subscribe( - { repoGitRoot: refsRoot, cwd: workspaceId }, - () => { - const activeTarget = this.workspaceGitWatchTargets.get(workspaceId); - if (activeTarget) { - this.scheduleWorkspaceGitWatchRefresh(activeTarget); - } - }, - ); - this.workspaceGitFetchSubscriptions.set(workspaceId, subscription.unsubscribe); - } - private async syncWorkspaceGitWatchTarget( cwd: string, options: { isGit: boolean }, @@ -4626,7 +4608,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", @@ -4645,7 +4627,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", @@ -5689,15 +5671,16 @@ export class Session { status: "done", activityAt: null, diffStat, - scripts: this.scriptRouteStore && this.scriptRuntimeStore - ? buildWorkspaceScriptPayloads({ - workspaceDirectory: workspace.directory, - routeStore: this.scriptRouteStore, - runtimeStore: this.scriptRuntimeStore, - daemonPort: this.getDaemonTcpPort?.() ?? null, - resolveHealth: this.resolveScriptHealth ?? undefined, - }) - : [], + scripts: + this.scriptRouteStore && this.scriptRuntimeStore + ? buildWorkspaceScriptPayloads({ + workspaceDirectory: workspace.directory, + routeStore: this.scriptRouteStore, + runtimeStore: this.scriptRuntimeStore, + daemonPort: this.getDaemonTcpPort?.() ?? null, + resolveHealth: this.resolveScriptHealth ?? undefined, + }) + : [], }; } @@ -5734,7 +5717,16 @@ export class Session { workspace: PersistedWorkspaceRecord, projectRecord?: PersistedProjectRecord | null, ): Promise { - return this.describeWorkspaceRecord(workspace, projectRecord); + const base = await this.describeWorkspaceRecord(workspace, projectRecord); + const snapshot = this.workspaceGitService.peekSnapshot(workspace.directory); + if (!snapshot) { + return base; + } + return { + ...base, + gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined, + githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot), + }; } private async buildWorkspaceDescriptor(input: { @@ -6291,7 +6283,7 @@ export class Session { private async emitWorkspaceUpdatesForWorkspaceIds( workspaceIds: Iterable, - options?: { skipReconcile?: boolean }, + options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, ): Promise { const subscription = this.workspaceUpdatesSubscription; if (!subscription) { @@ -6364,7 +6356,7 @@ export class Session { private async emitWorkspaceUpdateForCwd( cwd: string, - options?: { skipReconcile?: boolean }, + options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, ): Promise { const activeWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => !workspace.archivedAt, @@ -6708,20 +6700,22 @@ 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; workspaceId: number; worktree: { branchName: string; worktreePath: string }; shouldBootstrap: boolean; + slug: string; + worktreePath: string; }): Promise { - return createWorktreeInBackgroundSession( + return runWorktreeSetupInBackgroundSession( { paseoHome: this.paseoHome, emitWorkspaceUpdateForCwd: (cwd, emitOptions) => @@ -8455,7 +8449,9 @@ export class Session { } try { - const terminals = this.filterStandaloneTerminals(await this.terminalManager.getTerminals(cwd)); + const terminals = this.filterStandaloneTerminals( + await this.terminalManager.getTerminals(cwd), + ); for (const terminal of terminals) { this.ensureTerminalExitSubscription(terminal); } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index b2a773d45..5d9f87601 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -180,10 +180,12 @@ function createWorkspaceRuntimeSnapshot( }; } -function createSessionForWorkspaceTests(options: { - appVersion?: string | null; - workspaceGitService?: ReturnType; -} = {}): { +function createSessionForWorkspaceTests( + options: { + appVersion?: string | null; + workspaceGitService?: ReturnType; + } = {}, +): { session: Session; emitted: Array<{ type: string; payload: unknown }>; projects: Map>; @@ -344,10 +346,10 @@ function seedWorkspace(options: { return record; } -function createTempGitRepo(options?: { - remoteUrl?: string; - branchName?: string; -}): { tempDir: string; repoDir: string } { +function createTempGitRepo(options?: { remoteUrl?: string; branchName?: string }): { + tempDir: string; + repoDir: string; +} { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-git-"))); const repoDir = path.join(tempDir, "repo"); execSync(`mkdir -p ${repoDir}`); @@ -1205,9 +1207,7 @@ describe("workspace aggregation", () => { const response = emitted.find( (message) => message.type === "create_paseo_worktree_response", - ) as - | { type: "create_paseo_worktree_response"; payload: any } - | undefined; + ) as { type: "create_paseo_worktree_response"; payload: any } | undefined; expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace).toMatchObject({ @@ -1329,9 +1329,9 @@ describe("workspace aggregation", () => { requestId: "req-open", }); - expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === "/tmp/repo")).toBe( - true, - ); + expect( + Array.from(workspaces.values()).some((workspace) => workspace.directory === "/tmp/repo"), + ).toBe(true); const response = emitted.find((message) => message.type === "open_project_response") as any; expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace?.id).toBe("/tmp/repo"); @@ -1364,12 +1364,12 @@ describe("workspace aggregation", () => { requestId: "req-open-subdir", }); - expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === repoRoot)).toBe( - true, - ); - expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === subdir)).toBe( - false, - ); + expect( + Array.from(workspaces.values()).some((workspace) => workspace.directory === repoRoot), + ).toBe(true); + expect( + Array.from(workspaces.values()).some((workspace) => workspace.directory === subdir), + ).toBe(false); const response = emitted.find((message) => message.type === "open_project_response") as any; expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace?.id).toBe(repoRoot); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 63b796793..3bddaeeb1 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -85,6 +85,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 a17736934..a3e3a00b6 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"; @@ -251,15 +254,14 @@ export class VoiceAssistantWebSocketServer { private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpHost: (() => string | null) | null; - private readonly resolveScriptHealth: - | ((hostname: string) => ScriptHealthState | null) - | null; + private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null; private readonly dictation: { finalTimeoutMs?: number; } | null; 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 readonly onBranchChanged: @@ -308,6 +310,7 @@ export class VoiceAssistantWebSocketServer { finalTimeoutMs?: number; }, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, + providerOverrides?: Record, daemonVersion?: string, onLifecycleIntent?: (intent: SessionLifecycleIntent) => void, projectRegistry?: ProjectRegistry, @@ -365,10 +368,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, ); @@ -720,6 +725,7 @@ export class VoiceAssistantWebSocketServer { } : undefined, agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, + providerOverrides: this.providerOverrides, }); connection = { 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/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index d908ae81c..34379b490 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -209,10 +209,7 @@ export class WorkspaceReconciliationService { workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory; const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName); - if ( - wsGit.projectKind === "git" && - workspace.displayName !== wsGit.workspaceDisplayName - ) { + if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) { const timestamp = new Date().toISOString(); await this.workspaceRegistry.upsert({ ...workspace, diff --git a/packages/server/src/server/workspace-registry.test-helpers.ts b/packages/server/src/server/workspace-registry.test-helpers.ts index 1be76e04d..5047a3c32 100644 --- a/packages/server/src/server/workspace-registry.test-helpers.ts +++ b/packages/server/src/server/workspace-registry.test-helpers.ts @@ -67,7 +67,8 @@ class FileBackedRegistry { async insert(record: Omit): Promise { await this.load(); - const nextId = Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1; + const nextId = + Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1; const parsed = this.parseRecord({ ...record, id: nextId }); this.cache.set(String(this.getId(parsed)), parsed); await this.enqueuePersist(); diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index aa26442cf..1e5acfcfc 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -515,11 +515,7 @@ describe("runAsyncWorktreeBootstrap", () => { async getTerminals() { return []; }, - async createTerminal(options: { - cwd: string; - name?: string; - env?: Record; - }) { + async createTerminal(options: { cwd: string; name?: string; env?: Record }) { createTerminalCalls.push(options); return { id: "term-service", @@ -576,7 +572,8 @@ describe("runAsyncWorktreeBootstrap", () => { const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); - const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = []; + const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = + []; const results = await spawnWorktreeScripts({ repoRoot: repoDir, @@ -622,7 +619,8 @@ describe("runAsyncWorktreeBootstrap", () => { const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); - const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = []; + const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = + []; const results = await spawnWorktreeScripts({ repoRoot: repoDir, diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index a553717f1..f92956391 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,11 +1,10 @@ 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 { buildScriptHostname } from "../utils/script-hostname.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { createWorktree, getScriptConfigs, @@ -62,7 +61,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; -const execAsync = promisify(exec); + type MiddleTruncationAccumulator = { totalBytes: number; head: string; @@ -214,7 +213,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, }); @@ -877,7 +876,13 @@ export async function spawnWorkspaceScript( terminal.send({ type: "input", data: `${config.command}\r` }); logger?.info( - { scriptName, hostname, port, terminalId: terminal.id, type: serviceScript ? "service" : "script" }, + { + scriptName, + hostname, + port, + terminalId: terminal.id, + type: serviceScript ? "service" : "script", + }, serviceScript ? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}` : "Started workspace script", diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 8ad5e31a2..e3c5a6d37 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -1,5 +1,12 @@ import { execSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -42,38 +49,36 @@ function createTerminalManagerStub(options?: { terminals, manager: { registerCwdEnv: vi.fn(), - createTerminal: vi.fn(async (input: { - cwd: string; - name?: string; - env?: Record; - }) => { - if (options?.createTerminal) { - return options.createTerminal(input); - } - const sent: string[] = []; - const terminal = { - id: `terminal-${terminals.length + 1}`, - getState: () => ({ - scrollback: [[{ char: "$" }]], - grid: [], - }), - subscribe: () => () => {}, - onExit: () => () => {}, - send: (message: { type: string; data: string }) => { - if (message.type === "input") { - sent.push(message.data); - } - }, - }; - terminals.push({ - id: terminal.id, - cwd: input.cwd, - name: input.name, - env: input.env, - sent, - }); - return terminal; - }), + createTerminal: vi.fn( + async (input: { cwd: string; name?: string; env?: Record }) => { + if (options?.createTerminal) { + return options.createTerminal(input); + } + const sent: string[] = []; + const terminal = { + id: `terminal-${terminals.length + 1}`, + getState: () => ({ + scrollback: [[{ char: "$" }]], + grid: [], + }), + subscribe: () => () => {}, + onExit: () => () => {}, + send: (message: { type: string; data: string }) => { + if (message.type === "input") { + sent.push(message.data); + } + }, + }; + terminals.push({ + id: terminal.id, + cwd: input.cwd, + name: input.name, + env: input.env, + sent, + }); + return terminal; + }, + ), } as any, }; } @@ -114,9 +119,12 @@ function createGitHubPrRemoteRepo() { execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, { stdio: "pipe", }); - execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`, { - stdio: "pipe", - }); + execSync( + `git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`, + { + stdio: "pipe", + }, + ); execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" }); execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" }); @@ -165,7 +173,8 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, - cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => + snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -261,7 +270,8 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, - cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => + snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, @@ -301,7 +311,7 @@ describe("createPaseoWorktreeInBackground", () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { worktree: { - setup: ['sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"'], + setup: ["sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\""], }, }, }); @@ -327,7 +337,8 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, - cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => + snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, @@ -364,11 +375,13 @@ describe("createPaseoWorktreeInBackground", () => { }); expect(progressMessages.at(-1)?.payload.status).toBe("completed"); - const runningMessages = progressMessages.filter((message) => message.payload.status === "running"); - expect(runningMessages.length).toBeGreaterThan(0); - expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan( - progressMessages.findIndex((message) => message.payload.status === "completed"), + const runningMessages = progressMessages.filter( + (message) => message.payload.status === "running", ); + expect(runningMessages.length).toBeGreaterThan(0); + expect( + progressMessages.findIndex((message) => message.payload.status === "running"), + ).toBeLessThan(progressMessages.findIndex((message) => message.payload.status === "completed")); const setupOutputMessage = runningMessages.find((message) => message.payload.detail.commands[0]?.log.includes("phase-one"), @@ -376,7 +389,7 @@ describe("createPaseoWorktreeInBackground", () => { expect(setupOutputMessage?.payload.detail.log).toContain("phase-one"); expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({ index: 1, - command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"', + command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", log: expect.stringContaining("phase-one"), status: "running", }); @@ -394,7 +407,7 @@ describe("createPaseoWorktreeInBackground", () => { expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two"); expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({ index: 1, - command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"', + command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", log: expect.stringContaining("phase-two"), status: "completed", exitCode: 0, @@ -441,7 +454,8 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, - cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => + snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -482,10 +496,12 @@ describe("createPaseoWorktreeInBackground", () => { }, }); expect(terminalManager.terminals).toHaveLength(0); - expect( - readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"), - ).toContain("hello"); - expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow(); + expect(readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8")).toContain( + "hello", + ); + expect(() => + readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8"), + ).toThrow(); expect(snapshots.get("44")).toMatchObject({ status: "completed", error: null, @@ -531,7 +547,8 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, - cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => + snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -610,7 +627,8 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, - cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => + snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -714,7 +732,6 @@ describe("createPaseoWorktreeInBackground", () => { }, }); }); - }); describe("handleCreatePaseoWorktreeRequest", () => { @@ -787,7 +804,9 @@ describe("handleCreatePaseoWorktreeRequest", () => { ); const response = emitted.find( - (message): message is Extract => + ( + message, + ): message is Extract => message.type === "create_paseo_worktree_response", ); @@ -899,7 +918,9 @@ describe("handleCreatePaseoWorktreeRequest", () => { expect(createAgentWorktreeSpy).toHaveBeenCalledTimes(1); const response = emitted.find( - (message): message is Extract => + ( + message, + ): message is Extract => message.type === "create_paseo_worktree_response", ); expect(response?.payload.error).toBeNull(); @@ -950,7 +971,9 @@ describe("handleCreatePaseoWorktreeRequest", () => { ); const response = emitted.find( - (message): message is Extract => + ( + message, + ): message is Extract => message.type === "create_paseo_worktree_response", ); expect(response?.payload.error).toBeNull(); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index d56daef28..eb84c369d 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -1,4 +1,4 @@ -import { exec, execFile } from "node:child_process"; +import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { Logger } from "pino"; import { v4 as uuidv4 } from "uuid"; @@ -48,9 +48,9 @@ import { WorktreeSetupError, } from "../utils/worktree.js"; import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.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 execFileAsync = promisify(execFile); const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; @@ -90,20 +90,14 @@ type RegisterPendingWorktreeWorkspaceDependencies = { buildProjectPlacement: (cwd: string) => Promise; findWorkspaceByDirectory: (directory: string) => Promise; projectRegistry: Pick; - syncWorkspaceGitWatchTarget: ( - cwd: string, - options: { isGit: boolean }, - ) => Promise; + syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise; workspaceRegistry: Pick; archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise; }; type CreatePaseoWorktreeInBackgroundDependencies = { paseoHome?: string; - emitWorkspaceUpdateForCwd: ( - cwd: string, - options?: { dedupeGitState?: boolean }, - ) => Promise; + emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise; cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void; emit: EmitSessionMessage; sessionLogger: Logger; @@ -129,12 +123,14 @@ type HandleCreatePaseoWorktreeRequestDependencies = { branchName: string; }) => Promise; sessionLogger: Logger; - createPaseoWorktreeInBackground: (options: { + runWorktreeSetupInBackground: (options: { requestCwd: string; repoRoot: string; workspaceId: number; worktree: WorktreeConfig; shouldBootstrap: boolean; + slug: string; + worktreePath: string; }) => Promise; }; @@ -175,7 +171,8 @@ export async function buildAgentSessionConfig( githubPrAttachment.baseRefName?.trim() || normalized.baseBranch || (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); - const worktreeSlug = normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment)); + const worktreeSlug = + normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment)); const createdWorktree = await createGitHubPrWorktree({ cwd, branchName: resolveGitHubPrBranchName(githubPrAttachment), @@ -203,7 +200,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, }); @@ -690,6 +687,15 @@ export async function handleCreatePaseoWorktreeRequest( worktreePath: createdWorktree.worktree.worktreePath, branchName: createdWorktree.worktree.branchName, }); + + 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", @@ -701,12 +707,14 @@ export async function handleCreatePaseoWorktreeRequest( }, }); - void dependencies.createPaseoWorktreeInBackground({ + void dependencies.runWorktreeSetupInBackground({ requestCwd: request.cwd, repoRoot, workspaceId: workspace.id, worktree: createdWorktree.worktree, shouldBootstrap: createdWorktree.shouldBootstrap, + slug: normalizedSlug, + worktreePath: createdWorktree.worktree.worktreePath, }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create worktree"; @@ -752,7 +760,7 @@ export async function handleWorkspaceSetupStatusRequest( }); } -export async function createPaseoWorktreeInBackground( +export async function runWorktreeSetupInBackground( dependencies: CreatePaseoWorktreeInBackgroundDependencies, options: { requestCwd: string; @@ -760,6 +768,8 @@ export async function createPaseoWorktreeInBackground( workspaceId: number; worktree: WorktreeConfig; shouldBootstrap: boolean; + slug: string; + worktreePath: string; }, ): Promise { let worktree: WorktreeConfig = options.worktree; @@ -774,7 +784,9 @@ export async function createPaseoWorktreeInBackground( detail: buildWorktreeSetupDetail({ worktree, results: - status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults, + status === "running" + ? getWorktreeSetupProgressResults(progressAccumulator) + : setupResults, outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, }), error, @@ -849,7 +861,6 @@ export async function createPaseoWorktreeInBackground( ); return; } - } finally { await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 1a3fcfaf1..8d72782fa 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({ @@ -916,27 +921,23 @@ export const ShutdownServerRequestMessageSchema = z.object({ requestId: z.string(), }); -export const AgentTimelineCursorSchema = z - .object({ - seq: z.number().int().nonnegative(), - // COMPAT(timeline): retain legacy cursor epoch for older clients. - epoch: z.string().optional(), - }) - ; +export const AgentTimelineCursorSchema = z.object({ + seq: z.number().int().nonnegative(), + // COMPAT(timeline): retain legacy cursor epoch for older clients. + epoch: z.string().optional(), +}); -export const FetchAgentTimelineRequestMessageSchema = z - .object({ - type: z.literal("fetch_agent_timeline_request"), - agentId: z.string(), - requestId: z.string(), - direction: z.enum(["tail", "before", "after"]).optional(), - cursor: AgentTimelineCursorSchema.optional(), - // 0 means "all matching rows for this query window". - limit: z.number().int().nonnegative().optional(), - // COMPAT(timeline): retain removed projection so older clients can still send it. - projection: z.enum(["canonical", "projected"]).optional(), - }) - ; +export const FetchAgentTimelineRequestMessageSchema = z.object({ + type: z.literal("fetch_agent_timeline_request"), + agentId: z.string(), + requestId: z.string(), + direction: z.enum(["tail", "before", "after"]).optional(), + cursor: AgentTimelineCursorSchema.optional(), + // 0 means "all matching rows for this query window". + limit: z.number().int().nonnegative().optional(), + // COMPAT(timeline): retain removed projection so older clients can still send it. + projection: z.enum(["canonical", "projected"]).optional(), +}); export const SetAgentModeRequestMessageSchema = z.object({ type: z.literal("set_agent_mode_request"), @@ -1232,7 +1233,6 @@ export const WorkspaceSetupStatusRequestSchema = z.object({ requestId: z.string(), }); - // TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids. export const LEGACY_EDITOR_TARGET_IDS = [ "cursor", @@ -2030,20 +2030,18 @@ export const AgentUpdateMessageSchema = z.object({ ]), }); -export const AgentStreamMessageSchema = z - .object({ - type: z.literal("agent_stream"), - payload: z.object({ - agentId: z.string(), - event: AgentStreamEventPayloadSchema, - timestamp: z.string(), - // Present only for committed timeline events. - seq: z.number().int().nonnegative().optional(), - // COMPAT(timeline): retain removed epoch for older clients. - epoch: z.string().optional(), - }), - }) - ; +export const AgentStreamMessageSchema = z.object({ + type: z.literal("agent_stream"), + payload: z.object({ + agentId: z.string(), + event: AgentStreamEventPayloadSchema, + timestamp: z.string(), + // Present only for committed timeline events. + seq: z.number().int().nonnegative().optional(), + // COMPAT(timeline): retain removed epoch for older clients. + epoch: z.string().optional(), + }), +}); export const AgentStatusMessageSchema = z.object({ type: z.literal("agent_status"), @@ -2233,40 +2231,38 @@ export const AgentTimelineEntryPayloadSchema = z }; }); -export const FetchAgentTimelineResponseMessageSchema = z - .object({ - type: z.literal("fetch_agent_timeline_response"), - payload: z - .object({ - requestId: z.string(), - agentId: z.string(), - agent: AgentSnapshotPayloadSchema.nullable(), - direction: z.enum(["tail", "before", "after"]), - startSeq: z.number().int().nonnegative().nullable().optional(), - endSeq: z.number().int().nonnegative().nullable().optional(), - hasOlder: z.boolean().optional(), - hasNewer: z.boolean().optional(), - entries: z.array(AgentTimelineEntryPayloadSchema), - error: z.string().nullable(), - // COMPAT(timeline): retain legacy response baggage for older clients. - epoch: z.string().optional(), - reset: z.boolean().optional(), - staleCursor: z.boolean().optional(), - gap: z.unknown().optional(), - window: z.unknown().optional(), - startCursor: AgentTimelineCursorSchema.nullable().optional(), - endCursor: AgentTimelineCursorSchema.nullable().optional(), - projection: z.enum(["canonical", "projected"]).optional(), - }) - .transform((payload) => ({ - ...payload, - startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null, - endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null, - hasOlder: payload.hasOlder ?? false, - hasNewer: payload.hasNewer ?? false, - })), - }) - ; +export const FetchAgentTimelineResponseMessageSchema = z.object({ + type: z.literal("fetch_agent_timeline_response"), + payload: z + .object({ + requestId: z.string(), + agentId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + direction: z.enum(["tail", "before", "after"]), + startSeq: z.number().int().nonnegative().nullable().optional(), + endSeq: z.number().int().nonnegative().nullable().optional(), + hasOlder: z.boolean().optional(), + hasNewer: z.boolean().optional(), + entries: z.array(AgentTimelineEntryPayloadSchema), + error: z.string().nullable(), + // COMPAT(timeline): retain legacy response baggage for older clients. + epoch: z.string().optional(), + reset: z.boolean().optional(), + staleCursor: z.boolean().optional(), + gap: z.unknown().optional(), + window: z.unknown().optional(), + startCursor: AgentTimelineCursorSchema.nullable().optional(), + endCursor: AgentTimelineCursorSchema.nullable().optional(), + projection: z.enum(["canonical", "projected"]).optional(), + }) + .transform((payload) => ({ + ...payload, + startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null, + endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null, + hasOlder: payload.hasOlder ?? false, + hasNewer: payload.hasNewer ?? false, + })), +}); export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), @@ -3274,7 +3270,9 @@ export type TerminalsChanged = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; export type StartWorkspaceScriptRequest = z.infer; -export type StartWorkspaceScriptResponse = z.infer; +export type StartWorkspaceScriptResponse = z.infer< + typeof StartWorkspaceScriptResponseMessageSchema +>; export type SubscribeTerminalRequest = z.infer; export type SubscribeTerminalResponse = z.infer; export type UnsubscribeTerminalRequest = z.infer; 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/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 52f76695e..c427f1108 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -301,7 +301,8 @@ describe("TerminalManager", () => { describe("subscribeTerminalsChanged", () => { it("emits cwd snapshots when terminals are created", async () => { manager = createTerminalManager(); - const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = []; + const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = + []; const unsubscribe = manager.subscribeTerminalsChanged((input) => { snapshots.push({ cwd: input.cwd, @@ -327,37 +328,33 @@ describe("TerminalManager", () => { unsubscribe(); }); - it( - "emits updated terminal titles after debounced title changes", - async () => { - await withShell("/bin/sh", async () => { - manager = createTerminalManager(); - const snapshots: Array> = []; - const unsubscribe = manager.subscribeTerminalsChanged((input) => { - snapshots.push( - input.terminals.map((terminal) => ({ - id: terminal.id, - ...(terminal.title ? { title: terminal.title } : {}), - })), - ); - }); - - const session = await manager.createTerminal({ cwd: "/tmp" }); - session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" }); - - await waitForCondition( - () => - snapshots.some((snapshot) => - snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"), - ), - 10000, + it("emits updated terminal titles after debounced title changes", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const snapshots: Array> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + ...(terminal.title ? { title: terminal.title } : {}), + })), ); - - unsubscribe(); }); - }, - 10000, - ); + + const session = await manager.createTerminal({ cwd: "/tmp" }); + session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" }); + + await waitForCondition( + () => + snapshots.some((snapshot) => + snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"), + ), + 10000, + ); + + unsubscribe(); + }); + }, 10000); it("emits empty snapshot when last terminal is removed", async () => { manager = createTerminalManager(); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 20ac2e438..167e039b0 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -125,7 +125,9 @@ describe("Terminal", () => { it("humanizes interpreter-backed package manager commands", () => { expect( - humanizeProcessTitle("/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev"), + humanizeProcessTitle( + "/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev", + ), ).toBe("npm run dev"); expect( humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"), @@ -361,7 +363,7 @@ describe("Terminal", () => { await createTerminal({ cwd: homeDir, command: "/bin/zsh", - args: ["-c", "printf '%s\\n%s\\n' \"${ZDOTDIR-}\" \"${PASEO_TEST_REAL_ZDOTDIR-}\""], + args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], env: { HOME: homeDir, ZDOTDIR: realZdotdir, @@ -453,8 +455,7 @@ describe("Terminal", () => { await waitForLines(session, ["$"]); session.send({ type: "input", - data: - "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", + data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", }); await waitForTitle(session, (title) => title === "Final"); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index e75b4b08f..7b0141a76 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -158,7 +158,9 @@ export function resolveZshShellIntegrationDir(): string { return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url)); } -export function buildTerminalEnvironment(input: BuildTerminalEnvironmentInput): Record { +export function buildTerminalEnvironment( + input: BuildTerminalEnvironmentInput, +): Record { const baseEnv: Record = { ...process.env, ...input.env, @@ -433,10 +435,7 @@ function extractLastOutputLines(terminal: TerminalType, limit: number): string[] } function stripAnsiSequences(input: string): string { - return input.replace( - /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1b\\))/g, - "", - ); + return input.replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1b\\))/g, ""); } function extractLastOutputLinesFromText(text: string, limit: number): string[] { @@ -577,8 +576,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { 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 e2781e0b9..2ea834458 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,6 +1,3 @@ -import { exec, execFile } from "child_process"; -import { spawnProcess } from "./spawn.js"; -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"; @@ -9,17 +6,15 @@ import { z } from "zod"; 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 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; const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000; @@ -97,91 +92,6 @@ export function __setCheckoutShortstatCacheTtlForTests(ttlMs: number): void { shortstatInFlight.clear(); } -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; @@ -221,8 +131,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 @@ -324,17 +239,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") @@ -371,10 +281,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()) @@ -409,10 +322,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 { @@ -422,7 +334,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, }); @@ -462,20 +374,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); @@ -671,14 +584,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, }); @@ -688,10 +601,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 { @@ -700,8 +616,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(); @@ -711,7 +627,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, }); @@ -777,7 +693,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, }); @@ -800,8 +716,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); @@ -834,7 +751,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, }); @@ -843,7 +760,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, }); @@ -861,7 +778,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, }); @@ -884,7 +801,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 } @@ -892,7 +809,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 } @@ -901,10 +818,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") @@ -914,7 +834,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, }); @@ -927,7 +847,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, }); @@ -956,7 +876,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, }); @@ -987,8 +907,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+/); @@ -1014,8 +934,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+/); @@ -1032,15 +952,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) { @@ -1695,10 +1617,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, }); } @@ -1737,16 +1660,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 = @@ -1755,9 +1685,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") @@ -1781,7 +1711,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 } @@ -1802,7 +1732,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 } @@ -1832,7 +1765,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, }); @@ -1848,7 +1781,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 @@ -1856,9 +1789,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") @@ -1882,7 +1815,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 } @@ -1914,7 +1847,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; @@ -1931,7 +1864,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 { @@ -2016,7 +1949,6 @@ const ReviewDecisionSchema = z type CheckRunNode = z.infer; type StatusContextNode = z.infer; - export async function resolveGhPath(): Promise { if (cachedGhPath === undefined) { cachedGhPath = await findExecutable("gh"); @@ -2048,10 +1980,7 @@ function isGhAuthError(error: unknown): boolean { ); } -function mapCheckRunStatus( - status: unknown, - conclusion: unknown, -): PullRequestCheck["status"] { +function mapCheckRunStatus(status: unknown, conclusion: unknown): PullRequestCheck["status"] { if (status !== "COMPLETED") { return "pending"; } @@ -2203,7 +2132,7 @@ function mapReviewDecision(value: unknown): ReviewDecision { 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, }); @@ -2265,7 +2194,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}`]; @@ -2274,7 +2203,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"); @@ -2326,7 +2255,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise { 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 31bbfb2bf..92481a8e3 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -1,78 +1,213 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test, vi } from "vitest"; -import { findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; +type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => 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; + const missingBinaryName = "nonexistent-binary-xyz-12345"; -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(); - expect(warnSpy).toHaveBeenCalledOnce(); - - warnSpy.mockRestore(); + await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd"); }); - test("returns direct paths when they exist", () => { - findExecutableDependencies.existsSync.mockReturnValue(true); + 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"), + "", + ); + }, + }); - expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe( - "/usr/local/bin/codex", + 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(missingBinaryName)).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( + process.platform === "win32" ? "where.exe" : "which", + ["codex"], + process.platform === "win32" ? { encoding: "utf8", windowsHide: true } : { encoding: "utf8" }, + expect.any(Function), ); - expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex"); + }); + + 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(); + + warnSpy.mockRestore(); + }, + ); + + test("returns null when which lookup fails", async () => { + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(new Error("which failed"), "", ""); + }, + }); + + await expect(findExecutable(missingBinaryName)).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 +222,71 @@ 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("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(); expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); }); }); @@ -123,27 +302,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..8ed93e382 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; @@ -138,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 @@ -145,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); } /** @@ -157,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/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/script-hostname.ts b/packages/server/src/utils/script-hostname.ts index c923f6cd1..332908369 100644 --- a/packages/server/src/utils/script-hostname.ts +++ b/packages/server/src/utils/script-hostname.ts @@ -2,8 +2,7 @@ import { slugify } from "./worktree.js"; export function buildScriptHostname(branchName: string | null, scriptName: string): string { const serviceHostnameLabel = slugify(scriptName); - const isDefaultBranch = - branchName === null || branchName === "main" || branchName === "master"; + const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master"; if (isDefaultBranch) { return `${serviceHostnameLabel}.localhost`; diff --git a/packages/server/src/utils/spawn.test.ts b/packages/server/src/utils/spawn.test.ts new file mode 100644 index 000000000..117a57edb --- /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 () => { + const result = await execCommand("echo", ["hello"]); + + expect(result.stdout.trim()).toBe("hello"); + expect(result.stderr).toBe(""); + }); + + 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: [] }; + + 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/spawn.ts b/packages/server/src/utils/spawn.ts index 32c1d911d..bab7b047a 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,15 +1,23 @@ -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"; + +import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; + +const execFileAsync = promisify(execFile); + +interface ExecCommandOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + encoding?: BufferEncoding; + timeout?: number; + maxBuffer?: number; +} + +interface ExecCommandResult { + stdout: string; + stderr: string; +} -/** - * 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[], @@ -17,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, @@ -27,12 +35,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 ? quoteWindowsCommand(command) : command; + const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : 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-shell-selection.test.ts b/packages/server/src/utils/worktree-shell-selection.test.ts index bf9d0a41e..9702071ae 100644 --- a/packages/server/src/utils/worktree-shell-selection.test.ts +++ b/packages/server/src/utils/worktree-shell-selection.test.ts @@ -19,7 +19,12 @@ describe("worktree shell selection", () => { beforeEach(() => { execFileMock.mockReset(); execFileMock.mockImplementation( - (_file: string, _args: string[], _options: unknown, callback?: (error: Error | null, stdout: string, stderr: string) => void) => { + ( + _file: string, + _args: string[], + _options: unknown, + callback?: (error: Error | null, stdout: string, stderr: string) => void, + ) => { callback?.(null, "", ""); return {}; }, diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index fdd7d503b..9d8b88d56 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -15,12 +15,20 @@ 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"; -describe("createWorktree", () => { +describe.skipIf(process.platform === "win32")("createWorktree", () => { let tempDir: string; let repoDir: string; let paseoHome: string; @@ -32,13 +40,13 @@ 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 }); - 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(() => { @@ -70,13 +78,13 @@ 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 }); - 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", @@ -120,7 +128,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 }); @@ -157,19 +165,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 }); @@ -188,7 +196,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", @@ -280,7 +288,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, }); @@ -314,7 +322,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, }); @@ -338,7 +346,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, }); @@ -428,7 +436,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, }); @@ -581,13 +589,13 @@ 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 }); - 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(() => { @@ -599,13 +607,13 @@ 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 }); - 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({ @@ -671,7 +679,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); @@ -694,7 +702,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, }, @@ -733,7 +741,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 }, ); @@ -762,7 +770,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 }, ); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index be9a5c9be..588a8f805 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,4 +1,4 @@ -import { exec, execFile, spawn } from "child_process"; +import { execFile, spawn } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; @@ -15,6 +15,7 @@ import { writePaseoWorktreeMetadata, writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; +import { runGitCommand } from "./run-git-command.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js"; @@ -34,7 +35,6 @@ interface PaseoConfig { >; } -const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, @@ -255,7 +255,11 @@ export function getScriptConfigs(repoRoot: string): Map { } : { command }; - if (isServiceScript(scriptConfig) && typeof entry.port === "number" && Number.isFinite(entry.port)) { + if ( + isServiceScript(scriptConfig) && + typeof entry.port === "number" && + Number.isFinite(entry.port) + ) { scriptConfig.port = entry.port; } @@ -325,6 +329,7 @@ async function execSetupCommand( const { stdout, stderr } = await execFileAsync(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd, env: options.env, + ...(process.platform === "win32" ? {} : { shell: "/bin/bash" }), }); return { command, @@ -525,10 +530,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); @@ -586,8 +594,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 }); @@ -605,7 +614,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, }); @@ -708,10 +717,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"); @@ -941,7 +953,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, }); @@ -972,10 +984,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 { @@ -1038,8 +1053,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)) { @@ -1075,11 +1091,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}`); } @@ -1094,7 +1110,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; @@ -1111,7 +1129,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++; @@ -1128,8 +1148,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 }); 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": { diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 074e2e9db..2d418f4cb 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -629,7 +629,17 @@ function ServiceProxySection() {
{/* Project */}
- + my-app @@ -638,18 +648,13 @@ function ServiceProxySection() { {/* Workspaces indented */}
{workspaces.map((ws) => ( -
+
{ws.name} npm run dev
- - {ws.url} - + {ws.url}
))}
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