Compare commits

..
89 changed files with 3708 additions and 5051 deletions
-5
View File
@@ -5,13 +5,8 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
workflow_dispatch: workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-32
View File
@@ -1,36 +1,5 @@
# Changelog # Changelog
## 0.1.74 - 2026-05-11
### Fixed
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
## 0.1.73 - 2026-05-10
### Fixed
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
## 0.1.72 - 2026-05-10
### Fixed
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
## 0.1.71 - 2026-05-09 ## 0.1.71 - 2026-05-09
### Added ### Added
@@ -54,7 +23,6 @@
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831)) - iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830)) - System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link. - Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
### Security ### Security
-49
View File
@@ -1,49 +0,0 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
+20 -23
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.74", "version": "0.1.71",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.74", "version": "0.1.71",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -9397,6 +9397,12 @@
"node": ">=20.0" "node": ">=20.0"
} }
}, },
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": { "node_modules/@opentelemetry/api": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -38848,7 +38854,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.74", "version": "0.1.71",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -38974,10 +38980,10 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.74", "version": "0.1.71",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.74", "@getpaseo/server": "0.1.71",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -39020,7 +39026,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.74", "version": "0.1.71",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -39069,7 +39075,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.74", "version": "0.1.71",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
@@ -39105,7 +39111,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.74", "version": "0.1.71",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -39131,7 +39137,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.74", "version": "0.1.71",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -39146,18 +39152,18 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.74", "version": "0.1.71",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133", "@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.74", "@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.74", "@getpaseo/relay": "0.1.71",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2", "@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0", "@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
@@ -39338,15 +39344,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"packages/server/node_modules/@opencode-ai/sdk": {
"version": "1.14.46",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
"integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/accepts": { "packages/server/node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -39694,7 +39691,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.74", "version": "0.1.71",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.74", "version": "0.1.71",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API", "description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [ "keywords": [
+2 -2
View File
@@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
} }
export async function openMobileAgentSidebar(page: Page): Promise<void> { export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click(); await page.getByTestId("menu-button").click();
} }
// force=true: the overlay covers the button when the mobile sidebar is open. // force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> { export async function closeMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Close menu" }).click({ force: true }); await page.getByTestId("menu-button").click({ force: true });
} }
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. // The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.74", "version": "0.1.71",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -19,7 +19,7 @@ import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { isNative, isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
type EscHandler = () => void; type EscHandler = () => void;
const escStack: EscHandler[] = []; const escStack: EscHandler[] = [];
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) { function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
if (isMobile && isNative) { if (isMobile) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />; return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
} }
@@ -12,7 +12,7 @@ import {
import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
@@ -443,7 +443,7 @@ function ProviderSearchInput({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput; const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {}; if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
+2 -2
View File
@@ -38,7 +38,7 @@ import {
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isNative, isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
@@ -148,7 +148,7 @@ export function SearchInput({
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput; const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
});
});
});
@@ -1,85 +0,0 @@
import type { PaseoConfigRaw } from "@server/shared/messages";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
interface ReadProjectConfigResult {
ok: boolean;
config?: PaseoConfigRaw | null;
}
export interface WorktreeSetupCalloutPolicy {
id: string;
dismissalKey: string;
priority: number;
title: string;
description: string;
actionLabel: string;
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
testID: string;
}
export function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorktreeSetupWorkspaceInput,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
}
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
@@ -0,0 +1,279 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
const activeSelection = vi.hoisted(() => ({
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
serverId: string;
workspaceId: string;
} | null,
}));
const activeWorkspace = vi.hoisted(() => ({
value: {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
} as Record<string, unknown> | null,
}));
const client = vi.hoisted(() => ({
readProjectConfig: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceFields: (
serverId: string | null,
workspaceId: string | null,
project: (workspace: Record<string, unknown>) => unknown,
) => {
if (
!activeWorkspace.value ||
serverId !== activeSelection.value?.serverId ||
workspaceId !== activeWorkspace.value.id
) {
return null;
}
return project(activeWorkspace.value);
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
function readOk(config: Record<string, unknown>) {
return {
ok: true,
config,
revision: { exists: true, mtimeMs: 1, size: 2 },
};
}
function readError() {
return {
ok: false,
error: { code: "project_not_found", message: "Project not found" },
};
}
function Harness({ queryClient }: { queryClient: QueryClient }) {
return (
<QueryClientProvider client={queryClient}>
<SidebarCalloutProvider>
<WorktreeSetupCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
</QueryClientProvider>
);
}
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
await act(async () => {
root.render(<Harness queryClient={queryClient} />);
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
async function findByTestId(testID: string): Promise<HTMLElement | null> {
let element: HTMLElement | null = null;
for (let index = 0; index < 10 && !element; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
}
return element;
}
describe("WorktreeSetupCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
};
client.readProjectConfig.mockReset();
client.readProjectConfig.mockResolvedValue(readOk({}));
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
}
queryClient?.clear();
queryClient = null;
root = null;
container?.remove();
container = null;
});
it("registers a callout for an active git workspace with missing setup", async () => {
await renderHarness(root!, queryClient!);
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
expect(container?.textContent).toContain("Set up worktree scripts");
expect(container?.textContent).toContain("Open project settings");
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
});
it("does not register a callout for a non-git workspace", async () => {
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "local",
projectRootPath: "/repo/project-1",
};
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when setup is present", async () => {
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("does not register a callout without an active workspace", async () => {
activeSelection.value = null;
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when reading paseo.json fails", async () => {
client.readProjectConfig.mockResolvedValue(readError());
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("opens project settings from the callout action", async () => {
await renderHarness(root!, queryClient!);
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
});
it("persists dismissal for the project", async () => {
await renderHarness(root!, queryClient!);
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["worktree-setup-missing:project-1"]),
);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
});
@@ -1,16 +1,48 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { import type { WorkspaceDescriptor } from "@/stores/session-store";
buildWorktreeSetupCalloutPolicy, import { buildProjectSettingsRoute } from "@/utils/host-routes";
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout, interface ActiveGitWorkspaceProject {
} from "./worktree-setup-callout-policy"; serverId: string;
projectKey: string;
repoRoot: string;
}
function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorkspaceDescriptor,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
export function WorktreeSetupCalloutSource() { export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
@@ -26,7 +58,7 @@ export function WorktreeSetupCalloutSource() {
if (!activeProject) { if (!activeProject) {
return; return;
} }
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute); router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
}); });
const readQuery = useQuery({ const readQuery = useQuery({
@@ -41,31 +73,29 @@ export function WorktreeSetupCalloutSource() {
retry: false, retry: false,
}); });
const calloutPolicy = useMemo( const shouldShow =
() => activeProject !== null &&
activeProject && shouldShowWorktreeSetupCallout(readQuery.data) readQuery.data?.ok === true &&
? buildWorktreeSetupCalloutPolicy(activeProject) !hasSetupCommands(readQuery.data.config ?? {});
: null,
[activeProject, readQuery.data],
);
useEffect(() => { useEffect(() => {
if (!calloutPolicy) { if (!shouldShow || !activeProject) {
return; return;
} }
return callouts.show({ return callouts.show({
id: calloutPolicy.id, id: `worktree-setup-missing:${activeProject.projectKey}`,
dismissalKey: calloutPolicy.dismissalKey, dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
priority: calloutPolicy.priority, priority: 100,
title: calloutPolicy.title, title: "Set up worktree scripts",
description: calloutPolicy.description, description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actions: [ actions: [
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" }, { label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
], ],
testID: calloutPolicy.testID, testID: `worktree-setup-callout-${activeProject.projectKey}`,
}); });
}, [calloutPolicy, callouts, openProjectSettings]); }, [activeProject, callouts, openProjectSettings, shouldShow]);
return null; return null;
} }
@@ -0,0 +1,224 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import {
SidebarCalloutProvider,
type SidebarCalloutsApi,
SidebarCalloutViewport,
useSidebarCallouts,
} from "./sidebar-callout-context";
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
function handleApi(nextApi: SidebarCalloutsApi): void {
apiSink.current = nextApi;
}
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
const api = useSidebarCallouts();
onApi(api);
return null;
}
describe("SidebarCalloutProvider", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let api: SidebarCalloutsApi | null = null;
beforeEach(async () => {
api = null;
apiSink.current = null;
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
api = apiSink.current;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
api = null;
});
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
act(() => {
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
api?.show({ id: "update", priority: 200, title: "Update available" });
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).not.toContain("Set up scripts");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).toContain("Set up scripts");
expect(container?.textContent).not.toContain("Update available");
});
it("replaces a callout by id without duplicating the queue item", () => {
act(() => {
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
});
expect(container?.textContent).toContain("New daemon");
expect(container?.textContent).toContain("v2");
expect(container?.textContent).not.toContain("Old daemon");
});
it("keeps API consumers from rerendering when callout state changes", () => {
const renders = vi.fn();
function Producer() {
const callouts = useSidebarCallouts();
renders(callouts);
useEffect(() => {
callouts.show({ id: "initial", title: "Initial" });
}, [callouts]);
return null;
}
act(() => {
root?.render(
<SidebarCalloutProvider>
<Producer />
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
});
api = apiSink.current;
const firstApi = renders.mock.calls[0]?.[0];
act(() => {
api?.show({ id: "later", priority: 10, title: "Later" });
});
expect(renders).toHaveBeenCalledTimes(1);
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
});
it("unregisters only the registration returned by show", () => {
let unregisterOld: (() => void) | null = null;
act(() => {
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
api?.show({ id: "update", title: "New" });
});
act(() => {
unregisterOld?.();
});
expect(container?.textContent).toContain("New");
});
it("persists dismissals by dismissal key", () => {
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
});
});
expect(container?.textContent).toContain("Update available");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).not.toContain("Update available");
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["desktop-update:available:1.2.3"]),
);
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
});
});
expect(container?.textContent).not.toContain("Dismissed update");
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
});
});
expect(container?.textContent).toContain("New update");
});
});
@@ -8,24 +8,27 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
import { useStableEvent } from "@/hooks/use-stable-event";
import { import {
clearSidebarCallouts, SidebarCallout,
createSidebarCalloutState, type SidebarCalloutAction,
dismissSidebarCallout, type SidebarCalloutProps,
loadDismissedCalloutKeys, type SidebarCalloutVariant,
parseDismissedCalloutKeys, } from "@/components/sidebar-callout";
selectActiveSidebarCallout, import { useStableEvent } from "@/hooks/use-stable-event";
serializeDismissedCalloutKeys,
showSidebarCallout,
type SidebarCalloutEntry,
type SidebarCalloutOptions,
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
export type { SidebarCalloutOptions } from "./sidebar-callout-state"; export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutsApi { export interface SidebarCalloutsApi {
show: (callout: SidebarCalloutOptions) => () => void; show: (callout: SidebarCalloutOptions) => () => void;
@@ -33,48 +36,96 @@ export interface SidebarCalloutsApi {
clear: () => void; clear: () => void;
} }
type SidebarCalloutEntry = SidebarCalloutOptions & {
order: number;
priority: number;
token: number;
};
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals"; const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null); const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null); const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void { function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem( void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
DISMISSED_CALLOUTS_STORAGE_KEY, (error) => {
serializeDismissedCalloutKeys(keys), console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
).catch((error) => { },
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error); );
}
function selectActiveCallout(input: {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
}): SidebarCalloutEntry | null {
const visibleCallouts = input.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
}); });
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
} }
export function SidebarCalloutProvider({ children }: { children: ReactNode }) { export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState); const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
const stateRef = useRef<SidebarCalloutState>(state); const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
function commitState(next: SidebarCalloutState): void { const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
stateRef.current = next; const dismissedKeysRef = useRef<Set<string>>(new Set());
setState(next); const orderRef = useRef(0);
} const tokenRef = useRef(0);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
async function loadDismissedKeys(): Promise<void> { .then((value) => {
let dismissedKeys: ReadonlySet<string>; if (!mounted) {
try { return;
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY); }
dismissedKeys = parseDismissedCalloutKeys(value); const nextKeys = parseDismissedCalloutKeys(value);
} catch (error) { dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
return;
})
.catch((error) => {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error); console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
dismissedKeys = stateRef.current.dismissedKeys; })
} .finally(() => {
if (mounted) {
if (mounted) { setDismissalStorageLoaded(true);
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys)); }
} });
}
void loadDismissedKeys();
return () => { return () => {
mounted = false; mounted = false;
@@ -82,33 +133,60 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const show = useStableEvent((callout: SidebarCalloutOptions) => { const show = useStableEvent((callout: SidebarCalloutOptions) => {
const result = showSidebarCallout(stateRef.current, callout); tokenRef.current += 1;
commitState(result.state); const token = tokenRef.current;
const current = calloutsRef.current;
const existing = current.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? ++orderRef.current,
token,
};
const next = existing
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...current, nextEntry];
calloutsRef.current = next;
setCallouts(next);
return () => { return () => {
commitState( const updated = calloutsRef.current.filter(
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }), (entry) => entry.id !== callout.id || entry.token !== token,
); );
calloutsRef.current = updated;
setCallouts(updated);
}; };
}); });
const dismiss = useStableEvent((id: string) => { const dismiss = useStableEvent((id: string) => {
const result = dismissSidebarCallout(stateRef.current, id); const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
commitState(result.state); const next = calloutsRef.current.filter((entry) => entry.id !== id);
calloutsRef.current = next;
setCallouts(next);
if (result.dismissalKey) { const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
persistDismissedCalloutKeys(result.state.dismissedKeys); if (dismissalKey) {
const nextKeys = new Set(dismissedKeysRef.current);
nextKeys.add(dismissalKey);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
persistDismissedCalloutKeys(nextKeys);
} }
result.dismissedCallout?.onDismiss?.(); dismissed?.onDismiss?.();
}); });
const clear = useStableEvent(() => { const clear = useStableEvent(() => {
commitState(clearSidebarCallouts(stateRef.current)); calloutsRef.current = [];
setCallouts([]);
}); });
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]); const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]); const activeCallout = useMemo(
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
[callouts, dismissedKeys, dismissalStorageLoaded],
);
return ( return (
<SidebarCalloutApiContext.Provider value={api}> <SidebarCalloutApiContext.Provider value={api}>
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
clearSidebarCallouts,
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
describe("sidebar callout state", () => {
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "onboarding",
priority: 10,
title: "Set up scripts",
}).state;
state = showSidebarCallout(state, {
id: "update",
priority: 200,
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
state = dismissSidebarCallout(state, "update").state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
});
it("replaces a callout by id without duplicating the queue item", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "daemon",
title: "Old daemon",
description: "v1",
}).state;
state = showSidebarCallout(state, {
id: "daemon",
title: "New daemon",
description: "v2",
}).state;
expect(state.callouts).toMatchObject([
{
id: "daemon",
title: "New daemon",
description: "v2",
},
]);
});
it("unregisters only the registration returned by show", () => {
let state = createSidebarCalloutState();
const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" });
state = oldRegistration.state;
state = showSidebarCallout(state, { id: "update", title: "New" }).state;
state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token });
expect(selectActiveSidebarCallout(state)?.title).toBe("New");
});
it("persists dismissals by dismissal key and hides matching future callouts", () => {
const onDismiss = vi.fn();
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set());
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
onDismiss,
}).state;
const result = dismissSidebarCallout(state, "update");
state = result.state;
expect(result.dismissalKey).toBe("desktop-update:available:1.2.3");
expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe(
JSON.stringify(["desktop-update:available:1.2.3"]),
);
expect(onDismiss).not.toHaveBeenCalled();
result.dismissedCallout?.onDismiss?.();
expect(onDismiss).toHaveBeenCalledOnce();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("New update");
});
it("waits for dismissal storage before showing dismissible callouts", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = loadDismissedCalloutKeys(state, new Set());
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
});
it("parses stored dismissal keys defensively", () => {
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
});
it("clears visible callouts without dropping dismissal state", () => {
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
state = clearSidebarCallouts(state);
expect(state.callouts).toEqual([]);
expect(state.dismissedKeys).toEqual(new Set(["dismissed"]));
});
});
@@ -1,162 +0,0 @@
import type { ReactNode } from "react";
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
order: number;
priority: number;
token: number;
}
export interface SidebarCalloutState {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
nextOrder: number;
nextToken: number;
}
export function createSidebarCalloutState(): SidebarCalloutState {
return {
callouts: [],
dismissedKeys: new Set(),
dismissalStorageLoaded: false,
nextOrder: 0,
nextToken: 0,
};
}
export function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
return JSON.stringify([...keys]);
}
export function loadDismissedCalloutKeys(
state: SidebarCalloutState,
dismissedKeys: ReadonlySet<string>,
): SidebarCalloutState {
return {
...state,
dismissedKeys: new Set(dismissedKeys),
dismissalStorageLoaded: true,
};
}
export function showSidebarCallout(
state: SidebarCalloutState,
callout: SidebarCalloutOptions,
): { state: SidebarCalloutState; token: number } {
const token = state.nextToken + 1;
const existing = state.callouts.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? state.nextOrder + 1,
token,
};
const callouts = existing
? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...state.callouts, nextEntry];
return {
state: {
...state,
callouts,
nextOrder: existing ? state.nextOrder : state.nextOrder + 1,
nextToken: token,
},
token,
};
}
export function unregisterSidebarCallout(
state: SidebarCalloutState,
input: { id: string; token: number },
): SidebarCalloutState {
const callouts = state.callouts.filter(
(entry) => entry.id !== input.id || entry.token !== input.token,
);
return callouts.length === state.callouts.length ? state : { ...state, callouts };
}
export function dismissSidebarCallout(
state: SidebarCalloutState,
id: string,
): {
state: SidebarCalloutState;
dismissedCallout: SidebarCalloutEntry | null;
dismissalKey: string | null;
} {
const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null;
const callouts = state.callouts.filter((entry) => entry.id !== id);
const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey);
const dismissedKeys = dismissalKey
? new Set([...state.dismissedKeys, dismissalKey])
: state.dismissedKeys;
return {
state: {
...state,
callouts,
dismissedKeys,
},
dismissedCallout,
dismissalKey,
};
}
export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState {
return { ...state, callouts: [] };
}
export function selectActiveSidebarCallout(
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}
@@ -148,16 +148,9 @@ describe("keyboard-shortcuts", () => {
payload: { index: 2 }, payload: { index: 2 },
}, },
{ {
name: "matches tab index jump on mac desktop via Cmd+Alt+digit", name: "matches tab index jump on desktop via Alt+digit",
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
context: { isMac: true, isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on non-mac desktop via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true }, event: { key: "2", code: "Digit2", altKey: true },
context: { isMac: false, isDesktop: true }, context: { isDesktop: true },
action: "workspace.tab.navigate.index", action: "workspace.tab.navigate.index",
payload: { index: 2 }, payload: { index: 2 },
}, },
@@ -340,11 +333,6 @@ describe("keyboard-shortcuts", () => {
event: { key: "t", code: "KeyT", ctrlKey: true }, event: { key: "t", code: "KeyT", ctrlKey: true },
context: { isMac: true }, context: { isMac: true },
}, },
{
name: "keeps mac Option+digit available for international text input",
event: { key: "@", code: "Digit2", altKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
},
{ {
name: "does not match Ctrl+K for command center on non-mac in terminal", name: "does not match Ctrl+K for command center on non-mac in terminal",
event: { key: "k", code: "KeyK", ctrlKey: true }, event: { key: "k", code: "KeyK", ctrlKey: true },
@@ -489,17 +477,16 @@ describe("keyboard-shortcut help sections", () => {
"new-agent": ["mod", "shift", "O"], "new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"], "workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"], "workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"], "workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"], "workspace-tab-close-current": ["meta", "W"],
"workspace-pane-split-right": ["mod", "\\"], "workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"], "workspace-pane-close": ["mod", "shift", "W"],
}, },
}, },
{ {
name: "uses non-mac desktop defaults for tab jump and close tab", name: "shows Ctrl+W close tab for non-mac desktop",
context: { isMac: false, isDesktop: true }, context: { isMac: false, isDesktop: true },
expectedKeys: { expectedKeys: {
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["ctrl", "W"], "workspace-tab-close-current": ["ctrl", "W"],
}, },
}, },
@@ -292,24 +292,11 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
}, },
// --- Tab index jump --- // --- Tab index jump ---
{
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
action: "workspace.tab.navigate.index",
combo: "Cmd+Alt+Digit",
when: { mac: true, desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
label: "Jump to tab",
keys: ["mod", "alt", "1-9"],
},
},
{ {
id: "workspace-tab-navigate-index-alt-digit-desktop", id: "workspace-tab-navigate-index-alt-digit-desktop",
action: "workspace.tab.navigate.index", action: "workspace.tab.navigate.index",
combo: "Alt+Digit", combo: "Alt+Digit",
when: { mac: false, desktop: true, commandCenter: false }, when: { desktop: true, commandCenter: false },
payload: { type: "index" }, payload: { type: "index" },
help: { help: {
id: "workspace-tab-jump-index", id: "workspace-tab-jump-index",
-40
View File
@@ -1,40 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const expoCryptoMock = vi.hoisted(() => ({
getRandomValues: vi.fn(<T extends ArrayBufferView>(array: T): T => array),
randomUUID: vi.fn(() => {
throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback");
}),
}));
vi.mock("expo-crypto", () => expoCryptoMock);
describe("polyfillCrypto", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
expoCryptoMock.getRandomValues.mockClear();
expoCryptoMock.randomUUID.mockClear();
});
it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => {
const sourceBytes = Uint8Array.from([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
0xff,
]);
const getRandomValues = vi.fn(<T extends ArrayBufferView | null>(array: T): T => {
if (array && ArrayBuffer.isView(array)) {
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes);
}
return array;
});
vi.stubGlobal("crypto", { getRandomValues });
const { polyfillCrypto } = await import("./crypto");
polyfillCrypto();
expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff");
expect(getRandomValues).toHaveBeenCalledTimes(1);
expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled();
});
});
+8 -30
View File
@@ -13,26 +13,8 @@ interface MutableGlobal {
crypto?: Crypto; crypto?: Crypto;
} }
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
const bytes = fillRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
}
export function polyfillCrypto(): void { export function polyfillCrypto(): void {
const g = globalThis as unknown as MutableGlobal; const g = globalThis as unknown as MutableGlobal;
const nativeGetRandomValues =
typeof g.crypto?.getRandomValues === "function"
? g.crypto.getRandomValues.bind(g.crypto)
: null;
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport). // Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
// Hermes may not provide them in all configurations. // Hermes may not provide them in all configurations.
@@ -65,21 +47,17 @@ export function polyfillCrypto(): void {
g.crypto = {} as Crypto; g.crypto = {} as Crypto;
} }
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
if (nativeGetRandomValues) {
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
}
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
if (typeof g.crypto.randomUUID !== "function") { if (typeof g.crypto.randomUUID !== "function") {
g.crypto.randomUUID = () => createUuidV4(fillRandomValues); g.crypto.randomUUID = () =>
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
} }
if (typeof g.crypto.getRandomValues !== "function") { if (typeof g.crypto.getRandomValues !== "function") {
g.crypto.getRandomValues = fillRandomValues; g.crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
} }
} }
@@ -203,15 +203,6 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
}; };
} }
function encodeOfferUrl(payload: unknown): string {
const encoded = Buffer.from(JSON.stringify(payload), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
return `https://app.paseo.sh/#offer=${encoded}`;
}
function makeDeps( function makeDeps(
latencyByConnectionId: Record<string, number | Error>, latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[], createdClients: FakeDaemonClient[],
@@ -1743,41 +1734,6 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]); store.syncHosts([]);
}); });
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const oldPairingUrl = encodeOfferUrl({
v: 2,
serverId: "srv_offer",
daemonPublicKeyB64: "pk_test_offer",
relay: { endpoint: "relay.paseo.sh:443" },
});
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
{
id: "relay:wss:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_test_offer",
},
]);
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => { it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({ const store = new HostRuntimeStore({
deps: { deps: {
+1 -3
View File
@@ -1506,12 +1506,10 @@ export class HostRuntimeStore {
} }
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> { async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
return this.upsertRelayConnection({ return this.upsertRelayConnection({
serverId: offer.serverId, serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint, relayEndpoint: offer.relay.endpoint,
useTls, useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
label, label,
}); });
@@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import {
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
upsertCreatedTerminalPayload,
type ListTerminalsPayload,
} from "@/screens/workspace/terminals/state";
import type { CreateTerminalResponse } from "@server/shared/messages";
function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] {
return { id, name: id, title: id };
}
function createdTerminal(id: string): NonNullable<CreateTerminalResponse["payload"]["terminal"]> {
return { id, name: id, cwd: "/repo", title: id };
}
describe("workspace terminal state", () => {
it("keeps pending script terminals until they appear or a fresher list arrives", () => {
const pending = new Map([
["older-than-list", 10],
["now-live", 20],
["still-pending", 30],
]);
const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending);
expect(reconciled).toEqual(new Map([["still-pending", 30]]));
});
it("returns the same pending map when reconciliation changes nothing", () => {
const pending = new Map([["still-pending", 30]]);
const reconciled = reconcilePendingScriptTerminals([], 20)(pending);
expect(reconciled).toBe(pending);
});
it("combines live and pending terminal ids without duplicating script terminals", () => {
const pendingScriptTerminalIds = new Map([
["script-pending", 10],
["terminal-1", 10],
]);
expect(
collectKnownTerminalIds({
liveTerminalIds: ["terminal-1", "terminal-2"],
pendingScriptTerminalIds,
}),
).toEqual(["terminal-1", "terminal-2", "script-pending"]);
expect(
collectScriptTerminalIds({
pendingScriptTerminalIds,
scripts: [{ terminalId: "script-live" }, { terminalId: null }],
}),
).toEqual(new Set(["script-pending", "terminal-1", "script-live"]));
expect(
collectStandaloneTerminalIds({
terminals: [
listedTerminal("terminal-1"),
listedTerminal("terminal-2"),
listedTerminal("script-live"),
],
scriptTerminalIds: new Set(["terminal-1", "script-live"]),
}),
).toEqual(["terminal-2"]);
});
it("updates terminal cache entries for created and closed terminals", () => {
const current: ListTerminalsPayload = {
cwd: "/repo",
requestId: "existing",
terminals: [listedTerminal("terminal-1")],
};
expect(
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal("terminal-2"),
workspaceDirectory: "/repo",
}),
).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [
listedTerminal("terminal-1"),
{ id: "terminal-2", name: "terminal-2", title: "terminal-2" },
],
});
expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [],
});
});
});
@@ -1,106 +0,0 @@
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
export const TERMINALS_QUERY_STALE_TIME = 5_000;
export type ListTerminalsPayload = ListTerminalsResponse["payload"];
type TerminalEntry = ListTerminalsPayload["terminals"][number];
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
return ["terminals", serverId, workspaceDirectory] as const;
}
export function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
export function collectKnownTerminalIds(input: {
liveTerminalIds: string[];
pendingScriptTerminalIds: Map<string, number>;
}): string[] {
const terminalIds = new Set(input.liveTerminalIds);
for (const terminalId of input.pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}
export function collectScriptTerminalIds(input: {
pendingScriptTerminalIds: Map<string, number>;
scripts: Array<{ terminalId?: string | null }>;
}): Set<string> {
const terminalIds = new Set(input.pendingScriptTerminalIds.keys());
for (const script of input.scripts) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}
export function collectStandaloneTerminalIds(input: {
terminals: TerminalEntry[];
scriptTerminalIds: Set<string>;
}): string[] {
return input.terminals
.filter((terminal) => !input.scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id);
}
export function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
export function upsertCreatedTerminalPayload(input: {
current: ListTerminalsPayload | undefined;
terminal: CreatedTerminal;
workspaceDirectory: string | null;
}): ListTerminalsPayload {
const nextTerminals = upsertTerminalListEntry({
terminals: input.current?.terminals ?? [],
terminal: input.terminal,
});
const cwd = input.current?.cwd ?? input.workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`,
};
}
@@ -1,281 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildTerminalsQueryKey,
canCreateWorkspaceTerminal,
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
TERMINALS_QUERY_STALE_TIME,
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state";
interface PendingTerminalCreateInput {
paneId?: string;
}
interface UseWorkspaceTerminalsInput {
client: DaemonClient | null;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
workspaceScripts: WorkspaceDescriptor["scripts"];
hasHydratedWorkspaces: boolean;
isMissingWorkspaceExecutionAuthority: boolean;
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
onScriptTerminalSelected: (terminalId: string) => void;
onWorkspacePathUnavailable: () => void;
onTerminalCreateQueued: () => void;
}
export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
const {
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreated,
onScriptTerminalSelected,
onWorkspacePathUnavailable,
onTerminalCreateQueued,
} = input;
const queryClient = useQueryClient();
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
null,
);
const canCreateNow = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const queryKey = useMemo(
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
[normalizedServerId, workspaceDirectory],
);
const query = useQuery({
queryKey,
enabled: canCreateNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
});
const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]);
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
const dataUpdatedAt = query.dataUpdatedAt;
useEffect(() => {
setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt));
}, [liveTerminalIds, dataUpdatedAt]);
const knownTerminalIds = useMemo(
() => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }),
[liveTerminalIds, pendingScriptTerminalIds],
);
const scriptTerminalIds = useMemo(
() => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }),
[pendingScriptTerminalIds, workspaceScripts],
);
const standaloneTerminalIds = useMemo(
() => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }),
[scriptTerminalIds, terminals],
);
const createMutation = useMutation({
mutationFn: async (_input?: PendingTerminalCreateInput) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, createInput) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) =>
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal,
workspaceDirectory,
}),
);
}
void queryClient.invalidateQueries({ queryKey });
if (createdTerminal) {
onTerminalCreated({
terminalId: createdTerminal.id,
paneId: createInput?.paneId,
});
}
},
});
const killMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
});
useEffect(() => {
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
return;
}
const unsubscribeChanged = client.on("terminals_changed", (message) => {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
useEffect(() => {
if (!pendingCreateInput) {
return;
}
if (canCreateNow && !createMutation.isPending) {
const pendingInput = pendingCreateInput;
setPendingCreateInput(null);
createMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingCreateInput(null);
onWorkspacePathUnavailable();
}
}, [
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onWorkspacePathUnavailable,
pendingCreateInput,
]);
const createTerminal = useCallback(
(createInput?: PendingTerminalCreateInput) => {
if (createMutation.isPending || pendingCreateInput) {
return;
}
if (canCreateNow) {
createMutation.mutate(createInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
onWorkspacePathUnavailable();
return;
}
setPendingCreateInput(createInput ?? {});
onTerminalCreateQueued();
},
[
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreateQueued,
onWorkspacePathUnavailable,
pendingCreateInput,
],
);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, query.dataUpdatedAt);
return nextTerminalIds;
});
onScriptTerminalSelected(terminalId);
void queryClient.invalidateQueries({ queryKey });
},
[onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
onScriptTerminalSelected(terminalId);
},
[onScriptTerminalSelected],
);
const removeTerminalFromCache = useCallback(
(terminalId: string) => {
queryClient.setQueryData<ListTerminalsPayload>(
queryKey,
removeTerminalFromPayload(terminalId),
);
},
[queryClient, queryKey],
);
const invalidateTerminals = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return {
canCreateNow,
createMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput,
query,
queryKey,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
};
}
@@ -11,7 +11,7 @@ import {
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router"; import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
@@ -94,6 +94,8 @@ import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query"; import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys"; import { checkoutStatusQueryKey } from "@/git/query-keys";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
@@ -156,12 +158,10 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>(); const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>(); const EMPTY_SET = new Set<string>();
@@ -1028,6 +1028,8 @@ function WorkspaceHeaderTitleBar({
); );
} }
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type PaneDirection = "left" | "right" | "up" | "down"; type PaneDirection = "left" | "right" | "up" | "down";
function parsePaneDirection(actionId: string): PaneDirection | null { function parsePaneDirection(actionId: string): PaneDirection | null {
@@ -1178,6 +1180,39 @@ function resolveWorkspaceAuthorityState(
}; };
} }
function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string { function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
const trimmed = host?.label?.trim(); const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback; return trimmed ? trimmed : fallback;
@@ -1324,6 +1359,17 @@ function shouldShowWorkspaceExplorerSidebar(input: {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
} }
function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return null; return null;
@@ -1331,108 +1377,6 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`; return `${serverId}:${workspaceId}`;
} }
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
toast: {
error: (message: string) => void;
show: (message: string) => void;
};
}
interface WorkspaceTerminalTabActions {
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
handleScriptTerminalSelected: (terminalId: string) => void;
handleWorkspacePathUnavailable: () => void;
handleTerminalCreateQueued: () => void;
}
function useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
if (!persistenceKey) {
return;
}
if (paneId) {
focusWorkspacePane(persistenceKey, paneId);
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error("Workspace path is not available yet");
}, [toast]);
const handleTerminalCreateQueued = useCallback(() => {
toast.show("Preparing workspace, opening terminal when ready...");
}, [toast]);
return {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
};
}
function useWorkspaceCheckoutStatus(input: {
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}) {
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
isRouteFocused: input.isRouteFocused,
client: input.client,
isConnected: input.isConnected,
workspaceDirectory: input.workspaceDirectory,
}),
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
input.normalizedServerId,
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error("Host is not connected");
}
return await input.client.getCheckoutStatus(input.workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
return { checkoutQuery, isCheckoutStatusLoading };
}
function WorkspaceScreenContent({ function WorkspaceScreenContent({
serverId, serverId,
workspaceId, workspaceId,
@@ -1461,6 +1405,7 @@ function WorkspaceScreenContent({
scopeKey: workspaceTerminalScopeKey, scopeKey: workspaceTerminalScopeKey,
}); });
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId); const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceAuthority = useMemo( const workspaceAuthority = useMemo(
@@ -1485,19 +1430,12 @@ function WorkspaceScreenContent({
useProvidersSnapshot(normalizedServerId, { useProvidersSnapshot(normalizedServerId, {
enabled: isRouteFocused, enabled: isRouteFocused,
}); });
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
const persistenceKey = useMemo( paneId?: string;
() => } | null>(null);
buildWorkspaceTabPersistenceKey({ const canCreateTerminalNow = useMemo(
serverId: normalizedServerId, () => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
workspaceId: normalizedWorkspaceId, [isRouteFocused, client, isConnected, workspaceDirectory],
}),
[normalizedServerId, normalizedWorkspaceId],
);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
); );
const workspaceAgentVisibility = useStoreWithEqualityFn( const workspaceAgentVisibility = useStoreWithEqualityFn(
@@ -1511,56 +1449,167 @@ function WorkspaceScreenContent({
workspaceAgentVisibilityEqual, workspaceAgentVisibilityEqual,
); );
const { const terminalsQueryKey = useMemo(
handleTerminalCreated, () => ["terminals", normalizedServerId, workspaceDirectory] as const,
handleScriptTerminalSelected, [normalizedServerId, workspaceDirectory],
handleWorkspacePathUnavailable, );
handleTerminalCreateQueued, const terminalsQuery = useQuery({
} = useWorkspaceTerminalTabActions({ queryKey: terminalsQueryKey,
persistenceKey, enabled: canCreateTerminalNow,
focusWorkspacePane, queryFn: async () => {
openWorkspaceTabFocused, if (!client || !workspaceDirectory) {
toast, throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
}); });
const { const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
createMutation: createTerminalMutation, const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
createTerminal, const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
handleScriptTerminalStarted, () => new Map(),
handleViewScriptTerminal, );
invalidateTerminals, useEffect(() => {
killMutation: killTerminalMutation, setPendingScriptTerminalIds(new Map());
knownTerminalIds, }, [normalizedServerId, normalizedWorkspaceId]);
liveTerminalIds, const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
pendingCreateInput: pendingTerminalCreateInput, useEffect(() => {
query: terminalsQuery, setPendingScriptTerminalIds(
removeTerminalFromCache, reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
standaloneTerminalIds, );
terminals, }, [liveTerminalIds, terminalsDataUpdatedAt]);
} = useWorkspaceTerminals({ const knownTerminalIds = useMemo(() => {
client, const terminalIds = new Set(liveTerminalIds);
isConnected, for (const terminalId of pendingScriptTerminalIds.keys()) {
isRouteFocused, terminalIds.add(terminalId);
normalizedServerId, }
normalizedWorkspaceId, return Array.from(terminalIds);
workspaceDirectory, }, [liveTerminalIds, pendingScriptTerminalIds]);
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS, const scriptTerminalIds = useMemo(() => {
hasHydratedWorkspaces, const terminalIds = new Set(pendingScriptTerminalIds.keys());
isMissingWorkspaceExecutionAuthority, for (const script of workspaceDescriptor?.scripts ?? []) {
onTerminalCreated: handleTerminalCreated, if (script.terminalId) {
onScriptTerminalSelected: handleScriptTerminalSelected, terminalIds.add(script.terminalId);
onWorkspacePathUnavailable: handleWorkspacePathUnavailable, }
onTerminalCreateQueued: handleTerminalCreateQueued, }
return terminalIds;
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
const standaloneTerminalIds = useMemo(
() =>
terminals
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id),
[scriptTerminalIds, terminals],
);
const createTerminalMutation = useMutation({
mutationFn: async (_input?: { paneId?: string }) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, input) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
const cwd = current?.cwd ?? workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
if (createdTerminal) {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
if (!workspaceKey) {
return;
}
if (input?.paneId) {
focusWorkspacePane(workspaceKey, input.paneId);
}
useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
}
},
});
const killTerminalMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
}); });
const { archiveAgent } = useArchiveAgent(); const { archiveAgent } = useArchiveAgent();
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({ useEffect(() => {
client, if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
isConnected, return;
isRouteFocused, }
normalizedServerId,
normalizedWorkspaceId, const unsubscribeChanged = client.on("terminals_changed", (message) => {
workspaceDirectory, if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
const isCheckoutQueryEnabled = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
normalizedServerId,
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.getCheckoutStatus(workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}); });
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const hasHydratedAgents = useSessionStore( const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
); );
@@ -1569,6 +1618,30 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
hasHydratedWorkspaces, hasHydratedWorkspaces,
}); });
useEffect(() => {
if (!pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
const pendingInput = pendingTerminalCreateInput;
setPendingTerminalCreateInput(null);
createTerminalMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingTerminalCreateInput(null);
toast.error("Workspace path is not available yet");
}
}, [
canCreateTerminalNow,
createTerminalMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
pendingTerminalCreateInput,
toast,
]);
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({ const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading, isCheckoutStatusLoading,
isError: checkoutQuery.isError, isError: checkoutQuery.isError,
@@ -1665,6 +1738,15 @@ function WorkspaceScreenContent({
return () => handler.remove(); return () => handler.remove();
}, [isExplorerOpen, isRouteFocused, showMobileAgent]); }, [isExplorerOpen, isRouteFocused, showMobileAgent]);
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceLayout = useWorkspaceLayoutStore((state) => const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
); );
@@ -1679,6 +1761,7 @@ function WorkspaceScreenContent({
[workspaceLayout], [workspaceLayout],
); );
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused }); useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceTabInBackground = useWorkspaceLayoutStore( const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground, (state) => state.openTabInBackground,
); );
@@ -1694,6 +1777,39 @@ function WorkspaceScreenContent({
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
return nextTerminalIds;
});
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
},
[
openWorkspaceTabFocused,
persistenceKey,
queryClient,
terminalsQuery.dataUpdatedAt,
terminalsQueryKey,
],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const paneFocusSuppressedRef = useRef(false); const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -2084,7 +2200,24 @@ function WorkspaceScreenContent({
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey], [focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
); );
const handleCreateTerminal = useStableEvent(createTerminal); const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow) {
createTerminalMutation.mutate(input);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
toast.error("Workspace path is not available yet");
return;
}
setPendingTerminalCreateInput(input ?? {});
toast.show("Preparing workspace, opening terminal when ready...");
});
const handleCreateBrowserTab = useCallback( const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => { (input?: { paneId?: string }) => {
@@ -2151,7 +2284,10 @@ function WorkspaceScreenContent({
return; return;
} }
removeTerminalFromCache(terminalId); queryClient.setQueryData<ListTerminalsPayload>(
terminalsQueryKey,
removeTerminalFromPayload(terminalId),
);
setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) { if (persistenceKey) {
@@ -2161,16 +2297,18 @@ function WorkspaceScreenContent({
}); });
} }
void killTerminalAsync(terminalId).catch(invalidateTerminals); void killTerminalAsync(terminalId).catch(() => {
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
});
}); });
}, },
[ [
closeTab, closeTab,
closeWorkspaceTabWithCleanup, closeWorkspaceTabWithCleanup,
invalidateTerminals,
killTerminalAsync, killTerminalAsync,
persistenceKey, persistenceKey,
removeTerminalFromCache, queryClient,
terminalsQueryKey,
], ],
); );
@@ -1,7 +1,5 @@
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import { import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
@@ -88,7 +86,7 @@ interface InsertSplitInternalInput {
targetPaneId: string; targetPaneId: string;
tabId: string; tabId: string;
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; createNodeId: (prefix: "pane" | "group") => string;
} }
interface InsertSplitInternalResult { interface InsertSplitInternalResult {
@@ -144,7 +142,7 @@ interface SplitPaneInLayoutInput {
tabId: string; tabId: string;
targetPaneId: string; targetPaneId: string;
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number; maxTreeDepth: number;
} }
@@ -157,7 +155,7 @@ interface SplitPaneEmptyInLayoutInput {
layout: WorkspaceLayout; layout: WorkspaceLayout;
targetPaneId: string; targetPaneId: string;
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number; maxTreeDepth: number;
} }
@@ -234,6 +232,14 @@ function normalizeTabIds(list: unknown): string[] {
return next; return next;
} }
function generateNodeId(prefix: "pane" | "group"): string {
const randomValue =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}_${randomValue}`;
}
function createPaneNode(input: { function createPaneNode(input: {
id: string; id: string;
tabs?: WorkspaceTab[]; tabs?: WorkspaceTab[];
@@ -990,16 +996,13 @@ export function insertSplit(
targetPaneId: string, targetPaneId: string,
tabId: string, tabId: string,
position: "left" | "right" | "top" | "bottom", position: "left" | "right" | "top" | "bottom",
createNodeId: (
prefix: WorkspaceLayoutNodeIdPrefix,
) => string = defaultWorkspaceLayoutIds.createNodeId,
): SplitNode { ): SplitNode {
return insertSplitInternal({ return insertSplitInternal({
root: asInternalNode(root), root: asInternalNode(root),
targetPaneId, targetPaneId,
tabId, tabId,
position, position,
createNodeId, createNodeId: generateNodeId,
}).root; }).root;
} }
@@ -1,17 +0,0 @@
export type WorkspaceLayoutNodeIdPrefix = "pane" | "group";
export interface WorkspaceLayoutIdSource {
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
createFocusRestorationToken: () => string;
}
function createRandomIdValue(): string {
return typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const defaultWorkspaceLayoutIds: WorkspaceLayoutIdSource = {
createNodeId: (prefix) => `${prefix}_${createRandomIdValue()}`,
createFocusRestorationToken: () => `workspace-focus-${createRandomIdValue()}`,
};
@@ -20,7 +20,6 @@ import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
collectAllPanes, collectAllPanes,
collectAllTabs, collectAllTabs,
createWorkspaceLayoutStore,
createDefaultLayout, createDefaultLayout,
findPaneById, findPaneById,
findPaneContainingTab, findPaneContainingTab,
@@ -29,6 +28,7 @@ import {
insertSplit, insertSplit,
removePaneFromTree, removePaneFromTree,
removeTabFromTree, removeTabFromTree,
useWorkspaceLayoutStore,
type SplitNode, type SplitNode,
type SplitPane, type SplitPane,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
@@ -36,40 +36,6 @@ import {
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
const WORKSPACE_ID = "ws-main"; const WORKSPACE_ID = "ws-main";
function createDeterministicWorkspaceLayoutIds() {
let values: string[] = [];
let fallbackIndex = 0;
function nextValue(): string {
const value = values.shift();
if (value) {
return value;
}
fallbackIndex += 1;
return `generated-${fallbackIndex}`;
}
return {
useValues: (nextValues: string[]) => {
values = nextValues.slice();
fallbackIndex = 0;
},
reset: () => {
values = [];
fallbackIndex = 0;
},
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
};
}
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
function useWorkspaceLayoutIds(...values: string[]) {
workspaceLayoutIds.useValues(values);
}
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab { function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
return { return {
tabId, tabId,
@@ -202,14 +168,13 @@ describe("workspace-layout-store helpers", () => {
describe("workspace-layout-store tree transforms", () => { describe("workspace-layout-store tree transforms", () => {
beforeEach(() => { beforeEach(() => {
workspaceLayoutIds.reset(); vi.restoreAllMocks();
}); });
it("insertSplit wraps root-level same-direction splits in a nested group", () => { it("insertSplit wraps root-level same-direction splits in a nested group", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"11111111-1111-1111-1111-111111111111", .mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
"22222222-2222-2222-2222-222222222222", .mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
);
const root: SplitNode = { const root: SplitNode = {
kind: "group", kind: "group",
@@ -224,7 +189,7 @@ describe("workspace-layout-store tree transforms", () => {
}, },
}; };
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId); const nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextGroup = expectGroup(nextRoot); const nextGroup = expectGroup(nextRoot);
const nestedGroup = expectGroup(nextGroup.group.children[1]); const nestedGroup = expectGroup(nextGroup.group.children[1]);
@@ -305,20 +270,22 @@ describe("workspace-layout-store tree transforms", () => {
describe("workspace-layout-store actions", () => { describe("workspace-layout-store actions", () => {
beforeEach(() => { beforeEach(() => {
workspaceLayoutIds.reset(); useWorkspaceLayoutStore.setState({
workspaceLayoutStore.setState({
layoutByWorkspace: {}, layoutByWorkspace: {},
splitSizesByWorkspace: {}, splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {}, pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {}, focusRestorationByWorkspace: {},
}); });
vi.restoreAllMocks();
}); });
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -341,7 +308,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/b.ts", path: "/repo/worktree/b.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("file_/repo/worktree/a.ts"); expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe("file_/repo/worktree/b.ts"); expect(secondTabId).toBe("file_/repo/worktree/b.ts");
@@ -355,14 +322,14 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground inserts a tab without stealing focus", () => { it("openTabInBackground inserts a tab without stealing focus", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const setupTabId = store.openTabInBackground(workspaceKey, { const setupTabId = store.openTabInBackground(workspaceKey, {
kind: "setup", kind: "setup",
workspaceId: "ws-main", workspaceId: "ws-main",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layout.root, "main")!; const pane = findPaneById(layout.root, "main")!;
expect(agentTabId).toBe("agent_agent-1"); expect(agentTabId).toBe("agent_agent-1");
@@ -374,7 +341,7 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground on an existing target is a no-op", () => { it("openTabInBackground on an existing target is a no-op", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -388,7 +355,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/a.ts", path: "/repo/worktree/a.ts",
}); });
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layoutAfter.root, "main")!; const pane = findPaneById(layoutAfter.root, "main")!;
expect(duplicateTabId).toBe(firstTabId); expect(duplicateTabId).toBe(firstTabId);
@@ -398,25 +365,27 @@ describe("workspace-layout-store actions", () => {
it("unfocuses and restores the previous focused pane", () => { it("unfocuses and restores the previous focused pane", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const token = store.unfocusPane(workspaceKey); const token = store.unfocusPane(workspaceKey);
expect(token).toBeTruthy(); expect(token).toBeTruthy();
expect( expect(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull(); ).toBeNull();
store.restorePaneFocus(workspaceKey, token!); store.restorePaneFocus(workspaceKey, token!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main", "main",
); );
}); });
it("does not restore stale focus after another pane is focused", () => { it("does not restore stale focus after another pane is focused", () => {
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.splitPane(workspaceKey, { store.splitPane(workspaceKey, {
@@ -430,14 +399,14 @@ describe("workspace-layout-store actions", () => {
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
store.restorePaneFocus(workspaceKey, token!); store.restorePaneFocus(workspaceKey, token!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
); );
}); });
it("waits for nested focus restorations before restoring", () => { it("waits for nested focus restorations before restoring", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const outerToken = store.unfocusPane(workspaceKey); const outerToken = store.unfocusPane(workspaceKey);
@@ -445,22 +414,22 @@ describe("workspace-layout-store actions", () => {
store.restorePaneFocus(workspaceKey, outerToken!); store.restorePaneFocus(workspaceKey, outerToken!);
expect( expect(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull(); ).toBeNull();
store.restorePaneFocus(workspaceKey, innerToken!); store.restorePaneFocus(workspaceKey, innerToken!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main", "main",
); );
}); });
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => { it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("draft-1"); expect(firstTabId).toBe("draft-1");
expect(secondTabId).toBe("draft-2"); expect(secondTabId).toBe("draft-2");
@@ -481,9 +450,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => { it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"77777777-7777-7777-7777-777777777777",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const newPaneId = store.splitPaneEmpty(workspaceKey, { const newPaneId = store.splitPaneEmpty(workspaceKey, {
@@ -494,7 +465,7 @@ describe("workspace-layout-store actions", () => {
kind: "draft", kind: "draft",
draftId: "draft-split", draftId: "draft-split",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777"); expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
expect(draftTabId).toBe("draft-split"); expect(draftTabId).toBe("draft-split");
@@ -505,9 +476,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("focusTab moves workspace focus to the pane containing the tab", () => { it("focusTab moves workspace focus to the pane containing the tab", () => {
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const fileTabId = store.openTabFocused(workspaceKey, { const fileTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -524,20 +497,22 @@ describe("workspace-layout-store actions", () => {
}); });
store.focusTab(workspaceKey, fileTabId!); store.focusTab(workspaceKey, fileTabId!);
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
store.focusTab(workspaceKey, terminalTabId!); store.focusTab(workspaceKey, terminalTabId!);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
expect(layout.focusedPaneId).toBe(splitPaneId); expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId); expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
}); });
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => { it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
@@ -548,7 +523,7 @@ describe("workspace-layout-store actions", () => {
}); });
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1"); const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const splitPane = findPaneById(layout.root, splitPaneId); const splitPane = findPaneById(layout.root, splitPaneId);
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId); const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
@@ -565,7 +540,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab keeps a draft tab in place while updating its target", () => { it("retargetTab keeps a draft tab in place while updating its target", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, { const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft", kind: "draft",
@@ -575,7 +550,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/retargeted.ts", path: "/repo/worktree/retargeted.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(draftTabId).toBe("draft-retarget"); expect(draftTabId).toBe("draft-retarget");
expect(nextTabId).toBe(draftTabId); expect(nextTabId).toBe(draftTabId);
@@ -590,9 +565,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => { it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"55555555-5555-5555-5555-555555555555",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const existingFileTabId = store.openTabFocused(workspaceKey, { const existingFileTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -613,7 +590,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/existing.ts", path: "/repo/worktree/existing.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts"); expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
expect(draftTabId).toBe("draft-dup"); expect(draftTabId).toBe("draft-dup");
@@ -629,7 +606,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab closes a draft tab and focuses an existing matching target tab", () => { it("retargetTab closes a draft tab and focuses an existing matching target tab", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstDraftTabId = store.openTabFocused(workspaceKey, { const firstDraftTabId = store.openTabFocused(workspaceKey, {
kind: "draft", kind: "draft",
@@ -648,7 +625,7 @@ describe("workspace-layout-store actions", () => {
kind: "agent", kind: "agent",
agentId: "agent-1", agentId: "agent-1",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstAgentTabId).toBe(firstDraftTabId); expect(firstAgentTabId).toBe(firstDraftTabId);
expect(nextTabId).toBe(firstDraftTabId); expect(nextTabId).toBe(firstDraftTabId);
@@ -664,7 +641,7 @@ describe("workspace-layout-store actions", () => {
it("reorderTabs reorders tabs within the focused pane", () => { it("reorderTabs reorders tabs within the focused pane", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -680,7 +657,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]); store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")).toEqual({ expect(findPaneById(layout.root, "main")).toEqual({
id: "main", id: "main",
@@ -707,9 +684,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => { it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"34343434-3434-3434-3434-343434343434",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.openTabFocused(workspaceKey, { store.openTabFocused(workspaceKey, {
@@ -733,7 +712,7 @@ describe("workspace-layout-store actions", () => {
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!); store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
store.focusPane(workspaceKey, "main"); store.focusPane(workspaceKey, "main");
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]); store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434"); expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
@@ -757,9 +736,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("focusPane switches workspace focus to a different pane", () => { it("focusPane switches workspace focus to a different pane", () => {
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"56565656-5656-5656-5656-565656565656",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -773,20 +754,22 @@ describe("workspace-layout-store actions", () => {
}); });
store.focusPane(workspaceKey, "main"); store.focusPane(workspaceKey, "main");
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
store.focusPane(workspaceKey, splitPaneId!); store.focusPane(workspaceKey, splitPaneId!);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656"); expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
expect(layout.focusedPaneId).toBe(splitPaneId); expect(layout.focusedPaneId).toBe(splitPaneId);
}); });
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => { it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"cccccccc-cccc-cccc-cccc-cccccccccccc",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -800,7 +783,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.closeTab(workspaceKey, secondTabId!); store.closeTab(workspaceKey, secondTabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc"); expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
@@ -808,19 +791,18 @@ describe("workspace-layout-store actions", () => {
}); });
it("splitPane enforces the maximum depth of four", () => { it("splitPane enforces the maximum depth of four", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"11111111-1111-1111-1111-111111111111", .mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
"22222222-2222-2222-2222-222222222222", .mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
"33333333-3333-3333-3333-333333333333", .mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
"44444444-4444-4444-4444-444444444444", .mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
"55555555-5555-5555-5555-555555555555", .mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
"66666666-6666-6666-6666-666666666666", .mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
"77777777-7777-7777-7777-777777777777", .mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
"88888888-8888-8888-8888-888888888888", .mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
@@ -849,7 +831,7 @@ describe("workspace-layout-store actions", () => {
position: "bottom", position: "bottom",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111"); expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333"); expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555"); expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
@@ -858,9 +840,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("moveTabToPane collapses the source pane when its last tab moves out", () => { it("moveTabToPane collapses the source pane when its last tab moves out", () => {
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"dddddddd-dddd-dddd-dddd-dddddddddddd",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const leftTabId = store.openTabFocused(workspaceKey, { const leftTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -877,7 +861,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!); store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe(splitPaneId); expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]); expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
@@ -888,14 +872,13 @@ describe("workspace-layout-store actions", () => {
}); });
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => { it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"78787878-7878-7878-7878-787878787878", .mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
"89898989-8989-8989-8989-898989898989", .mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a", .mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -918,7 +901,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.closeTab(workspaceKey, secondTabId!); store.closeTab(workspaceKey, secondTabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const rootGroup = expectGroup(layout.root); const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
@@ -954,9 +937,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("openTab focuses the existing tab instead of creating a duplicate entry", () => { it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"abababab-abab-abab-abab-abababababab",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -974,7 +959,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/b.ts", path: "/repo/worktree/b.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab"); expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
expect(duplicateTabId).toBe(secondTabId); expect(duplicateTabId).toBe(secondTabId);
@@ -986,14 +971,13 @@ describe("workspace-layout-store actions", () => {
}); });
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", .mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
"ffffffff-ffff-ffff-ffff-ffffffffffff", .mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
"11111111-1111-1111-1111-111111111111", .mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
@@ -1011,12 +995,12 @@ describe("workspace-layout-store actions", () => {
position: "right", position: "right",
}); });
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitGroup = expectGroup(splitRoot); const splitGroup = expectGroup(splitRoot);
const nestedGroup = expectGroup(splitGroup.group.children[1]); const nestedGroup = expectGroup(splitGroup.group.children[1]);
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]); store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedGroup = expectGroup(resizedRoot); const resizedGroup = expectGroup(resizedRoot);
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]); const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0); const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
@@ -1030,11 +1014,11 @@ describe("workspace-layout-store actions", () => {
it("closing the last tab keeps a single empty pane in the layout", () => { it("closing the last tab keeps a single empty pane in the layout", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.closeTab(workspaceKey, tabId!); store.closeTab(workspaceKey, tabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout).toEqual(createDefaultLayout()); expect(layout).toEqual(createDefaultLayout());
}); });
@@ -1048,12 +1032,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy(); expect(otherWorkspaceKey).toBeTruthy();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(otherWorkspaceKey as string, "agent-2"); store.pinAgent(otherWorkspaceKey as string, "agent-2");
let state = workspaceLayoutStore.getState(); let state = useWorkspaceLayoutStore.getState();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
@@ -1061,13 +1045,13 @@ describe("workspace-layout-store actions", () => {
store.unpinAgent(workspaceKey, "agent-1"); store.unpinAgent(workspaceKey, "agent-1");
state = workspaceLayoutStore.getState(); state = useWorkspaceLayoutStore.getState();
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
]); ]);
const partialize = workspaceLayoutStore.persist.getOptions().partialize; const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function"); expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({ expect(partialize?.(state)).toEqual({
layoutByWorkspace: {}, layoutByWorkspace: {},
@@ -1084,12 +1068,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy(); expect(otherWorkspaceKey).toBeTruthy();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(otherWorkspaceKey as string, "agent-2"); store.hideAgent(otherWorkspaceKey as string, "agent-2");
let state = workspaceLayoutStore.getState(); let state = useWorkspaceLayoutStore.getState();
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
@@ -1097,13 +1081,13 @@ describe("workspace-layout-store actions", () => {
store.unhideAgent(workspaceKey, "agent-1"); store.unhideAgent(workspaceKey, "agent-1");
state = workspaceLayoutStore.getState(); state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
]); ]);
const partialize = workspaceLayoutStore.persist.getOptions().partialize; const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function"); expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({ expect(partialize?.(state)).toEqual({
layoutByWorkspace: {}, layoutByWorkspace: {},
@@ -1112,9 +1096,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => { it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"67676767-6767-6767-6767-676767676767",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, { const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft", kind: "draft",
@@ -1128,7 +1114,7 @@ describe("workspace-layout-store actions", () => {
}); });
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1"); const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767"); expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
expect(nextTabId).toBe("agent_agent-1"); expect(nextTabId).toBe("agent_agent-1");
@@ -1140,7 +1126,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => { it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.setState((state) => ({ useWorkspaceLayoutStore.setState((state) => ({
...state, ...state,
layoutByWorkspace: { layoutByWorkspace: {
...state.layoutByWorkspace, ...state.layoutByWorkspace,
@@ -1183,7 +1169,7 @@ describe("workspace-layout-store actions", () => {
}, },
})); }));
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: ["agent-1"], activeAgentIds: ["agent-1"],
@@ -1193,7 +1179,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = collectAllTabs(layout.root); const tabs = collectAllTabs(layout.root);
expect(tabs.map((tab) => tab.tabId)).toEqual([ expect(tabs.map((tab) => tab.tabId)).toEqual([
@@ -1214,14 +1200,14 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not re-add locally hidden agent tabs", () => { it("reconcileTabs does not re-add locally hidden agent tabs", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.setState((state) => ({ useWorkspaceLayoutStore.setState((state) => ({
...state, ...state,
hiddenAgentIdsByWorkspace: { hiddenAgentIdsByWorkspace: {
[workspaceKey]: new Set<string>(["agent-1"]), [workspaceKey]: new Set<string>(["agent-1"]),
}, },
})); }));
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: ["agent-1"], activeAgentIds: ["agent-1"],
@@ -1231,13 +1217,13 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
}); });
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => { it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"], activeAgentIds: ["parent-agent", "child-agent"],
@@ -1248,7 +1234,7 @@ describe("workspace-layout-store actions", () => {
}); });
expect( expect(
workspaceLayoutStore useWorkspaceLayoutStore
.getState() .getState()
.getWorkspaceTabs(workspaceKey) .getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId), .map((tab) => tab.tabId),
@@ -1257,7 +1243,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => { it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1272,7 +1258,7 @@ describe("workspace-layout-store actions", () => {
}); });
expect( expect(
workspaceLayoutStore useWorkspaceLayoutStore
.getState() .getState()
.getWorkspaceTabs(workspaceKey) .getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId), .map((tab) => tab.tabId),
@@ -1281,7 +1267,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => { it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1296,7 +1282,7 @@ describe("workspace-layout-store actions", () => {
}); });
expect( expect(
workspaceLayoutStore useWorkspaceLayoutStore
.getState() .getState()
.getWorkspaceTabs(workspaceKey) .getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId), .map((tab) => tab.tabId),
@@ -1305,7 +1291,7 @@ describe("workspace-layout-store actions", () => {
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => { it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "child-agent"); store.hideAgent(workspaceKey, "child-agent");
store.reconcileTabs(workspaceKey, { store.reconcileTabs(workspaceKey, {
@@ -1318,11 +1304,11 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_child-agent", "agent_child-agent",
@@ -1331,7 +1317,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => { it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const scriptTabId = store.openTabFocused(workspaceKey, { const scriptTabId = store.openTabFocused(workspaceKey, {
kind: "terminal", kind: "terminal",
@@ -1349,8 +1335,8 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey); const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]); expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId); expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
}); });
@@ -1358,7 +1344,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not auto-open live non-standalone terminals", () => { it("reconcileTabs does not auto-open live non-standalone terminals", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: [], activeAgentIds: [],
@@ -1369,44 +1355,46 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
}); });
it("explicitly opening an agent tab clears hidden intent", () => { it("explicitly opening an agent tab clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
}); });
it("pinning an agent clears hidden intent", () => { it("pinning an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined(); expect(
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
).toBeDefined();
store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(workspaceKey, "agent-1");
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
}); });
it("retargeting a tab to an agent clears hidden intent", () => { it("retargeting a tab to an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" }); store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -1,142 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
class FakeDaemonClient implements DaemonProbeClient { const daemonClientMock = vi.hoisted(() => {
readonly lastError: string | null; const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
let nextConnectError: Error | null = null;
let nextLastError: string | null = null;
constructor( class MockDaemonClient {
private readonly probe: FakeDaemonProbe, public lastError: string | null = nextLastError;
readonly config: DaemonClientConfig, private lastServerInfo = {
) { status: "server_info" as const,
this.lastError = probe.nextLastError; serverId: "srv_probe_test",
} hostname: "probe-host" as string | null,
version: "0.0.0",
};
async connect(): Promise<void> { constructor(config: { clientId?: string; url?: string; password?: string }) {
if (this.probe.nextConnectError) { createdConfigs.push(config);
throw this.probe.nextConnectError; }
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(): () => void {
return () => undefined;
}
async connect(): Promise<void> {
if (nextConnectError) {
throw nextConnectError;
}
return;
}
getLastServerInfoMessage() {
return this.lastServerInfo;
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
} }
} }
getLastServerInfoMessage() { return {
return { MockDaemonClient,
serverId: "srv_probe_test", createdConfigs,
hostname: "probe-host", setNextConnectFailure: (error: Error, lastError: string | null) => {
}; nextConnectError = error;
} nextLastError = lastError;
async close(): Promise<void> {
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
nextConnectError: Error | null = null;
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
}, },
resolveAppVersion: () => null, reset: () => {
createLocalTransportFactory: () => null, createdConfigs.length = 0;
buildLocalTransportUrl: ({ transportType, transportPath }) => nextConnectError = null;
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`, nextLastError = null;
createClient: (config) => {
const client = new FakeDaemonClient(this, config);
this.createdClients.push(client);
return client;
}, },
}; };
});
failNextConnection(error: Error, lastError: string | null): void { const clientIdMock = vi.hoisted(() => ({
this.nextConnectError = error; getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
this.nextLastError = lastError; }));
}
createdConfigs(): DaemonClientConfig[] { vi.mock("@server/client/daemon-client", () => ({
return this.createdClients.map((client) => client.config); DaemonClient: daemonClientMock.MockDaemonClient,
} }));
}
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
buildLocalDaemonTransportUrl: vi.fn(
({
transportType,
transportPath,
}: {
transportType: "socket" | "pipe";
transportPath: string;
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
),
}));
describe("test-daemon-connection connectToDaemon", () => { describe("test-daemon-connection connectToDaemon", () => {
let probe: FakeDaemonProbe;
beforeEach(() => { beforeEach(() => {
daemonClientMock.reset();
clientIdMock.getOrCreateClientId.mockClear();
vi.stubGlobal("__DEV__", false); vi.stubGlobal("__DEV__", false);
probe = new FakeDaemonProbe();
}); });
it("reuses the app clientId for direct connections", async () => { it("reuses the app clientId for direct connections", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const first = await connectToDaemon(
{ const first = await mod.connectToDaemon({
id: "direct:lan:6767", id: "direct:lan:6767",
type: "directTcp", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, });
undefined,
probe.deps,
);
await first.client.close(); await first.client.close();
const second = await connectToDaemon( const second = await mod.connectToDaemon({
{ id: "direct:lan:6767",
id: "direct:lan:6767", type: "directTcp",
type: "directTcp", endpoint: "lan:6767",
endpoint: "lan:6767", });
},
undefined,
probe.deps,
);
await second.client.close(); await second.client.close();
const [firstConfig, secondConfig] = probe.createdConfigs(); const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
expect(firstConfig?.clientId).toBe("cid_shared_probe_test"); expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
expect(secondConfig?.clientId).toBe("cid_shared_probe_test"); expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
expect(probe.clientIdsRequested).toBe(2); expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
}); });
it("encodes the local socket target into the client config", async () => { it("encodes the local socket target into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const result = await connectToDaemon(
{ const result = await mod.connectToDaemon({
id: "socket:/tmp/paseo.sock", id: "socket:/tmp/paseo.sock",
type: "directSocket", type: "directSocket",
path: "/tmp/paseo.sock", path: "/tmp/paseo.sock",
}, });
undefined,
probe.deps,
);
await result.client.close(); await result.client.close();
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"); expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
);
}); });
it("passes direct TCP connection passwords into the client config", async () => { it("passes direct TCP connection passwords into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const result = await connectToDaemon(
{ const result = await mod.connectToDaemon({
id: "direct:lan:6767", id: "direct:lan:6767",
type: "directTcp", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
password: "shared-secret", password: "shared-secret",
}, });
undefined,
probe.deps,
);
await result.client.close(); await result.client.close();
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret"); expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
}); });
it("uses relay TLS from the stored connection", async () => { it("uses relay TLS from the stored connection", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const tlsResult = await connectToDaemon(
const tlsResult = await mod.connectToDaemon(
{ {
id: "relay:wss:[::1]:443", id: "relay:wss:[::1]:443",
type: "relay", type: "relay",
@@ -145,11 +157,10 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey", daemonPublicKeyB64: "pubkey",
}, },
{ serverId: "srv_probe_test" }, { serverId: "srv_probe_test" },
probe.deps,
); );
await tlsResult.client.close(); await tlsResult.client.close();
const plainResult = await connectToDaemon( const plainResult = await mod.connectToDaemon(
{ {
id: "relay:relay.paseo.sh:443", id: "relay:relay.paseo.sh:443",
type: "relay", type: "relay",
@@ -158,52 +169,43 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey", daemonPublicKeyB64: "pubkey",
}, },
{ serverId: "srv_probe_test" }, { serverId: "srv_probe_test" },
probe.deps,
); );
await plainResult.client.close(); await plainResult.client.close();
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/); expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
}); });
it("surfaces auth rejection as an incorrect password", async () => { it("surfaces auth rejection as an incorrect password", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
probe.failNextConnection( daemonClientMock.setNextConnectFailure(
new Error("Transport closed (code 4001)"), new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)", "Transport closed (code 4001)",
); );
await expect( await expect(
connectToDaemon( mod.connectToDaemon({
{ id: "direct:lan:6767",
id: "direct:lan:6767", type: "directTcp",
type: "directTcp", endpoint: "lan:6767",
endpoint: "lan:6767", password: "wrong-secret",
password: "wrong-secret", }),
},
undefined,
probe.deps,
),
).rejects.toMatchObject({ ).rejects.toMatchObject({
message: "Incorrect password", message: "Incorrect password",
}); });
}); });
it("keeps generic transport failures generic when a password was supplied", async () => { it("keeps generic transport failures generic when a password was supplied", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
probe.failNextConnection(new Error("Transport error"), "Transport error"); daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
await expect( await expect(
connectToDaemon( mod.connectToDaemon({
{ id: "direct:lan:6767",
id: "direct:lan:6767", type: "directTcp",
type: "directTcp", endpoint: "lan:6767",
endpoint: "lan:6767", password: "shared-secret",
password: "shared-secret", }),
},
undefined,
probe.deps,
),
).rejects.toMatchObject({ ).rejects.toMatchObject({
message: "Transport error", message: "Transport error",
}); });
@@ -13,34 +13,6 @@ import {
createDesktopLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient {
readonly lastError: string | null;
connect(): Promise<void>;
close(): Promise<void>;
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
}
interface LocalTransportUrlInput {
transportType: "socket" | "pipe";
transportPath: string;
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient;
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
getClientId: getOrCreateClientId,
resolveAppVersion,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config),
};
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -97,17 +69,13 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig( export async function buildClientConfig(
connection: HostConnection, connection: HostConnection,
serverId?: string, serverId?: string,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientId = await deps.getClientId(); const clientId = await getOrCreateClientId();
const localTransportFactory = deps.createLocalTransportFactory(); const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = { const base = {
clientId, clientId,
clientType: "mobile" as const, clientType: "mobile" as const,
appVersion: deps.resolveAppVersion() ?? undefined, appVersion: resolveAppVersion() ?? undefined,
suppressSendErrors: true, suppressSendErrors: true,
reconnect: { enabled: false }, reconnect: { enabled: false },
...((connection.type === "directSocket" || connection.type === "directPipe") && ...((connection.type === "directSocket" || connection.type === "directPipe") &&
@@ -119,7 +87,7 @@ export async function buildClientConfig(
if (connection.type === "directSocket" || connection.type === "directPipe") { if (connection.type === "directSocket" || connection.type === "directPipe") {
return { return {
...base, ...base,
url: deps.buildLocalTransportUrl({ url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe", transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path, transportPath: connection.path,
}), }),
@@ -152,23 +120,10 @@ export async function buildClientConfig(
export function connectAndProbe( export function connectAndProbe(
config: DaemonClientConfig, config: DaemonClientConfig,
timeoutMs: number, timeoutMs: number,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>; ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
export function connectAndProbe<TClient extends DaemonProbeClient>( const client = new DaemonClient(config);
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"createClient"
> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const client = deps.createClient(config);
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>( return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
(resolve, reject) => { (resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
void client.close().catch(() => undefined); void client.close().catch(() => undefined);
@@ -228,20 +183,10 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
return connection.type === "relay" ? 10_000 : 6_000; return connection.type === "relay" ? 10_000 : 6_000;
} }
export function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectToDaemon<TClient extends DaemonProbeClient>(
connection: HostConnection,
options: ProbeOptions | undefined,
deps: DaemonConnectionDependencies<TClient>,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export async function connectToDaemon( export async function connectToDaemon(
connection: HostConnection, connection: HostConnection,
options?: ProbeOptions, options?: ProbeOptions,
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies, ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { const config = await buildClientConfig(connection, options?.serverId);
const config = await buildClientConfig(connection, options?.serverId, deps); return connectAndProbe(config, resolveTimeout(connection, options));
return connectAndProbe(config, resolveTimeout(connection, options), deps);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.74", "version": "0.1.71",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -24,7 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.74", "@getpaseo/server": "0.1.71",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -1,67 +1,32 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { const mocks = vi.hoisted(() => ({
type DaemonLaunchRuntime, spawnSync: vi.fn(),
type DetachedDaemonProcess, spawnProcess: vi.fn(),
startLocalDaemonDetached, }));
startLocalDaemonForeground,
} from "./local-daemon.js";
type RecordedDaemonLaunch = vi.mock("node:child_process", async () => {
| { const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mode: "detached"; return {
command: string; ...actual,
args: string[]; spawnSync: mocks.spawnSync,
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2]; };
} });
| {
mode: "foreground";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
};
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess { vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
pid = 4242; pid = 4242;
wasUnreferenced = false; unref = vi.fn();
unref(): void {
this.wasUnreferenced = true;
}
}
class FakeDaemonRuntime implements DaemonLaunchRuntime {
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
readonly daemonProcess = new FakeDaemonProcess();
foregroundStatus = 0;
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
resolveRunnerEntry(): string {
return this.runnerEntry;
}
resolveHome(env: NodeJS.ProcessEnv): string {
return env.PASEO_HOME ?? "/tmp/paseo";
}
spawnDetached(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
): DetachedDaemonProcess {
this.recordedLaunches.push({ mode: "detached", command, args, options });
return this.daemonProcess;
}
spawnForeground(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
) {
this.recordedLaunches.push({ mode: "foreground", command, args, options });
return { status: this.foregroundStatus, error: undefined };
}
} }
function expectSupervisorLaunch(argv: string[]): void { function expectSupervisorLaunch(argv: string[]): void {
@@ -76,59 +41,59 @@ function expectSupervisorLaunch(argv: string[]): void {
describe("local daemon launch supervision", () => { describe("local daemon launch supervision", () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers(); vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
}); });
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
const runtime = new FakeDaemonRuntime(); mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime); const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
expect(status).toBe(0); expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); expect(mocks.spawnSync).toHaveBeenCalledOnce();
const launch = runtime.recordedLaunches[0]; const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(launch?.mode).toBe("foreground"); expect(command).toBe(process.execPath);
expect(launch?.command).toBe(process.execPath); expectSupervisorLaunch(argv);
expectSupervisorLaunch(launch?.args ?? []); expect(argv).toContain("--no-relay");
expect(launch?.args).toContain("--no-relay");
}); });
test("detached start spawns supervisor-entrypoint instead of server/index", async () => { test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const runtime = new FakeDaemonRuntime(); const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const resultPromise = startLocalDaemonDetached( const { startLocalDaemonDetached } = await import("./local-daemon.js");
{ home: "/tmp/paseo-test", mcp: false }, const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
runtime,
);
await vi.advanceTimersByTimeAsync(1200); await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise; const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" }); expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(runtime.daemonProcess.wasUnreferenced).toBe(true); expect(child.unref).toHaveBeenCalledOnce();
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]); expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const launch = runtime.recordedLaunches[0]; const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(launch?.mode).toBe("detached"); expect(command).toBe(process.execPath);
expect(launch?.command).toBe(process.execPath); expectSupervisorLaunch(argv);
expectSupervisorLaunch(launch?.args ?? []); expect(argv).toContain("--no-mcp");
expect(launch?.args).toContain("--no-mcp");
}); });
test("relay TLS flag is passed to the supervised daemon", async () => { test("relay TLS flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime(); mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const status = startLocalDaemonForeground( const { startLocalDaemonForeground } = await import("./local-daemon.js");
{ const status = startLocalDaemonForeground({
home: "/tmp/paseo-test", home: "/tmp/paseo-test",
relayUseTls: true, relayUseTls: true,
}, });
runtime,
);
expect(status).toBe(0); expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
const launch = runtime.recordedLaunches[0]; string,
expect(launch?.mode).toBe("foreground"); string[],
expect(launch?.args).toContain("--relay-use-tls"); { env?: NodeJS.ProcessEnv },
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true"); ];
expect(argv).toContain("--relay-use-tls");
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
}); });
}); });
@@ -1,4 +1,4 @@
import { spawnSync, type ChildProcess } from "node:child_process"; import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
@@ -68,28 +68,6 @@ interface ProcessExitDetails {
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails); type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
export interface ForegroundDaemonProcessResult {
status: number | null;
error?: Error;
}
export interface DaemonLaunchRuntime {
resolveRunnerEntry(): string;
resolveHome(env: NodeJS.ProcessEnv): string;
spawnDetached(
command: string,
args: string[],
options: Parameters<typeof spawnProcess>[2],
): DetachedDaemonProcess;
spawnForeground(
command: string,
args: string[],
options: Parameters<typeof spawnSync>[2],
): ForegroundDaemonProcessResult;
}
const DETACHED_STARTUP_GRACE_MS = 1200; const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100; const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log"; const DAEMON_LOG_FILENAME = "daemon.log";
@@ -100,13 +78,6 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
spawnDetached: spawnProcess,
spawnForeground: spawnSync,
};
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }); const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
@@ -424,18 +395,17 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
export async function startLocalDaemonDetached( export async function startLocalDaemonDetached(
options: DaemonStartOptions, options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): Promise<DetachedStartResult> { ): Promise<DetachedStartResult> {
if (options.listen && options.port) { if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together"); throw new Error("Cannot use --listen and --port together");
} }
const daemonRunnerEntry = runtime.resolveRunnerEntry(); const daemonRunnerEntry = resolveDaemonRunnerEntry();
const childEnv = buildChildEnv(options); const childEnv = buildChildEnv(options);
const paseoHome = runtime.resolveHome(childEnv); const paseoHome = resolvePaseoHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME); const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const child = runtime.spawnDetached( const child = spawnProcess(
process.execPath, process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{ {
@@ -491,17 +461,14 @@ export async function startLocalDaemonDetached(
}; };
} }
export function startLocalDaemonForeground( export function startLocalDaemonForeground(options: DaemonStartOptions): number {
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): number {
if (options.listen && options.port) { if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together"); throw new Error("Cannot use --listen and --port together");
} }
const daemonRunnerEntry = runtime.resolveRunnerEntry(); const daemonRunnerEntry = resolveDaemonRunnerEntry();
const childEnv = buildChildEnv(options); const childEnv = buildChildEnv(options);
const result = runtime.spawnForeground( const result = spawnSync(
process.execPath, process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{ {
@@ -1,104 +0,0 @@
import type { DaemonClient } from "@getpaseo/server";
import type { CommandError, CommandOptions } from "../../output/index.js";
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
type CreatePaseoWorktreeRequest = Parameters<DaemonClient["createPaseoWorktree"]>[0];
export function buildCreateWorktreeRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off":
return buildBranchOffRequest(options, cwd);
case "checkout-branch":
return buildCheckoutBranchRequest(options, cwd);
case "checkout-pr":
return buildCheckoutPrRequest(options, cwd);
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
function buildBranchOffRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
worktreeSlug: options.newBranch,
action: "branch-off",
...(options.base ? { refName: options.base } : {}),
};
}
function buildCheckoutBranchRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return {
cwd,
action: "checkout",
refName: options.branch,
};
}
function buildCheckoutPrRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const prNumber = Number(options.prNumber);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return {
cwd,
action: "checkout",
githubPrNumber: prNumber,
};
}
function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message };
}
@@ -1,17 +1,21 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js"; import {
buildCreateWorktreeInput,
toDaemonCreateInput,
type WorktreeCreateOptions,
} from "./create.js";
const REPO = "/tmp/repo"; const REPO = "/tmp/repo";
function build(options: WorktreeCreateOptions): unknown { function build(options: WorktreeCreateOptions): unknown {
try { try {
return buildCreateWorktreeRequest(options, REPO); return buildCreateWorktreeInput(options, REPO);
} catch (err) { } catch (err) {
return err; return err;
} }
} }
describe("buildCreateWorktreeRequest", () => { describe("buildCreateWorktreeInput", () => {
it("requires --mode", () => { it("requires --mode", () => {
expect(build({})).toMatchObject({ code: "MISSING_MODE" }); expect(build({})).toMatchObject({ code: "MISSING_MODE" });
}); });
@@ -24,20 +28,17 @@ describe("buildCreateWorktreeRequest", () => {
expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" }); expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" });
}); });
it("branch-off builds a daemon request with a new branch", () => { it("branch-off parses with new branch only", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({ expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({
cwd: REPO, cwd: REPO,
worktreeSlug: "feature-x", target: { mode: "branch-off", newBranch: "feature-x" },
action: "branch-off",
}); });
}); });
it("branch-off includes the base ref when provided", () => { it("branch-off parses with base ref", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({ expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({
cwd: REPO, cwd: REPO,
worktreeSlug: "feature-x", target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
action: "branch-off",
refName: "main",
}); });
}); });
@@ -45,11 +46,10 @@ describe("buildCreateWorktreeRequest", () => {
expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" }); expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" });
}); });
it("checkout-branch builds a checkout request for the branch", () => { it("checkout-branch parses with branch", () => {
expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({ expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({
cwd: REPO, cwd: REPO,
action: "checkout", target: { mode: "checkout-branch", branch: "feat/x" },
refName: "feat/x",
}); });
}); });
@@ -69,8 +69,62 @@ describe("buildCreateWorktreeRequest", () => {
}); });
}); });
it("checkout-pr builds a checkout request for the pull request", () => { it("checkout-pr parses positive integers", () => {
expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({ expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
});
});
});
describe("toDaemonCreateInput", () => {
it("maps branch-off without base", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
});
});
it("maps branch-off with base ref", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
});
});
it("maps checkout-branch to action=checkout + refName", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-branch", branch: "feat/x" },
}),
).toEqual({
cwd: REPO,
action: "checkout",
refName: "feat/x",
});
});
it("maps checkout-pr to action=checkout + githubPrNumber", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
}),
).toEqual({
cwd: REPO, cwd: REPO,
action: "checkout", action: "checkout",
githubPrNumber: 42, githubPrNumber: 42,
+112 -4
View File
@@ -2,8 +2,12 @@ import path from "node:path";
import type { Command } from "commander"; import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/server"; import type { DaemonClient } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; import type {
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js"; CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
export interface WorktreeCreateResult { export interface WorktreeCreateResult {
name: string; name: string;
@@ -20,6 +24,110 @@ export const createSchema: OutputSchema<WorktreeCreateResult> = {
], ],
}; };
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
export type WorktreeCreateTarget =
| { mode: "branch-off"; newBranch: string; base?: string }
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
export interface ParsedWorktreeCreateInput {
cwd: string;
target: WorktreeCreateTarget;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
export function buildCreateWorktreeInput(
options: WorktreeCreateOptions,
cwd: string,
): ParsedWorktreeCreateInput {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off": {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
target: {
mode: "branch-off",
newBranch: options.newBranch,
...(options.base ? { base: options.base } : {}),
},
};
}
case "checkout-branch": {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return { cwd, target: { mode: "checkout-branch", branch: options.branch } };
}
case "checkout-pr": {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const parsed = Number(options.prNumber);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return { cwd, target: { mode: "checkout-pr", prNumber: parsed } };
}
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
switch (parsed.target.mode) {
case "branch-off":
return {
cwd: parsed.cwd,
worktreeSlug: parsed.target.newBranch,
action: "branch-off" as const,
...(parsed.target.base ? { refName: parsed.target.base } : {}),
};
case "checkout-branch":
return {
cwd: parsed.cwd,
action: "checkout" as const,
refName: parsed.target.branch,
};
case "checkout-pr":
return {
cwd: parsed.cwd,
action: "checkout" as const,
githubPrNumber: parsed.target.prNumber,
};
default:
throw new Error("unreachable");
}
}
function cmdError(code: string, message: string, details?: string): CommandError { function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message }; return details ? { code, message, details } : { code, message };
} }
@@ -29,7 +137,7 @@ export async function runCreateCommand(
_command: Command, _command: Command,
): Promise<SingleResult<WorktreeCreateResult>> { ): Promise<SingleResult<WorktreeCreateResult>> {
const cwd = options.cwd ?? process.cwd(); const cwd = options.cwd ?? process.cwd();
const request = buildCreateWorktreeRequest(options, cwd); const parsed = buildCreateWorktreeInput(options, cwd);
const host = getDaemonHost({ host: options.host }); const host = getDaemonHost({ host: options.host });
let client: DaemonClient; let client: DaemonClient;
@@ -45,7 +153,7 @@ export async function runCreateCommand(
} }
try { try {
const response = await client.createPaseoWorktree(request); const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
const workspace = response.workspace; const workspace = response.workspace;
if (!workspace || response.error) { if (!workspace || response.error) {
+5 -22
View File
@@ -8,26 +8,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
async function waitForLoopInList(
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
id: string,
) {
for (let attempt = 0; attempt < 20; attempt++) {
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
if (listedJson.some((item: { id: string }) => item.id === id)) {
return listedJson;
}
await sleep(250);
}
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
return JSON.parse(listed.stdout);
}
console.log("=== Loop And Schedule Command Tests ===\n"); console.log("=== Loop And Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 }); const ctx = await createE2ETestContext({ timeout: 30000 });
@@ -163,10 +143,13 @@ try {
const runJson = JSON.parse(run.stdout); const runJson = JSON.parse(run.stdout);
assert.strictEqual(runJson.name, "smoke-loop"); assert.strictEqual(runJson.name, "smoke-loop");
const listedJson = await waitForLoopInList(ctx, runJson.id); const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
assert( assert(
listedJson.some((item: { id: string }) => item.id === runJson.id), listedJson.some((item: { id: string }) => item.id === runJson.id),
JSON.stringify(listedJson), listed.stdout,
); );
async function pollStatus(attempt: number): Promise<string> { async function pollStatus(attempt: number): Promise<string> {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.74", "version": "0.1.71",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
@@ -1,53 +0,0 @@
import { ipcMain, shell } from "electron";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener";
vi.mock("electron", () => ({
ipcMain: { handle: vi.fn() },
shell: { openExternal: vi.fn() },
}));
function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise<void> {
registerOpenerHandlers();
const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
return channel === "paseo:opener:openUrl";
})?.[1];
if (typeof handler !== "function") {
throw new Error("open URL handler was not registered");
}
return handler as (_event: unknown, url: unknown) => Promise<void>;
}
describe("desktop opener", () => {
beforeEach(() => {
vi.mocked(ipcMain.handle).mockReset();
vi.mocked(shell.openExternal).mockReset();
});
it("allows only http and https external URLs", () => {
expect(isAllowedExternalUrl("https://example.com/path")).toBe(true);
expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true);
expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false);
expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false);
expect(isAllowedExternalUrl("paseo://settings")).toBe(false);
expect(isAllowedExternalUrl("/relative/path")).toBe(false);
expect(isAllowedExternalUrl(null)).toBe(false);
});
it("opens allowed URLs through Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await handler({}, "https://example.com");
expect(shell.openExternal).toHaveBeenCalledWith("https://example.com");
});
it("rejects blocked URLs before invoking Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});
+1 -19
View File
@@ -1,25 +1,7 @@
import { shell, ipcMain } from "electron"; import { shell, ipcMain } from "electron";
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
export function isAllowedExternalUrl(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
try {
const url = new URL(value);
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol);
} catch {
return false;
}
}
export function registerOpenerHandlers(): void { export function registerOpenerHandlers(): void {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => { ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
if (!isAllowedExternalUrl(url)) {
throw new Error("Unsupported external URL");
}
await shell.openExternal(url); await shell.openExternal(url);
}); });
} }
-1
View File
@@ -62,7 +62,6 @@ function resolveShellEnv(): Record<string, string> | undefined {
const result = spawnSync(shell, [...shellArgs, command], { const result = spawnSync(shell, [...shellArgs, command], {
encoding: "utf8", encoding: "utf8",
timeout: RESOLVE_TIMEOUT_MS, timeout: RESOLVE_TIMEOUT_MS,
windowsHide: true,
env: { env: {
...shellEnv, ...shellEnv,
ELECTRON_RUN_AS_NODE: "1", ELECTRON_RUN_AS_NODE: "1",
-2
View File
@@ -191,7 +191,6 @@ if (forcedUserDataDir) {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8", encoding: "utf-8",
timeout: 3000, timeout: 3000,
windowsHide: true,
}).trim(); }).trim();
devWorktreeName = path.basename(topLevel); devWorktreeName = path.basename(topLevel);
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge. // Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
@@ -201,7 +200,6 @@ if (forcedUserDataDir) {
cwd: topLevel, cwd: topLevel,
encoding: "utf-8", encoding: "utf-8",
timeout: 3000, timeout: 3000,
windowsHide: true,
}).trim(), }).trim(),
); );
const isWorktree = path.resolve(topLevel, ".git") !== commonDir; const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.74", "version": "0.1.71",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.74", "version": "0.1.71",
"files": [ "files": [
"dist" "dist"
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.74", "version": "0.1.71",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist" "dist"
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.74", "version": "0.1.71",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -58,14 +58,14 @@
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133", "@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.74", "@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.74", "@getpaseo/relay": "0.1.71",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2", "@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0", "@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
@@ -1,70 +0,0 @@
import { expect, test } from "vitest";
import { buildArchivedAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord } from "./agent-storage.js";
const BASE_RECORD: StoredAgentRecord = {
id: "agent-1",
provider: "codex",
cwd: "/workspace/project",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
labels: {},
lastStatus: "idle",
config: null,
};
test("archives a stored agent without changing terminal statuses", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["idle", "error", "closed"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe(status);
expect(archived.archivedAt).toBe("2025-01-03T00:00:00.000Z");
expect(archived.updatedAt).toBe(BASE_RECORD.updatedAt);
}
});
test("archives busy stored agents as idle", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["initializing", "running"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe("idle");
}
});
test("clears persisted attention when archiving", () => {
const archived = buildArchivedAgentRecord(
{
...BASE_RECORD,
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: "2025-01-02T12:00:00.000Z",
},
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived).toMatchObject({
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
});
test("can stamp updatedAt to the archive timestamp", () => {
const archived = buildArchivedAgentRecord(BASE_RECORD, {
archivedAt: "2025-01-03T00:00:00.000Z",
updatedAt: "2025-01-03T00:00:00.000Z",
});
expect(archived.updatedAt).toBe("2025-01-03T00:00:00.000Z");
});
@@ -1,30 +0,0 @@
import type { StoredAgentRecord } from "./agent-storage.js";
export type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface BuildArchivedAgentRecordOptions {
archivedAt?: string;
updatedAt?: string;
}
export function buildArchivedAgentRecord(
record: StoredAgentRecord,
options?: BuildArchivedAgentRecordOptions,
): ArchivedStoredAgentRecord {
const archivedAt = options?.archivedAt ?? new Date().toISOString();
return {
...record,
archivedAt,
updatedAt: options?.updatedAt ?? record.updatedAt,
lastStatus: normalizeArchivedStatus(record.lastStatus),
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
}
function normalizeArchivedStatus(
status: StoredAgentRecord["lastStatus"],
): StoredAgentRecord["lastStatus"] {
return status === "running" || status === "initializing" ? "idle" : status;
}
@@ -35,7 +35,6 @@ import type {
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import { import {
InMemoryAgentTimelineStore, InMemoryAgentTimelineStore,
@@ -67,6 +66,7 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
}; };
type TimeoutResult = "completed" | "timed_out"; type TimeoutResult = "completed" | "timed_out";
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface TimeoutOptions { interface TimeoutOptions {
operation: Promise<void>; operation: Promise<void>;
@@ -1071,7 +1071,19 @@ export class AgentManager {
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> { private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const registry = this.requireRegistry(); const registry = this.requireRegistry();
const archivedAt = new Date().toISOString(); const archivedAt = new Date().toISOString();
const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt }); const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const archivedRecord: ArchivedStoredAgentRecord = {
...record,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await registry.upsert(archivedRecord); await registry.upsert(archivedRecord);
@@ -1247,7 +1259,19 @@ export class AgentManager {
throw new Error(`Agent not found: ${agentId}`); throw new Error(`Agent not found: ${agentId}`);
} }
const nextRecord = buildArchivedAgentRecord(record, { archivedAt }); const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const nextRecord: StoredAgentRecord = {
...record,
archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await registry.upsert(nextRecord); await registry.upsert(nextRecord);
await this.archiveNativeSessionBestEffort(record.provider, record.persistence); await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
@@ -230,7 +230,6 @@ export function findExecutable(name: string): string | null {
const result = execFileSync(cmd, [trimmed], { const result = execFileSync(cmd, [trimmed], {
encoding: "utf8", encoding: "utf8",
env: createProviderEnv({ baseEnv: process.env }), env: createProviderEnv({ baseEnv: process.env }),
windowsHide: true,
}).trim(); }).trim();
const lines = result.split(/\r?\n/).filter((l: string) => l.trim()); const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
const candidate = lines.at(-1)?.trim() ?? null; const candidate = lines.at(-1)?.trim() ?? null;
@@ -1,11 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { __codexAppServerInternals } from "./codex-app-server-agent.js"; import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex"; const CODEX_PROVIDER = "codex";
@@ -31,7 +27,39 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
}, },
]; ];
type CodexFeaturesTestSession = AgentSession; interface CodexRequestFn {
(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
interface CodexClientLike {
request: CodexRequestFn;
}
interface CodexSessionTestAccess {
client: CodexClientLike | null;
connected: boolean;
currentThreadId: string | null;
serviceTier: "fast" | null;
planModeEnabled: boolean;
cachedRuntimeInfo: unknown;
ensureThreadLoaded: () => Promise<void>;
ensureThread: () => Promise<void>;
buildUserInput: (...args: unknown[]) => Promise<unknown>;
resolveSlashCommandInvocation: (...args: unknown[]) => Promise<unknown>;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
}
type CodexFeaturesTestSession = AgentSession & {
connected: boolean;
currentThreadId: string | null;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
};
function asInternals(session: CodexFeaturesTestSession): CodexSessionTestAccess {
return session as unknown as CodexSessionTestAccess;
}
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig { function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return { return {
@@ -43,36 +71,28 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
}; };
} }
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): { function createSession(
session: CodexFeaturesTestSession; configOverrides: Partial<AgentSessionConfig> = {},
appServer: FakeCodexAppServer; ): CodexFeaturesTestSession {
} {
const config = createConfig(configOverrides); const config = createConfig(configOverrides);
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession( const session = new __codexAppServerInternals.CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER }, { ...config, provider: CODEX_PROVIDER },
null, null,
createTestLogger(), createTestLogger(),
async () => appServer.child, () => {
) as CodexFeaturesTestSession; throw new Error("Test session cannot spawn Codex app-server");
return { session, appServer }; },
} ) as unknown as CodexFeaturesTestSession;
session.connected = true;
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{ session.currentThreadId = "test-thread";
session: CodexFeaturesTestSession; session.collaborationModes = TEST_COLLABORATION_MODES;
appServer: FakeCodexAppServer; session.refreshResolvedCollaborationMode();
}> { return session;
const harness = createSessionHarness(configOverrides);
await harness.session.connect();
harness.appServer.assertNoErrors();
return harness;
} }
describe("Codex app-server provider features", () => { describe("Codex app-server provider features", () => {
test("features returns fast and plan toggles when supported", async () => { test("features returns fast and plan toggles when supported", async () => {
const { session } = await createConnectedSession(); const session = createSession();
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
@@ -120,8 +140,8 @@ describe("Codex app-server provider features", () => {
]); ]);
}); });
test("features returns only plan toggle when model does not support fast mode", async () => { test("features returns only plan toggle when model does not support fast mode", () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" }); const session = createSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
@@ -137,56 +157,49 @@ describe("Codex app-server provider features", () => {
}); });
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ expect(asInternals(session).serviceTier).toBe("fast");
serviceTier: "fast",
});
}); });
test("setFeature('fast_mode', false) clears serviceTier to null", async () => { test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
const { session, appServer } = await createConnectedSession({ const session = createSession({
featureValues: { fast_mode: true }, featureValues: { fast_mode: true },
}); });
await session.setFeature?.("fast_mode", false); await session.setFeature?.("fast_mode", false);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ expect(asInternals(session).serviceTier).toBeNull();
serviceTier: expect.anything(),
});
}); });
test("setFeature invalidates runtime info", async () => { test("setFeature invalidates cachedRuntimeInfo", async () => {
const { session } = await createConnectedSession(); const session = createSession();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({ await session.getRuntimeInfo();
extra: { collaborationMode: "Plan" }, expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
});
await session.setFeature?.("plan_mode", true); await session.setFeature?.("fast_mode", true);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ expect(asInternals(session).cachedRuntimeInfo).toBeNull();
extra: { collaborationMode: "Plan" },
});
}); });
test("setFeature throws for unknown feature ids", async () => { test("setFeature throws for unknown feature ids", async () => {
const { session } = createSessionHarness(); const session = createSession();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow( await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature", "Unknown Codex feature: unknown_feature",
); );
}); });
test("constructor restores feature flags from config.featureValues", async () => { test("constructor restores feature flags from config.featureValues", () => {
const { session, appServer } = await createConnectedSession({ const session = createSession({
featureValues: { fast_mode: true, plan_mode: true }, featureValues: { fast_mode: true, plan_mode: true },
}); });
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(true);
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
type: "toggle", type: "toggle",
@@ -207,29 +220,41 @@ describe("Codex app-server provider features", () => {
value: true, value: true,
}, },
]); ]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
collaborationMode: expect.objectContaining({
mode: "plan",
}),
});
}); });
test("startTurn includes serviceTier when fast mode is enabled", async () => { test("startTurn includes serviceTier when fast mode is enabled", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ expect(request).toHaveBeenCalledWith(
serviceTier: "fast", "turn/start",
}); expect.objectContaining({
serviceTier: "fast",
}),
expect.any(Number),
);
}); });
test("setModel clears fast mode when switching to an unsupported model", async () => { test("setModel clears fast mode when switching to an unsupported model", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo"); await session.setModel("gpt-3.5-turbo");
@@ -245,23 +270,41 @@ describe("Codex app-server provider features", () => {
value: false, value: false,
}, },
]); ]);
expect(asInternals(session).serviceTier).toBeNull();
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ expect(request).toHaveBeenCalledWith(
serviceTier: expect.anything(), "turn/start",
}); expect.not.objectContaining({
serviceTier: expect.anything(),
}),
expect.any(Number),
);
}); });
test("startTurn switches collaboration mode when plan mode is enabled", async () => { test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("plan_mode", true); await session.setFeature?.("plan_mode", true);
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ expect(request).toHaveBeenCalledWith(
collaborationMode: expect.objectContaining({ "turn/start",
mode: "plan", expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
}), }),
}); expect.any(Number),
);
}); });
}); });
@@ -1,11 +1,9 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { PassThrough } from "node:stream";
import type { import type {
AgentLaunchContext, AgentLaunchContext,
@@ -19,9 +17,9 @@ import {
codexAppServerTurnInputFromPrompt, codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js"; } from "./codex-app-server-agent.js";
import { import {
createFakeCodexAppServer, createCodexAppServerChildProcessStub,
waitForNextPermission, TestCodexAppServerPeer,
} from "./codex/test-utils/fake-app-server.js"; } from "./codex/test-utils/test-app-server-peer.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js"; import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
@@ -102,6 +100,34 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")"); return match[1].replace(/\\\)/g, ")");
} }
function waitForNextPermission(
session: AgentSession,
events: AgentStreamEvent[],
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
const existing = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}
describe("Codex app-server provider", () => { describe("Codex app-server provider", () => {
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => { test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
const requests: Array<{ method: string; params: unknown }> = []; const requests: Array<{ method: string; params: unknown }> = [];
@@ -165,21 +191,15 @@ describe("Codex app-server provider", () => {
test("disposes an unresponsive app-server child with SIGKILL", async () => { test("disposes an unresponsive app-server child with SIGKILL", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const child = new EventEmitter() as ChildProcessWithoutNullStreams; const child = createCodexAppServerChildProcessStub({ exitOnKill: false });
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger()); const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
try { try {
const disposePromise = client.dispose(); const disposePromise = client.dispose();
expect(child.kill).toHaveBeenCalledWith("SIGTERM"); expect(child.killSignals).toEqual(["SIGTERM"]);
await vi.advanceTimersByTimeAsync(2_000); await vi.advanceTimersByTimeAsync(2_000);
expect(child.kill).toHaveBeenCalledWith("SIGKILL"); expect(child.killSignals).toEqual(["SIGTERM", "SIGKILL"]);
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
await expect(disposePromise).resolves.toBeUndefined(); await expect(disposePromise).resolves.toBeUndefined();
@@ -189,7 +209,7 @@ describe("Codex app-server provider", () => {
}); });
test("round-trips server-initiated command approvals through the real app-server transport", async () => { test("round-trips server-initiated command approvals through the real app-server transport", async () => {
const appServer = createFakeCodexAppServer({ const peer = new TestCodexAppServerPeer({
initialize: () => ({}), initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }), "collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }), "skills/list": () => ({ data: [] }),
@@ -198,21 +218,28 @@ describe("Codex app-server provider", () => {
createConfig({ cwd: "/workspace/project" }), createConfig({ cwd: "/workspace/project" }),
null, null,
createTestLogger(), createTestLogger(),
async () => appServer.child, async () => peer.child,
); );
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect(); await session.connect();
appServer.assertNoErrors(); peer.assertNoErrors();
const permissionRequested = waitForNextPermission(session); const permissionRequested = waitForNextPermission(session, events);
appServer.requestCommandApproval({
itemId: "exec-approval-1", peer.writeRequest(
threadId: "thread-1", "item/commandExecution/requestApproval",
turnId: "turn-1", {
command: "git restore README.md", itemId: "exec-approval-1",
cwd: "/workspace/project", threadId: "thread-1",
reason: "requires escalated permissions", turnId: "turn-1",
}); command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
},
41,
);
const permissionEvent = await permissionRequested; const permissionEvent = await permissionRequested;
expect(permissionEvent.request).toMatchObject({ expect(permissionEvent.request).toMatchObject({
@@ -235,10 +262,11 @@ describe("Codex app-server provider", () => {
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" }); await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({ await expect(peer.waitForResponse(41, { decision: "accept" })).resolves.toMatchObject({
decision: "accept", id: 41,
result: { decision: "accept" },
}); });
appServer.assertNoErrors(); peer.assertNoErrors();
await session.close(); await session.close();
}); });
@@ -252,8 +280,12 @@ describe("Codex app-server provider", () => {
path.join(repoSkillDir, "SKILL.md"), path.join(repoSkillDir, "SKILL.md"),
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n", "---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
); );
const resolvedRepoRoots: string[] = [];
const workspaceGitService = { const workspaceGitService = {
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")), resolveRepoRoot: async (pathToResolve: string) => {
resolvedRepoRoots.push(pathToResolve);
return path.join(tempDir, "repo");
},
}; };
try { try {
@@ -264,7 +296,7 @@ describe("Codex app-server provider", () => {
description: "Ship changes carefully.", description: "Ship changes carefully.",
argumentHint: "", argumentHint: "",
}); });
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd); expect(resolvedRepoRoots).toEqual([cwd]);
} finally { } finally {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
} }
@@ -903,52 +935,6 @@ describe("Codex app-server provider", () => {
}); });
}); });
test("keeps the parent sub-agent running when a child command fails during the child turn", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-child-command-failure",
tool: "spawnAgent",
status: "completed",
prompt: "Fix the regression test-first.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "running", message: null },
},
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
item: {
type: "commandExecution",
id: "child-failing-command",
status: "failed",
command: "npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts",
aggregatedOutput: "expected false to be true",
exitCode: 1,
error: { message: "Command failed" },
},
});
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Fix the regression test-first.",
},
});
});
test("loads Codex persisted history from the app-server thread", async () => { test("loads Codex persisted history from the app-server thread", async () => {
const session = createSession(); const session = createSession();
const requests: Array<{ method: string; params: unknown }> = []; const requests: Array<{ method: string; params: unknown }> = [];
@@ -1804,14 +1790,7 @@ describe("Codex persisted sessions", () => {
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>( castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider, provider,
).spawnAppServer = async () => { ).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams; return createCodexAppServerChildProcessStub();
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
}; };
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" }); const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });
@@ -3786,7 +3786,10 @@ class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId);
} }
this.emitSubAgentActivityUpdate(callId, "running"); this.emitSubAgentActivityUpdate(
callId,
timelineItem.type === "tool_call" && timelineItem.status === "failed" ? "failed" : "running",
);
} }
private shouldSkipCompletedThreadItem( private shouldSkipCompletedThreadItem(
@@ -1,22 +1,20 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js"; import { CodexAppServerClient } from "./app-server-transport.js";
import { TestCodexAppServerPeer } from "./test-utils/test-app-server-peer.js";
describe("Codex app-server transport", () => { describe("Codex app-server transport", () => {
test("ignores non-JSON stdout lines without dropping pending requests", async () => { test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createCodexAppServerChildProcess(); const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(child, createTestLogger()); const client = new CodexAppServerClient(peer.child, createTestLogger());
const request = client.request("model/list", {}); const request = client.request("model/list", {});
child.stdout.write("Codex ha iniciado en modo localizado\n"); peer.writeNonJsonStdout("Codex ha iniciado en modo localizado");
child.stdout.write('{"id":1,"result":{"data":[]}}\n'); peer.writeResponse(1, { data: [] });
await expect(request).resolves.toEqual({ data: [] }); await expect(request).resolves.toEqual({ data: [] });
child.stdout.end(); peer.close();
child.stderr.end();
child.stdin.end();
}); });
test.each([ test.each([
@@ -25,23 +23,19 @@ describe("Codex app-server transport", () => {
"item/tool/requestUserInput", "item/tool/requestUserInput",
"tool/requestUserInput", "tool/requestUserInput",
])("answers server-initiated %s requests through registered handlers", async (method) => { ])("answers server-initiated %s requests through registered handlers", async (method) => {
const child = createCodexAppServerChildProcess(); const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(child, createTestLogger()); const client = new CodexAppServerClient(peer.child, createTestLogger());
const handlerCalls: unknown[] = []; const handlerCalls: unknown[] = [];
client.setRequestHandler(method, async (params) => { client.setRequestHandler(method, async (params) => {
handlerCalls.push(params); handlerCalls.push(params);
return { ok: true }; return { ok: true };
}); });
const response = new Promise<string>((resolve) => { const response = peer.nextPaseoOutputLine();
child.stdin.once("data", (chunk) => resolve(chunk.toString())); peer.writeRequest(method);
});
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n'); await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
expect(handlerCalls).toEqual([{}]); expect(handlerCalls).toEqual([{}]);
child.stdout.end(); peer.close();
child.stderr.end();
child.stdin.end();
}); });
}); });
@@ -1,228 +0,0 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
};
export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess;
assertNoErrors(): void;
waitForTurnStart(): Promise<JsonObject>;
requestCommandApproval(params: {
itemId: string;
threadId: string;
turnId: string;
command: string;
cwd: string;
reason: string;
}): void;
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
}
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export function createFakeCodexAppServer(
handlers: Record<string, FakeCodexAppServerHandler> = {},
): FakeCodexAppServer {
const child = createCodexAppServerChildProcess();
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"config/read": () => ({ config: {} }),
getUserSavedConfig: () => ({ config: {} }),
"model/list": () => ({
data: [
{
id: "gpt-5.4",
isDefault: true,
defaultReasoningEffort: "medium",
},
],
}),
"skills/list": () => ({ data: [] }),
"thread/start": () => ({ thread: { id: "thread-1" } }),
"thread/loaded/list": () => ({ data: [] }),
"thread/resume": () => ({}),
"turn/start": () => ({}),
...handlers,
};
const messages: JsonObject[] = [];
const errors: Error[] = [];
const approvalRequestIds = new Map<string, number>();
const waiters = new Set<{
predicate: (message: JsonObject) => boolean;
resolve: (message: JsonObject) => void;
}>();
let buffer = "";
let nextServerRequestId = 1;
function processMessage(message: JsonObject): void {
messages.push(message);
for (const waiter of Array.from(waiters)) {
if (waiter.predicate(message)) {
waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = responseHandlers[message.method];
if (!handler) {
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
return undefined;
})
.catch((error) => {
child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const parsed: unknown = JSON.parse(line);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
processMessage(parsed as JsonObject);
}
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
});
function waitForMessage(
predicate: (message: JsonObject) => boolean,
label: string,
): Promise<JsonObject> {
const existing = messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonObject) => {
clearTimeout(timeout);
resolve(message);
},
};
waiters.add(waiter);
});
}
return {
child,
assertNoErrors() {
if (errors.length > 0) {
throw errors[0];
}
},
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "turn/start",
"turn start request",
);
return toJsonObject(message.params);
},
requestCommandApproval(params) {
const requestId = nextServerRequestId;
nextServerRequestId += 1;
approvalRequestIds.set(params.itemId, requestId);
child.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: requestId,
method: "item/commandExecution/requestApproval",
params,
})}\n`,
);
},
async waitForCommandApprovalDecision(itemId) {
const requestId = approvalRequestIds.get(itemId);
if (requestId === undefined) {
throw new Error(`No pending fake Codex app-server approval for ${itemId}`);
}
const message = await waitForMessage(
(candidate) =>
candidate.id === requestId && !("method" in candidate) && "result" in candidate,
"command approval response",
);
return message.result;
},
};
}
function toJsonObject(value: unknown): JsonObject {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonObject;
}
return {};
}
export function waitForNextPermission(
session: AgentSession,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}
@@ -0,0 +1,183 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { isDeepStrictEqual } from "node:util";
interface JsonRecord {
[key: string]: unknown;
}
type RequestHandler = (params: unknown) => unknown;
type StubbedCodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killSignals: Array<NodeJS.Signals | number | null>;
};
function isJsonRecord(value: unknown): value is JsonRecord {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function parseJsonLine(line: string): JsonRecord | null {
const parsed: unknown = JSON.parse(line);
return isJsonRecord(parsed) ? parsed : null;
}
export function createCodexAppServerChildProcessStub(
options: { exitOnKill?: boolean } = {},
): StubbedCodexAppServerChildProcess {
const child = new EventEmitter() as StubbedCodexAppServerChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killSignals = [];
Object.defineProperty(child, "exitCode", { value: null, configurable: true });
Object.defineProperty(child, "signalCode", { value: null, configurable: true });
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killSignals.push(signal ?? null);
if (options.exitOnKill !== false) {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
}
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export class TestCodexAppServerPeer {
readonly child: StubbedCodexAppServerChildProcess;
private readonly messages: JsonRecord[] = [];
private readonly errors: Error[] = [];
private readonly waiters = new Set<{
predicate: (message: JsonRecord) => boolean;
resolve: (message: JsonRecord) => void;
}>();
private buffer = "";
constructor(handlers: Record<string, RequestHandler> = {}) {
this.child = createCodexAppServerChildProcessStub();
this.child.stdin.on("data", (chunk) => {
this.acceptPaseoOutput(chunk.toString(), handlers);
});
}
writeNonJsonStdout(line: string): void {
this.child.stdout.write(`${line}\n`);
}
writeResponse(id: number, result: unknown): void {
this.child.stdout.write(`${JSON.stringify({ id, result })}\n`);
}
writeRequest(method: string, params: unknown = {}, id = 7): void {
this.child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
}
nextPaseoOutputLine(): Promise<string> {
return new Promise((resolve) => {
this.child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
}
async waitForResponse(id: number, result: unknown): Promise<JsonRecord> {
return this.waitForMessage(
(message) =>
message.id === id && !("method" in message) && isDeepStrictEqual(message.result, result),
`response ${id}`,
);
}
assertNoErrors(): void {
if (this.errors.length > 0) {
throw this.errors[0];
}
}
close(): void {
this.child.stdout.end();
this.child.stderr.end();
this.child.stdin.end();
}
private acceptPaseoOutput(chunk: string, handlers: Record<string, RequestHandler>): void {
this.buffer += chunk;
for (;;) {
const newlineIndex = this.buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const message = parseJsonLine(line);
if (message) {
this.processPaseoMessage(message, handlers);
}
} catch (error) {
this.errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
}
private processPaseoMessage(message: JsonRecord, handlers: Record<string, RequestHandler>): void {
this.messages.push(message);
for (const waiter of Array.from(this.waiters)) {
if (waiter.predicate(message)) {
this.waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = handlers[message.method];
if (!handler) {
this.errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
this.writeResponse(message.id as number, result);
return undefined;
})
.catch((error) => {
this.child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
private waitForMessage(
predicate: (message: JsonRecord) => boolean,
label: string,
): Promise<JsonRecord> {
const existing = this.messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonRecord) => {
clearTimeout(timeout);
resolve(message);
},
};
this.waiters.add(waiter);
});
}
}
@@ -78,14 +78,6 @@ async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Pr
return result; return result;
} }
function createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
function isBinaryInstalled(binary: string): boolean { function isBinaryInstalled(binary: string): boolean {
try { try {
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim(); const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
@@ -574,160 +566,6 @@ describe("OpenCode adapter context-window normalization", () => {
}); });
describe("OpenCode adapter startTurn error handling", () => { describe("OpenCode adapter startTurn error handling", () => {
test("unwraps OpenCode global event payloads during a turn", async () => {
const globalEvents = [
{
payload: {
type: "server.connected",
properties: {},
},
},
{
directory: "/tmp/other",
payload: {
type: "message.part.delta",
properties: {
sessionID: "other-session",
messageID: "msg_other",
partID: "prt_other",
field: "text",
delta: "ignore me",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.updated",
properties: {
info: {
id: "msg_assistant",
sessionID: "ses_unit_test",
role: "assistant",
},
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.part.delta",
properties: {
sessionID: "ses_unit_test",
messageID: "msg_assistant",
partID: "prt_text",
field: "text",
delta: "Hello from global",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: { type: "idle" },
},
},
},
];
const fakeClient = {
event: {
subscribe: vi.fn(),
},
global: {
event: vi.fn().mockResolvedValue({ stream: createAsyncIterable(globalEvents) }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const turn = await collectTurnEvents(streamSession(session, "hello"));
expect(fakeClient.global.event).toHaveBeenCalledWith({
signal: expect.any(AbortSignal),
sseMaxRetryAttempts: 0,
});
expect(fakeClient.event.subscribe).not.toHaveBeenCalled();
expect(turn.turnCompleted).toBe(true);
expect(turn.turnFailed).toBe(false);
expect(turn.assistantMessages.map((message) => message.text).join("")).toBe(
"Hello from global",
);
});
test("fails a turn when OpenCode retry status does not recover", async () => {
vi.useFakeTimers();
const retryStream: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let emitted = false;
return {
next: async () => {
if (!emitted) {
emitted = true;
return {
done: false,
value: {
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: {
type: "retry",
attempt: 1,
message: "model does not exist",
},
},
},
},
};
}
return new Promise(() => {});
},
};
},
};
const fakeClient = {
global: {
event: vi.fn().mockResolvedValue({ stream: retryStream }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.startTurn("hello");
await vi.advanceTimersByTimeAsync(10_000);
const failed = events.find((event) => event.type === "turn_failed");
expect(failed).toMatchObject({
type: "turn_failed",
error: expect.stringContaining("model does not exist"),
});
vi.useRealTimers();
});
test("deletes provider session on close when persistence is disabled", async () => { test("deletes provider session on close when persistence is disabled", async () => {
const fakeClient = { const fakeClient = {
session: { session: {
@@ -742,7 +580,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
new Map(), new Map(),
undefined, undefined,
false, false,
@@ -770,7 +607,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
await session.close(); await session.close();
@@ -779,29 +615,19 @@ describe("OpenCode adapter startTurn error handling", () => {
}); });
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => { test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
// Yield the server-connected event, then park forever. The adapter waits // Async iterable that never yields and never resolves. The IIFE in
// for that first event before sending the prompt. // startTurn synchronously hits the promptAsync throw and finishes the
// turn before this iterator is ever pulled, so the never-resolving
// promise inside next() is fine and gets garbage-collected.
const neverYieldingStream: AsyncIterable<OpenCodeEvent> = { const neverYieldingStream: AsyncIterable<OpenCodeEvent> = {
[Symbol.asyncIterator]: () => { [Symbol.asyncIterator]: () => ({
let emittedConnected = false; next: () => new Promise(() => {}),
return { }),
next: () => {
if (!emittedConnected) {
emittedConnected = true;
return Promise.resolve({
done: false,
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
});
}
return new Promise(() => {});
},
};
},
}; };
const fakeClient = { const fakeClient = {
global: { event: {
event: vi.fn().mockResolvedValue({ stream: neverYieldingStream }), subscribe: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
}, },
session: { session: {
promptAsync: vi.fn(() => { promptAsync: vi.fn(() => {
@@ -815,7 +641,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -830,58 +655,6 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(failed.error).toContain("boom: synchronous throw"); expect(failed.error).toContain("boom: synchronous throw");
} }
}); });
test("delays the next prompt until a slow interrupt abort settles", async () => {
vi.useFakeTimers();
const abortDeferred = createTestDeferred<{ data: boolean; error: undefined }>();
const promptAsync = vi.fn().mockResolvedValue({ data: {}, error: undefined });
const abort = vi
.fn()
.mockReturnValueOnce(abortDeferred.promise)
.mockResolvedValue({ data: true, error: undefined });
const fakeClient = {
global: {
event: vi.fn().mockImplementation(
async (options: {
signal: AbortSignal;
}): Promise<{ stream: AsyncIterable<OpenCodeEvent> }> => ({
stream: abortableOpenCodeStream(options.signal),
}),
),
},
session: {
promptAsync,
abort,
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
await session.startTurn("first");
expect(promptAsync).toHaveBeenCalledTimes(1);
const interruptPromise = session.interrupt();
await vi.advanceTimersByTimeAsync(2_000);
await interruptPromise;
expect(abort).toHaveBeenCalledTimes(1);
const secondTurnPromise = session.startTurn("second");
await vi.advanceTimersByTimeAsync(1_000);
expect(promptAsync).toHaveBeenCalledTimes(1);
abortDeferred.resolve({ data: true, error: undefined });
await secondTurnPromise;
expect(promptAsync).toHaveBeenCalledTimes(2);
await session.interrupt();
vi.useRealTimers();
});
}); });
describe("OpenCode persisted sessions", () => { describe("OpenCode persisted sessions", () => {
@@ -967,45 +740,3 @@ function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unk
mkdirSync(path.dirname(filePath), { recursive: true }); mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value), "utf8"); writeFileSync(filePath, JSON.stringify(value), "utf8");
} }
function createTestDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
return {
[Symbol.asyncIterator]: () => {
let emittedConnected = false;
return {
next: () => {
if (!emittedConnected) {
emittedConnected = true;
return Promise.resolve({
done: false,
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
});
}
return new Promise<IteratorResult<OpenCodeEvent>>((resolve) => {
if (signal.aborted) {
resolve({ done: true, value: undefined });
return;
}
signal.addEventListener("abort", () => resolve({ done: true, value: undefined }), {
once: true,
});
});
},
};
},
};
}
@@ -74,8 +74,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build"; const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access"; const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200; const OPENCODE_STORAGE_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
const OPENCODE_RETRY_STATUS_FAILURE_MS = 10_000;
const DEFAULT_MODES: AgentMode[] = [ const DEFAULT_MODES: AgentMode[] = [
{ {
@@ -820,32 +818,18 @@ async function readOpenCodeSessionTimeline(
} }
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> { async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
const parts = await readOpenCodeStoredParts(storageRoot, messageId);
return readOpenCodeTextFromParts(parts);
}
async function readOpenCodeStoredParts(
storageRoot: string,
messageId: string,
): Promise<OpenCodeStoredPart[]> {
const partRoot = path.join(storageRoot, "part", messageId); const partRoot = path.join(storageRoot, "part", messageId);
const partFiles = await findJsonFiles(partRoot); const partFiles = await findJsonFiles(partRoot);
const parts: OpenCodeStoredPart[] = []; const parts: OpenCodeStoredPart[] = [];
for (const file of partFiles) { for (const file of partFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema); const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
if (parsed) { if (parsed?.type === "text" && typeof parsed.text === "string") {
parts.push(parsed); parts.push(parsed);
} }
} }
return parts.sort(
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
);
}
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
return parts return parts
.filter((part) => part.type === "text" && typeof part.text === "string") .sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
.map((part) => part.text?.trim() ?? "") .map((part) => part.text?.trim() ?? "")
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
@@ -1000,7 +984,6 @@ export class OpenCodeAgentClient implements AgentClient {
client, client,
session.id, session.id,
this.logger, this.logger,
this.storageRoot,
new Map(this.modelContextWindows), new Map(this.modelContextWindows),
acquisition.release, acquisition.release,
options?.persistSession, options?.persistSession,
@@ -1042,10 +1025,8 @@ export class OpenCodeAgentClient implements AgentClient {
client, client,
handle.sessionId, handle.sessionId,
this.logger, this.logger,
this.storageRoot,
new Map(this.modelContextWindows), new Map(this.modelContextWindows),
acquisition.release, acquisition.release,
undefined,
); );
} catch (error) { } catch (error) {
acquisition.release(); acquisition.release();
@@ -2141,36 +2122,6 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve, reject }; return { promise, resolve, reject };
} }
const OPENCODE_TRACE_ENABLED = process.env.PASEO_OPENCODE_TRACE === "1";
function traceOpenCode(tag: string, data: Record<string, unknown> = {}): void {
if (!OPENCODE_TRACE_ENABLED) return;
const line = JSON.stringify({ ts: new Date().toISOString(), tag, ...data }, (_k, v) => {
if (v instanceof Error) return { name: v.name, message: v.message, stack: v.stack };
if (typeof v === "bigint") return v.toString();
return v;
});
process.stderr.write(`[opencode-trace] ${line}\n`);
}
function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null {
const record = readOpenCodeRecord(event);
if (!record) {
return null;
}
const payload = readOpenCodeRecord(record.payload);
if (typeof payload?.type === "string") {
return payload as unknown as OpenCodeEvent;
}
if (typeof record.type === "string") {
return record as unknown as OpenCodeEvent;
}
return null;
}
class OpenCodeAgentSession implements AgentSession { class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const; readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES; readonly capabilities = OPENCODE_CAPABILITIES;
@@ -2183,7 +2134,6 @@ class OpenCodeAgentSession implements AgentSession {
private currentMode: string = "default"; private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>(); private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
private pendingAbortPromise: Promise<void> | null = null;
private accumulatedUsage: AgentUsage = {}; private accumulatedUsage: AgentUsage = {};
private mcpConfigured = false; private mcpConfigured = false;
private mcpSetupPromise: Promise<void> | null = null; private mcpSetupPromise: Promise<void> | null = null;
@@ -2207,13 +2157,11 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null; private releaseServer: (() => void) | null;
private readonly persistSession: boolean; private readonly persistSession: boolean;
private deletedFromProvider = false; private deletedFromProvider = false;
private retryFailureTimer: ReturnType<typeof setTimeout> | null = null;
constructor( constructor(
config: OpenCodeAgentConfig, config: OpenCodeAgentConfig,
client: OpencodeClient, client: OpencodeClient,
sessionId: string, sessionId: string,
logger: Logger, logger: Logger,
_storageRoot: string,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(), modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void, releaseServer?: () => void,
persistSession = true, persistSession = true,
@@ -2275,18 +2223,9 @@ class OpenCodeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId; const turnId = this.activeForegroundTurnId;
const turnAbortController = this.abortController; const turnAbortController = this.abortController;
turnAbortController?.abort(); turnAbortController?.abort();
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until await this.client.session.abort({
// the running tool actually stops, which can be tens of seconds for sessionID: this.sessionId,
// long-running tools. Cap the wait so the user-visible cancel lands directory: this.config.cwd,
// quickly while still giving OpenCode a chance to confirm the abort
// cleanly. Drop the timeout once upstream returns abort acknowledgement
// before tool teardown.
const abortPromise = this.beginSessionAbort(turnId, "interrupt");
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId },
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
);
}); });
if (turnId) { if (turnId) {
this.finishForegroundTurn( this.finishForegroundTurn(
@@ -2296,46 +2235,6 @@ class OpenCodeAgentSession implements AgentSession {
} }
} }
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
const abortPromise = this.client.session
.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
})
.then(() => undefined)
.catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId, reason },
"OpenCode session.abort rejected",
);
});
const trackedAbortPromise = abortPromise.finally(() => {
if (this.pendingAbortPromise === trackedAbortPromise) {
this.pendingAbortPromise = null;
}
});
this.pendingAbortPromise = trackedAbortPromise;
return trackedAbortPromise;
}
private async awaitPendingAbortBeforeStartingTurn(): Promise<void> {
const pendingAbortPromise = this.pendingAbortPromise;
if (!pendingAbortPromise) {
return;
}
await withTimeout(
pendingAbortPromise,
OPENCODE_PENDING_ABORT_START_TIMEOUT_MS,
"OpenCode pending session.abort",
).catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId },
"OpenCode session.abort was still pending before starting the next turn",
);
});
}
async startTurn( async startTurn(
prompt: AgentPromptInput, prompt: AgentPromptInput,
options?: AgentRunOptions, options?: AgentRunOptions,
@@ -2343,13 +2242,11 @@ class OpenCodeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) { if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active"); throw new Error("A foreground turn is already active");
} }
await this.awaitPendingAbortBeforeStartingTurn();
this.runningToolCalls.clear(); this.runningToolCalls.clear();
this.subAgentsByCallId.clear(); this.subAgentsByCallId.clear();
this.subAgentCallIdByChildSessionId.clear(); this.subAgentCallIdByChildSessionId.clear();
this.pendingChildToolPartsBySessionId.clear(); this.pendingChildToolPartsBySessionId.clear();
this.clearRetryFailureTimer();
const turnAbortController = new AbortController(); const turnAbortController = new AbortController();
this.abortController = turnAbortController; this.abortController = turnAbortController;
await this.ensureMcpServersConfigured(); await this.ensureMcpServersConfigured();
@@ -2483,14 +2380,6 @@ class OpenCodeAgentSession implements AgentSession {
// SDK input validation) is caught alongside async rejections. A plain // SDK input validation) is caught alongside async rejections. A plain
// `.then().catch()` chain would let a sync throw escape unhandled. // `.then().catch()` chain would let a sync throw escape unhandled.
void (async () => { void (async () => {
traceOpenCode("promptAsync.start", {
turnId,
sessionId: this.sessionId,
model,
effectiveMode,
effectiveVariant,
partTypes: parts.map((p) => p.type),
});
try { try {
const promptResponse = await this.client.session.promptAsync({ const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId, sessionID: this.sessionId,
@@ -2509,12 +2398,6 @@ class OpenCodeAgentSession implements AgentSession {
...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}),
}); });
traceOpenCode("promptAsync.response", {
turnId,
hasError: promptResponse.error !== undefined,
error: promptResponse.error,
data: promptResponse.data,
});
if (promptResponse.error) { if (promptResponse.error) {
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
@@ -2526,13 +2409,6 @@ class OpenCodeAgentSession implements AgentSession {
); );
} }
} catch (error) { } catch (error) {
traceOpenCode("promptAsync.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
type: "turn_failed", type: "turn_failed",
@@ -2560,44 +2436,36 @@ class OpenCodeAgentSession implements AgentSession {
turnAbortController: AbortController, turnAbortController: AbortController,
subscriptionReady: Deferred<void>, subscriptionReady: Deferred<void>,
): Promise<void> { ): Promise<void> {
traceOpenCode("subscribe.start", { turnId, sessionId: this.sessionId, cwd: this.config.cwd });
try { try {
const result = await this.client.global.event({ const result = await this.client.event.subscribe(
signal: turnAbortController.signal, { directory: this.config.cwd },
sseMaxRetryAttempts: 0, { signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
}); );
let eventCount = 0; subscriptionReady.resolve();
let subscriptionReadyResolved = false;
for await (const rawEvent of result.stream) { for await (const event of result.stream) {
eventCount += 1; if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
if (!subscriptionReadyResolved) { break;
subscriptionReadyResolved = true;
traceOpenCode("subscribe.ready", { turnId, sessionId: this.sessionId });
subscriptionReady.resolve();
} }
const shouldContinue = await this.consumeOpenCodeStreamEvent({
rawEvent, const translated = await this.translateEvent(event);
eventCount, for (const e of translated) {
turnId, if (this.activeForegroundTurnId !== turnId) {
turnAbortController, return;
}); }
if (!shouldContinue) { if (e.type === "timeline" && e.item.type === "tool_call") {
return; this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
this.finishForegroundTurn(terminalEvent, turnId);
return;
}
this.notifySubscribers(e, turnId);
} }
} }
traceOpenCode("stream.eof", {
turnId,
eventCount,
aborted: turnAbortController.signal.aborted,
stillActive: this.activeForegroundTurnId === turnId,
});
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
traceOpenCode("turn.fail.eof", { turnId, eventCount });
if (!subscriptionReadyResolved) {
subscriptionReady.reject(new Error("OpenCode event stream ended before it became ready"));
}
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
type: "turn_failed", type: "turn_failed",
@@ -2608,11 +2476,6 @@ class OpenCodeAgentSession implements AgentSession {
); );
} }
} catch (error) { } catch (error) {
traceOpenCode("subscribe.error", {
turnId,
error:
error instanceof Error ? { name: error.name, message: error.message } : String(error),
});
subscriptionReady.reject(error); subscriptionReady.reject(error);
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn( this.finishForegroundTurn(
@@ -2641,75 +2504,10 @@ class OpenCodeAgentSession implements AgentSession {
} }
} }
private async consumeOpenCodeStreamEvent(params: {
rawEvent: unknown;
eventCount: number;
turnId: string;
turnAbortController: AbortController;
}): Promise<boolean> {
const { rawEvent, eventCount, turnId, turnAbortController } = params;
const event = unwrapOpenCodeGlobalEvent(rawEvent);
traceOpenCode("event.raw", {
turnId,
n: eventCount,
type: event?.type,
rawType: readOpenCodeRecord(rawEvent)?.type,
directory: readOpenCodeRecord(rawEvent)?.directory,
properties: event ? (event as { properties?: unknown }).properties : undefined,
});
if (!event) {
return true;
}
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.skip", {
turnId,
n: eventCount,
aborted: turnAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
});
return false;
}
this.armRetryFailureTimerForStatus(event, turnId);
const translated = await this.translateEvent(event);
traceOpenCode("event.translated", {
turnId,
n: eventCount,
count: translated.length,
types: translated.map((t) => t.type),
});
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.translated.skip-active", { turnId, type: e.type });
return false;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
traceOpenCode("event.terminal", { turnId, type: terminalEvent.type });
this.finishForegroundTurn(terminalEvent, turnId);
return false;
}
this.notifySubscribers(e, turnId);
}
return true;
}
private finishForegroundTurn( private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>, event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string, turnId: string,
): void { ): void {
traceOpenCode("finishForegroundTurn", {
turnId,
activeTurnId: this.activeForegroundTurnId,
type: event.type,
error: event.type === "turn_failed" ? event.error : undefined,
reason: event.type === "turn_canceled" ? event.reason : undefined,
});
if (this.activeForegroundTurnId !== turnId) { if (this.activeForegroundTurnId !== turnId) {
return; return;
} }
@@ -2718,7 +2516,6 @@ class OpenCodeAgentSession implements AgentSession {
} else { } else {
this.runningToolCalls.clear(); this.runningToolCalls.clear();
} }
this.clearRetryFailureTimer();
this.activeForegroundTurnId = null; this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch. // Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort(); this.abortController?.abort();
@@ -2734,44 +2531,6 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.delete(item.callId); this.runningToolCalls.delete(item.callId);
} }
private armRetryFailureTimerForStatus(event: OpenCodeEvent, turnId: string): void {
if (this.retryFailureTimer || event.type !== "session.status") {
return;
}
if (event.properties.sessionID !== this.sessionId || event.properties.status.type !== "retry") {
return;
}
const retry = event.properties.status;
const message = typeof retry.message === "string" ? retry.message.trim() : "";
const error = message
? `OpenCode provider retry did not recover: ${message}`
: "OpenCode provider retry did not recover";
this.retryFailureTimer = setTimeout(() => {
this.retryFailureTimer = null;
if (this.activeForegroundTurnId !== turnId) {
return;
}
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error,
},
turnId,
);
}, OPENCODE_RETRY_STATUS_FAILURE_MS);
}
private clearRetryFailureTimer(): void {
if (!this.retryFailureTimer) {
return;
}
clearTimeout(this.retryFailureTimer);
this.retryFailureTimer = null;
}
private synthesizeInterruptedToolCalls(turnId: string): void { private synthesizeInterruptedToolCalls(turnId: string): void {
for (const item of this.runningToolCalls.values()) { for (const item of this.runningToolCalls.values()) {
const error = { message: "Tool execution aborted" }; const error = { message: "Tool execution aborted" };
@@ -47,7 +47,6 @@ export class TestOpenCodeClient {
appAgents: [] as unknown[], appAgents: [] as unknown[],
commandList: [] as unknown[], commandList: [] as unknown[],
eventSubscribe: [] as unknown[], eventSubscribe: [] as unknown[],
globalEvent: [] as unknown[],
permissionReply: [] as unknown[], permissionReply: [] as unknown[],
providerList: [] as unknown[], providerList: [] as unknown[],
questionReject: [] as unknown[], questionReject: [] as unknown[],
@@ -100,12 +99,6 @@ export class TestOpenCodeClient {
return { stream: this.eventStream }; return { stream: this.eventStream };
}, },
}, },
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
return { stream: this.eventStream };
},
},
mcp: { mcp: {
add: async () => ({}), add: async () => ({}),
connect: async () => ({}), connect: async () => ({}),
@@ -41,52 +41,6 @@ describe("paseo daemon bootstrap", () => {
} }
}); });
test("redacts Agent MCP debug request credentials and bodies", async () => {
const logLines: string[] = [];
const logger = pino(
{ level: "debug" },
{
write: (line: string) => {
logLines.push(line);
},
},
);
const daemonHandle = await createTestPaseoDaemon({
logger,
mcpDebug: true,
});
try {
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`, {
method: "POST",
headers: {
Authorization: "Bearer secret-debug-token",
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
apiKey: "secret-body-token",
},
}),
});
expect(response.status).toBe(400);
const logs = logLines.join("\n");
expect(logs).toContain("Agent MCP request");
expect(logs).toContain("[redacted]");
expect(logs).toContain('"method":"tools/call"');
expect(logs).toContain('"hasParams":true');
expect(logs).not.toContain("secret-debug-token");
expect(logs).not.toContain("secret-body-token");
expect(logs).not.toContain("apiKey");
} finally {
await daemonHandle.close();
}
});
test("fails fast when OpenAI speech provider is configured without credentials", async () => { test("fails fast when OpenAI speech provider is configured without credentials", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-openai-config-")); const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-openai-config-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo"); const paseoHome = path.join(paseoHomeRoot, ".paseo");
+7 -52
View File
@@ -1,7 +1,7 @@
import express from "express"; import express from "express";
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
import { constants, existsSync, unlinkSync } from "fs"; import { createReadStream, unlinkSync, existsSync } from "fs";
import { open } from "fs/promises"; import { stat } from "fs/promises";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { hostname as getHostname } from "node:os"; import { hostname as getHostname } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -119,7 +119,6 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js"; import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js"; import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
@@ -141,11 +140,6 @@ import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js"
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>; type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
const DOWNLOAD_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
function formatHostForHttpUrl(host: string): string { function formatHostForHttpUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
} }
@@ -160,38 +154,6 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null
).toString(); ).toString();
} }
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return {
type: body === null ? "null" : typeof body,
};
}
const record = body as Record<string, unknown>;
const method = typeof record.method === "string" ? record.method : undefined;
return {
type: "object",
...(typeof record.jsonrpc === "string" ? { jsonrpc: record.jsonrpc } : {}),
...(method ? { method } : {}),
hasId: Object.prototype.hasOwnProperty.call(record, "id"),
hasParams: Object.prototype.hasOwnProperty.call(record, "params"),
};
}
function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
if (!Array.isArray(body)) {
return summarizeAgentMcpDebugMessage(body);
}
const messages = body.slice(0, MAX_MCP_DEBUG_BATCH_ITEMS).map(summarizeAgentMcpDebugMessage);
return {
type: "batch",
count: body.length,
messages,
...(body.length > messages.length ? { omitted: body.length - messages.length } : {}),
};
}
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig; export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig; export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
@@ -243,7 +205,6 @@ export interface PaseoDaemonConfig {
providerOverrides?: Record<string, ProviderOverride>; providerOverrides?: Record<string, ProviderOverride>;
log?: PersistedConfig["log"]; log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pushNotificationSender?: PushNotificationSender;
} }
export interface PaseoDaemon { export interface PaseoDaemon {
@@ -417,10 +378,8 @@ export async function createPaseoDaemon(
return; return;
} }
let fileHandle: Awaited<ReturnType<typeof open>> | null = null;
try { try {
fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS); const fileStats = await stat(entry.absolutePath);
const fileStats = await fileHandle.stat();
if (!fileStats.isFile()) { if (!fileStats.isFile()) {
res.status(404).json({ error: "File not found" }); res.status(404).json({ error: "File not found" });
return; return;
@@ -429,10 +388,9 @@ export async function createPaseoDaemon(
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_"); const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
res.setHeader("Content-Type", entry.mimeType); res.setHeader("Content-Type", entry.mimeType);
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`); res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
res.setHeader("Content-Length", fileStats.size.toString()); res.setHeader("Content-Length", entry.size.toString());
const stream = fileHandle.createReadStream(); const stream = createReadStream(entry.absolutePath);
fileHandle = null;
stream.on("error", (err) => { stream.on("error", (err) => {
logger.error({ err }, "Failed to stream download"); logger.error({ err }, "Failed to stream download");
if (!res.headersSent) { if (!res.headersSent) {
@@ -447,8 +405,6 @@ export async function createPaseoDaemon(
if (!res.headersSent) { if (!res.headersSent) {
res.status(404).json({ error: "File not found" }); res.status(404).json({ error: "File not found" });
} }
} finally {
await fileHandle?.close().catch(() => undefined);
} }
}; };
@@ -713,8 +669,8 @@ export async function createPaseoDaemon(
method: req.method, method: req.method,
url: req.originalUrl, url: req.originalUrl,
sessionId: req.header("mcp-session-id"), sessionId: req.header("mcp-session-id"),
authorization: req.header("authorization") ? REDACTED_LOG_VALUE : undefined, authorization: req.header("authorization"),
body: summarizeAgentMcpDebugBody(req.body), body: req.body,
}, },
"Agent MCP request", "Agent MCP request",
); );
@@ -886,7 +842,6 @@ export async function createPaseoDaemon(
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService, workspaceGitService,
github, github,
config.pushNotificationSender,
); );
if (relayEnabled) { if (relayEnabled) {
@@ -1,19 +1,12 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js"; import { DaemonClient } from "./test-utils/daemon-client.js";
import type { AgentStreamEventPayload } from "../shared/messages.js"; import type { AgentStreamEventPayload } from "../shared/messages.js";
import type { AgentSnapshotPayload } from "./messages.js"; import type { AgentSnapshotPayload } from "./messages.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; import { PushService } from "./push/push-service.js";
import { PushTokenStore } from "./push/token-store.js";
import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js"; import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js";
class RecordingPushNotificationSender implements PushNotificationSender {
readonly sent: PushPayload[] = [];
async send(payload: PushPayload): Promise<void> {
this.sent.push(payload);
}
}
/** /**
* Tests for client activity tracking and smart notifications. * Tests for client activity tracking and smart notifications.
* *
@@ -38,17 +31,23 @@ describe("client activity tracking", () => {
let daemon: TestPaseoDaemon; let daemon: TestPaseoDaemon;
let client1: DaemonClient; let client1: DaemonClient;
let client2: DaemonClient; let client2: DaemonClient;
let pushNotifications: RecordingPushNotificationSender; let sendPushSpy: ReturnType<typeof vi.spyOn>;
let getAllTokensSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => { beforeEach(async () => {
pushNotifications = new RecordingPushNotificationSender(); sendPushSpy = vi.spyOn(PushService.prototype, "sendPush").mockResolvedValue(undefined);
daemon = await createTestPaseoDaemon({ pushNotificationSender: pushNotifications }); getAllTokensSpy = vi
.spyOn(PushTokenStore.prototype, "getAllTokens")
.mockReturnValue(["ExponentPushToken[activity-test]"]);
daemon = await createTestPaseoDaemon();
}); });
afterEach(async () => { afterEach(async () => {
if (client1) await client1.close().catch(() => {}); if (client1) await client1.close().catch(() => {});
if (client2) await client2.close().catch(() => {}); if (client2) await client2.close().catch(() => {});
await daemon.close(); await daemon.close();
sendPushSpy.mockRestore();
getAllTokensSpy.mockRestore();
}, 30000); }, 30000);
async function createClient(): Promise<DaemonClient> { async function createClient(): Promise<DaemonClient> {
@@ -206,7 +205,7 @@ describe("client activity tracking", () => {
expect(attention.reason).toBe("finished"); expect(attention.reason).toBe("finished");
expect(attention.shouldNotify).toBe(false); expect(attention.shouldNotify).toBe(false);
expect(pushNotifications.sent).toHaveLength(1); expect(sendPushSpy).toHaveBeenCalledTimes(1);
}, 120000); }, 120000);
test("notification when no heartbeat received (legacy/new client)", async () => { test("notification when no heartbeat received (legacy/new client)", async () => {
@@ -226,7 +225,7 @@ describe("client activity tracking", () => {
expect(attention.reason).toBe("finished"); expect(attention.reason).toBe("finished");
expect(attention.shouldNotify).toBe(false); expect(attention.shouldNotify).toBe(false);
expect(pushNotifications.sent).toHaveLength(1); expect(sendPushSpy).toHaveBeenCalledTimes(1);
}, 120000); }, 120000);
}); });
@@ -310,7 +309,7 @@ describe("client activity tracking", () => {
// No stale client is selected for in-app; push handles the fallback. // No stale client is selected for in-app; push handles the fallback.
expect(attention1.shouldNotify).toBe(false); expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(false); expect(attention2.shouldNotify).toBe(false);
expect(pushNotifications.sent).toHaveLength(1); expect(sendPushSpy).toHaveBeenCalledTimes(1);
}, 120000); }, 120000);
test("notifies only the present Electron-style web client when Firefox is stale", async () => { test("notifies only the present Electron-style web client when Firefox is stale", async () => {
@@ -346,7 +345,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false); expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(true); expect(attention2.shouldNotify).toBe(true);
expect(pushNotifications.sent).toEqual([]); expect(sendPushSpy).not.toHaveBeenCalled();
}, 120000); }, 120000);
}); });
@@ -466,7 +465,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false); expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(true); expect(attention2.shouldNotify).toBe(true);
expect(pushNotifications.sent).toEqual([]); expect(sendPushSpy).not.toHaveBeenCalled();
}, 120000); }, 120000);
test("notify web when user active on web but looking at different agent", async () => { test("notify web when user active on web but looking at different agent", async () => {
@@ -544,7 +543,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false); expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(false); expect(attention2.shouldNotify).toBe(false);
expect(pushNotifications.sent).toHaveLength(1); expect(sendPushSpy).toHaveBeenCalledTimes(1);
}, 120000); }, 120000);
}); });
@@ -582,7 +581,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false); expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(false); expect(attention2.shouldNotify).toBe(false);
expect(pushNotifications.sent).toHaveLength(1); expect(sendPushSpy).toHaveBeenCalledTimes(1);
}, 120000); }, 120000);
test("notification when app not visible but activity is recent", async () => { test("notification when app not visible but activity is recent", async () => {
@@ -648,7 +647,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(true); expect(attention1.shouldNotify).toBe(true);
expect(attention2.shouldNotify).toBe(false); expect(attention2.shouldNotify).toBe(false);
expect(pushNotifications.sent).toEqual([]); expect(sendPushSpy).not.toHaveBeenCalled();
}, 120000); }, 120000);
}); });
}); });
@@ -9,8 +9,6 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js"; import { isProviderAvailable } from "./agent-configs.js";
const OPENCODE_REAL_TEST_MODEL = "opencode/big-pickle";
function tmpCwd(): string { function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-")); return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-"));
} }
@@ -49,13 +47,13 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
try { try {
const models = await client.listProviderModels("opencode"); const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === OPENCODE_REAL_TEST_MODEL)).toBe(true); expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const agent = await client.createAgent({ const agent = await client.createAgent({
provider: "opencode", provider: "opencode",
cwd, cwd,
title: "OpenCode initial prompt wait regression", title: "OpenCode initial prompt wait regression",
model: OPENCODE_REAL_TEST_MODEL, model: "opencode/big-pickle",
initialPrompt: "Reply with exactly: BIG_PICKLE_OK", initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
}); });
@@ -85,4 +83,35 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, 90_000); }, 90_000);
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();
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);
}); });
@@ -13,6 +13,16 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-")); return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-"));
} }
function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
): string {
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0].id;
}
async function createHarness(): Promise<{ async function createHarness(): Promise<{
client: DaemonClient; client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>; daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
@@ -53,7 +63,7 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
provider: "opencode", provider: "opencode",
cwd, cwd,
title: "OpenCode question regression", title: "OpenCode question regression",
model: "opencode/big-pickle", model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
modeId: "plan", modeId: "plan",
}); });
@@ -101,7 +111,7 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
provider: "opencode", provider: "opencode",
cwd, cwd,
title: "OpenCode plan mode regression", title: "OpenCode plan mode regression",
model: "opencode/big-pickle", model: pickOpenCodeModel(modelList.models),
modeId: "plan", modeId: "plan",
}); });
@@ -22,6 +22,23 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = [
"minimax-m2.5-free",
"kimi-k2.5-free",
"glm-5-free",
"free",
"mini",
"gpt-5-nano",
],
): string {
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0].id;
}
function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean { function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean {
return messages.some( return messages.some(
(message) => (message) =>
@@ -288,8 +305,8 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
provider: "opencode", provider: "opencode",
cwd, cwd,
title: "OpenCode send while working", title: "OpenCode send while working",
model: "opencode/big-pickle", model: pickOpenCodeModel(modelList.models),
modeId: "build", modeId: "default",
}); });
await client.sendMessage( await client.sendMessage(
@@ -348,8 +365,8 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
provider: "opencode", provider: "opencode",
cwd, cwd,
title: "OpenCode explicit interrupt", title: "OpenCode explicit interrupt",
model: "opencode/big-pickle", model: pickOpenCodeModel(modelList.models),
modeId: "build", modeId: "default",
}); });
await client.sendMessage( await client.sendMessage(
@@ -1,10 +1,10 @@
// POSIX-only: symlink fixtures // POSIX-only: symlink fixtures
/* eslint-disable max-nested-callbacks */ /* eslint-disable max-nested-callbacks */
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getDownloadableFileInfo, listDirectoryEntries, readExplorerFile } from "./service.js"; import { listDirectoryEntries, readExplorerFile } from "./service.js";
import { isPlatform } from "../../test-utils/platform.js"; import { isPlatform } from "../../test-utils/platform.js";
async function createTempDir(prefix: string): Promise<string> { async function createTempDir(prefix: string): Promise<string> {
@@ -55,53 +55,4 @@ describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
await rm(outsideRoot, { recursive: true, force: true }); await rm(outsideRoot, { recursive: true, force: true });
} }
}); });
it("skips listed symlink entries that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
await writeFile(path.join(root, "visible.txt"), "visible\n", "utf-8");
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
const result = await listDirectoryEntries({ root });
const names = result.entries.map((entry) => entry.name);
expect(names).toContain("visible.txt");
expect(names).not.toContain("secret-link.txt");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
it("uses canonical paths for downloadable symlink targets inside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
const target = path.join(root, "safe.txt");
const link = path.join(root, "safe-link.txt");
await writeFile(target, "safe\n", "utf-8");
await symlink("safe.txt", link);
const file = await readExplorerFile({
root,
relativePath: "safe-link.txt",
});
const info = await getDownloadableFileInfo({
root,
relativePath: "safe-link.txt",
});
expect(file.path).toBe("safe-link.txt");
expect(file.content).toBe("safe\n");
expect(info.path).toBe("safe-link.txt");
expect(info.fileName).toBe("safe-link.txt");
expect(info.absolutePath).toBe(await realpath(target));
} finally {
await rm(root, { recursive: true, force: true });
}
});
}); });
@@ -1,5 +1,4 @@
import { constants, promises as fs } from "fs"; import { promises as fs } from "fs";
import type { FileHandle } from "fs/promises";
import path from "path"; import path from "path";
import { resolvePathFromBase } from "../path-utils.js"; import { resolvePathFromBase } from "../path-utils.js";
@@ -55,10 +54,6 @@ const TEXT_MIME_TYPES: Record<string, string> = {
}; };
const DEFAULT_TEXT_MIME_TYPE = "text/plain"; const DEFAULT_TEXT_MIME_TYPE = "text/plain";
const FILE_TYPE_SAMPLE_BYTES = 8192;
const READ_FILE_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed";
const IMAGE_MIME_TYPES: Record<string, string> = { const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png", ".png": "image/png",
@@ -74,11 +69,6 @@ interface ScopedPathParams {
relativePath?: string; relativePath?: string;
} }
interface ScopedPath {
requestedPath: string;
resolvedPath: string;
}
interface EntryPayloadParams { interface EntryPayloadParams {
root: string; root: string;
targetPath: string; targetPath: string;
@@ -91,17 +81,17 @@ export async function listDirectoryEntries({
relativePath = ".", relativePath = ".",
}: ListDirectoryParams): Promise<FileExplorerDirectory> { }: ListDirectoryParams): Promise<FileExplorerDirectory> {
const directoryPath = await resolveScopedPath({ root, relativePath }); const directoryPath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(directoryPath.resolvedPath); const stats = await fs.stat(directoryPath);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
throw new Error("Requested path is not a directory"); throw new Error("Requested path is not a directory");
} }
const dirents = await fs.readdir(directoryPath.resolvedPath, { withFileTypes: true }); const dirents = await fs.readdir(directoryPath, { withFileTypes: true });
const entriesWithNulls = await Promise.all( const entriesWithNulls = await Promise.all(
dirents.map(async (dirent) => { dirents.map(async (dirent) => {
const targetPath = path.join(directoryPath.requestedPath, dirent.name); const targetPath = path.join(directoryPath, dirent.name);
const kind: ExplorerEntryKind = dirent.isDirectory() ? "directory" : "file"; const kind: ExplorerEntryKind = dirent.isDirectory() ? "directory" : "file";
try { try {
return await buildEntryPayload({ return await buildEntryPayload({
@@ -113,7 +103,7 @@ export async function listDirectoryEntries({
} catch (error) { } catch (error) {
// Directories can contain dangling links (e.g. AGENTS.md -> CLAUDE.md). // Directories can contain dangling links (e.g. AGENTS.md -> CLAUDE.md).
// Skip entries whose targets disappeared instead of failing the whole listing. // Skip entries whose targets disappeared instead of failing the whole listing.
if (isMissingEntryError(error) || isOutsideWorkspaceError(error)) { if (isMissingEntryError(error)) {
return null; return null;
} }
throw error; throw error;
@@ -131,7 +121,7 @@ export async function listDirectoryEntries({
}); });
return { return {
path: normalizeRelativePath({ root, targetPath: directoryPath.requestedPath }), path: normalizeRelativePath({ root, targetPath: directoryPath }),
entries, entries,
}; };
} }
@@ -181,53 +171,48 @@ export async function readExplorerFileBytes({
relativePath, relativePath,
}: ReadFileParams): Promise<FileExplorerFileBytes> { }: ReadFileParams): Promise<FileExplorerFileBytes> {
const filePath = await resolveScopedPath({ root, relativePath }); const filePath = await resolveScopedPath({ root, relativePath });
const handle = await openFileForRead(filePath.resolvedPath); const stats = await fs.stat(filePath);
try { if (!stats.isFile()) {
const stats = await handle.stat(); throw new Error("Requested path is not a file");
}
if (!stats.isFile()) { const ext = path.extname(filePath).toLowerCase();
throw new Error("Requested path is not a file"); const basePayload = {
} path: normalizeRelativePath({ root, targetPath: filePath }),
size: stats.size,
const ext = path.extname(filePath.resolvedPath).toLowerCase(); modifiedAt: stats.mtime.toISOString(),
const basePayload = { };
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
const buffer = await handle.readFile();
if (ext in IMAGE_MIME_TYPES) {
return {
...basePayload,
kind: "image",
encoding: "binary",
bytes: buffer,
mimeType: IMAGE_MIME_TYPES[ext],
};
}
if (isLikelyBinary(buffer)) {
return {
...basePayload,
kind: "binary",
encoding: "binary",
bytes: buffer,
mimeType: "application/octet-stream",
};
}
if (ext in IMAGE_MIME_TYPES) {
const buffer = await fs.readFile(filePath);
return { return {
...basePayload, ...basePayload,
kind: "text", kind: "image",
encoding: "utf-8", encoding: "binary",
bytes: buffer, bytes: buffer,
mimeType: textMimeTypeForExtension(ext), mimeType: IMAGE_MIME_TYPES[ext],
}; };
} finally {
await handle.close();
} }
const buffer = await fs.readFile(filePath);
if (isLikelyBinary(buffer)) {
return {
...basePayload,
kind: "binary",
encoding: "binary",
bytes: buffer,
mimeType: "application/octet-stream",
};
}
return {
...basePayload,
kind: "text",
encoding: "utf-8",
bytes: buffer,
mimeType: textMimeTypeForExtension(ext),
};
} }
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{ export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
@@ -238,50 +223,47 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
size: number; size: number;
}> { }> {
const filePath = await resolveScopedPath({ root, relativePath }); const filePath = await resolveScopedPath({ root, relativePath });
const handle = await openFileForRead(filePath.resolvedPath); const stats = await fs.stat(filePath);
try { if (!stats.isFile()) {
const stats = await handle.stat(); throw new Error("Requested path is not a file");
}
if (!stats.isFile()) { const ext = path.extname(filePath).toLowerCase();
throw new Error("Requested path is not a file"); let mimeType = "application/octet-stream";
} if (ext in IMAGE_MIME_TYPES) {
mimeType = IMAGE_MIME_TYPES[ext];
const ext = path.extname(filePath.resolvedPath).toLowerCase(); } else {
let mimeType = "application/octet-stream"; // Read only a small prefix to classify likely text vs binary.
if (ext in IMAGE_MIME_TYPES) { const handle = await fs.open(filePath, "r");
mimeType = IMAGE_MIME_TYPES[ext]; const sample = Buffer.alloc(8192);
} else { try {
const sample = Buffer.alloc(FILE_TYPE_SAMPLE_BYTES);
const { bytesRead } = await handle.read(sample, 0, sample.length, 0); const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample; const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
if (!isLikelyBinary(chunk)) { if (!isLikelyBinary(chunk)) {
mimeType = textMimeTypeForExtension(ext); mimeType = textMimeTypeForExtension(ext);
} }
} finally {
await handle.close();
} }
return {
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
absolutePath: filePath.resolvedPath,
fileName: path.basename(filePath.requestedPath),
mimeType,
size: stats.size,
};
} finally {
await handle.close();
} }
return {
path: normalizeRelativePath({ root, targetPath: filePath }),
absolutePath: filePath,
fileName: path.basename(filePath),
mimeType,
size: stats.size,
};
} }
async function resolveScopedPath({ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams): Promise<string> {
root,
relativePath = ".",
}: ScopedPathParams): Promise<ScopedPath> {
const normalizedRoot = path.resolve(root); const normalizedRoot = path.resolve(root);
const requestedPath = resolvePathFromBase(normalizedRoot, relativePath); const requestedPath = resolvePathFromBase(normalizedRoot, relativePath);
const relative = path.relative(normalizedRoot, requestedPath); const relative = path.relative(normalizedRoot, requestedPath);
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) { if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); throw new Error("Access outside of workspace is not allowed");
} }
const realRoot = await fs.realpath(normalizedRoot); const realRoot = await fs.realpath(normalizedRoot);
@@ -290,32 +272,24 @@ async function resolveScopedPath({
const realPath = await fs.realpath(requestedPath); const realPath = await fs.realpath(requestedPath);
const realRelative = path.relative(realRoot, realPath); const realRelative = path.relative(realRoot, realPath);
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) { if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); throw new Error("Access outside of workspace is not allowed");
} }
return { requestedPath, resolvedPath: realPath }; return requestedPath;
} catch (error) { } catch (error) {
if (isMissingEntryError(error)) { if (isMissingEntryError(error)) {
return { requestedPath, resolvedPath: requestedPath }; return requestedPath;
} }
throw error; throw error;
} }
} }
async function openFileForRead(filePath: string): Promise<FileHandle> {
return fs.open(filePath, READ_FILE_OPEN_FLAGS);
}
async function buildEntryPayload({ async function buildEntryPayload({
root, root,
targetPath, targetPath,
name, name,
kind, kind,
}: EntryPayloadParams): Promise<FileExplorerEntry> { }: EntryPayloadParams): Promise<FileExplorerEntry> {
const entryPath = await resolveScopedPath({ const stats = await fs.stat(targetPath);
root,
relativePath: normalizeRelativePath({ root, targetPath }),
});
const stats = await fs.stat(entryPath.resolvedPath);
return { return {
name, name,
path: normalizeRelativePath({ root, targetPath }), path: normalizeRelativePath({ root, targetPath }),
@@ -330,10 +304,6 @@ function isMissingEntryError(error: unknown): boolean {
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP"; return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
} }
function isOutsideWorkspaceError(error: unknown): boolean {
return error instanceof Error && error.message === ACCESS_OUTSIDE_WORKSPACE_MESSAGE;
}
function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: string }): string { function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: string }): string {
const normalizedRoot = path.resolve(root); const normalizedRoot = path.resolve(root);
const normalizedTarget = path.resolve(targetPath); const normalizedTarget = path.resolve(targetPath);
@@ -1,29 +0,0 @@
import type pino from "pino";
import { PushService, type PushPayload } from "./push-service.js";
import type { PushTokenStore } from "./token-store.js";
export type { PushPayload };
export interface PushNotificationSender {
send(payload: PushPayload): Promise<void>;
}
export function createPushNotificationSender(
logger: pino.Logger,
tokenStore: PushTokenStore,
): PushNotificationSender {
const pushService = new PushService(logger, tokenStore);
return {
async send(payload) {
const tokens = tokenStore.getAllTokens();
logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length === 0) {
return;
}
await pushService.sendPush(tokens, payload);
},
};
}
@@ -1,7 +1,7 @@
import type { PushTokenStore } from "./token-store.js"; import type { PushTokenStore } from "./token-store.js";
import type pino from "pino"; import type pino from "pino";
export interface PushPayload { interface PushPayload {
title: string; title: string;
body: string; body: string;
data?: Record<string, unknown>; data?: Record<string, unknown>;
+125 -128
View File
@@ -1,115 +1,124 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const wsMock = vi.hoisted(() => {
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
readonly url: string;
readonly options: unknown;
readyState = MockWebSocket.CONNECTING;
sent: string[] = [];
terminateCalls = 0;
private listeners = new Map<string, Array<(...args: unknown[]) => void>>();
constructor(url: string, options?: unknown) {
this.url = url;
this.options = options;
MockWebSocket.instances.push(this);
}
static reset() {
MockWebSocket.instances = [];
}
on(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
handlers.push(listener);
this.listeners.set(event, handlers);
return this;
}
once(event: string, listener: (...args: unknown[]) => void) {
const wrapped = (...args: unknown[]) => {
this.off(event, wrapped);
listener(...args);
};
return this.on(event, wrapped);
}
close(code?: number, reason?: string) {
this.readyState = MockWebSocket.CLOSED;
this.emit("close", code ?? 1000, reason ?? "");
}
terminate() {
this.terminateCalls += 1;
this.readyState = MockWebSocket.CLOSED;
this.emit("close", 1006, "");
}
send(data: string) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
}
this.sent.push(data);
}
open() {
this.readyState = MockWebSocket.OPEN;
this.emit("open");
}
message(data: unknown) {
this.emit("message", data);
}
error(err: unknown) {
this.emit("error", err);
}
private off(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
this.listeners.set(
event,
handlers.filter((handler) => handler !== listener),
);
}
private emit(event: string, ...args: unknown[]) {
const handlers = this.listeners.get(event) ?? [];
for (const handler of handlers.slice()) {
handler(...args);
}
}
}
return { MockWebSocket };
});
vi.mock("ws", () => ({
default: wsMock.MockWebSocket,
WebSocket: wsMock.MockWebSocket,
}));
import type pino from "pino"; import type pino from "pino";
import { startRelayTransport } from "./relay-transport"; import { startRelayTransport } from "./relay-transport";
function createMockLogger() { function createMockLogger() {
const messages: { level: "debug" | "info" | "warn" | "error"; args: unknown[] }[] = [];
const logger = { const logger = {
messages, child: vi.fn(() => logger),
child: () => logger, debug: vi.fn(),
debug: (...args: unknown[]) => messages.push({ level: "debug", args }), info: vi.fn(),
info: (...args: unknown[]) => messages.push({ level: "info", args }), warn: vi.fn(),
warn: (...args: unknown[]) => messages.push({ level: "warn", args }), error: vi.fn(),
error: (...args: unknown[]) => messages.push({ level: "error", args }),
}; };
return logger; return logger;
} }
type TestLogger = ReturnType<typeof createMockLogger>; function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
function hasLogMessage(logger: TestLogger, level: "info" | "warn", message: string): boolean {
return logger.messages.some((entry) => {
return entry.level === level && entry.args.some((arg) => arg === message);
});
}
class FakeRelayWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 3;
readyState = FakeRelayWebSocket.CONNECTING;
sent: Array<string | Uint8Array | ArrayBuffer> = [];
terminateCalls = 0;
private readonly listeners = new Map<string, Array<(...args: unknown[]) => void>>();
constructor(readonly url: string) {}
on(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
handlers.push(listener);
this.listeners.set(event, handlers);
}
once(event: string, listener: (...args: unknown[]) => void) {
const wrapped = (...args: unknown[]) => {
this.off(event, wrapped);
listener(...args);
};
this.on(event, wrapped);
}
close(code?: number, reason?: string) {
this.readyState = FakeRelayWebSocket.CLOSED;
this.emit("close", code ?? 1000, reason ?? "");
}
terminate() {
this.terminateCalls += 1;
this.readyState = FakeRelayWebSocket.CLOSED;
this.emit("close", 1006, "");
}
send(data: string | Uint8Array | ArrayBuffer) {
if (this.readyState !== FakeRelayWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
}
this.sent.push(data);
}
open() {
this.readyState = FakeRelayWebSocket.OPEN;
this.emit("open");
}
message(data: unknown) {
this.emit("message", data);
}
private off(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
this.listeners.set(
event,
handlers.filter((handler) => handler !== listener),
);
}
private emit(event: string, ...args: unknown[]) {
const handlers = this.listeners.get(event) ?? [];
for (const handler of handlers.slice()) {
handler(...args);
}
}
}
function createFakeWebSockets() {
const sockets: FakeRelayWebSocket[] = [];
return {
sockets,
createWebSocket(url: string) {
const socket = new FakeRelayWebSocket(url);
sockets.push(socket);
return socket;
},
};
} }
describe("relay-transport control lifecycle", () => { describe("relay-transport control lifecycle", () => {
const controllers: Array<{ stop: () => Promise<void> }> = []; const controllers: Array<{ stop: () => Promise<void> }> = [];
let relay: ReturnType<typeof createFakeWebSockets>; const MockWebSocket = wsMock.MockWebSocket;
beforeEach(() => { beforeEach(() => {
relay = createFakeWebSockets(); MockWebSocket.reset();
}); });
afterEach(async () => { afterEach(async () => {
@@ -126,19 +135,18 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = relay.sockets[0]; const control = MockWebSocket.instances[0];
expect(control).toBeDefined(); expect(control).toBeDefined();
control.open(); control.open();
expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(false); expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
expect(control.sent.length).toBeGreaterThan(0); expect(control.sent.length).toBeGreaterThan(0);
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(true); expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
}); });
test("terminates and reconnects when control socket opens but never becomes ready", () => { test("terminates and reconnects when control socket opens but never becomes ready", () => {
@@ -150,19 +158,18 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const firstControl = relay.sockets[0]; const firstControl = MockWebSocket.instances[0];
firstControl.open(); firstControl.open();
vi.advanceTimersByTime(8_000); vi.advanceTimersByTime(8_000);
expect(hasLogMessage(logger, "warn", "relay_control_ready_timeout_terminating")).toBe(true); expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
expect(firstControl.terminateCalls).toBe(1); expect(firstControl.terminateCalls).toBe(1);
vi.advanceTimersByTime(1_000); vi.advanceTimersByTime(1_000);
expect(relay.sockets.length).toBeGreaterThanOrEqual(2); expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
}); });
test("terminates stale control sockets in under one minute", () => { test("terminates stale control sockets in under one minute", () => {
@@ -174,56 +181,47 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = relay.sockets[0]; const control = MockWebSocket.instances[0];
control.open(); control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
logger.messages.length = 0; logger.warn.mockClear();
vi.advanceTimersByTime(40_000); vi.advanceTimersByTime(40_000);
expect(hasLogMessage(logger, "warn", "relay_control_stale_terminating")).toBe(true); expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
expect(control.terminateCalls).toBe(1); expect(control.terminateCalls).toBe(1);
}); });
test("passes stable relay external session metadata when attaching data socket", async () => { test("passes stable relay external session metadata when attaching data socket", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const attachedSockets: unknown[] = []; const attachSocket = vi.fn(async () => {});
const attachedMetadata: unknown[] = [];
const attachSocket = async (socket: unknown, metadata: unknown) => {
attachedSockets.push(socket);
attachedMetadata.push(metadata);
};
const controller = startRelayTransport({ const controller = startRelayTransport({
logger: logger as unknown as pino.Logger, logger: logger as unknown as pino.Logger,
attachSocket, attachSocket,
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = relay.sockets[0]; const control = MockWebSocket.instances[0];
control.open(); control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" })); control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
const dataSocket = relay.sockets[1]; const dataSocket = MockWebSocket.instances[1];
expect(dataSocket).toBeDefined(); expect(dataSocket).toBeDefined();
dataSocket.open(); dataSocket.open();
await Promise.resolve(); await Promise.resolve();
expect(attachedSockets).toEqual([dataSocket]); expect(attachSocket).toHaveBeenCalledTimes(1);
expect(attachedMetadata).toEqual([ expect(attachSocket).toHaveBeenCalledWith(dataSocket, {
{ transport: "relay",
transport: "relay", externalSessionKey: "session:clt_test",
externalSessionKey: "session:clt_test", });
},
]);
}); });
test("uses relayUseTls for control and data socket URLs", () => { test("uses relayUseTls for control and data socket URLs", () => {
@@ -234,16 +232,15 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "[::1]:443", relayEndpoint: "[::1]:443",
relayUseTls: true, relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
createWebSocket: relay.createWebSocket,
}); });
controllers.push(controller); controllers.push(controller);
const control = relay.sockets[0]; const control = MockWebSocket.instances[0];
control.open(); control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" })); control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
expect(relay.sockets[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); expect(MockWebSocket.instances[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(relay.sockets[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); expect(MockWebSocket.instances[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
}); });
}); });
+8 -29
View File
@@ -18,7 +18,6 @@ interface RelayTransportOptions {
relayUseTls: boolean; relayUseTls: boolean;
serverId: string; serverId: string;
daemonKeyPair?: KeyPair; daemonKeyPair?: KeyPair;
createWebSocket?: RelayWebSocketFactory;
} }
export interface RelayTransportController { export interface RelayTransportController {
@@ -33,16 +32,6 @@ interface RelaySocketLike {
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
} }
interface RelayWebSocketLike extends RelaySocketLike {
terminate: () => void;
on: (
event: "open" | "message" | "close" | "error",
listener: (...args: unknown[]) => void,
) => void;
}
type RelayWebSocketFactory = (url: string) => RelayWebSocketLike;
type ControlMessage = type ControlMessage =
| { type: "sync"; connectionIds: string[] } | { type: "sync"; connectionIds: string[] }
| { type: "connected"; connectionId: string } | { type: "connected"; connectionId: string }
@@ -53,11 +42,6 @@ type ControlMessage =
const CONTROL_PING_INTERVAL_MS = 10_000; const CONTROL_PING_INTERVAL_MS = 10_000;
const CONTROL_STALE_TIMEOUT_MS = 30_000; const CONTROL_STALE_TIMEOUT_MS = 30_000;
const CONTROL_READY_TIMEOUT_MS = 8_000; const CONTROL_READY_TIMEOUT_MS = 8_000;
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
}
function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer { function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer {
if (typeof data === "string") return data; if (typeof data === "string") return data;
@@ -122,15 +106,14 @@ export function startRelayTransport({
relayUseTls, relayUseTls,
serverId, serverId,
daemonKeyPair, daemonKeyPair,
createWebSocket = createDefaultRelayWebSocket,
}: RelayTransportOptions): RelayTransportController { }: RelayTransportOptions): RelayTransportController {
const relayLogger = logger.child({ module: "relay-transport" }); const relayLogger = logger.child({ module: "relay-transport" });
let stopped = false; let stopped = false;
let controlWs: RelayWebSocketLike | null = null; let controlWs: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null; let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempt = 0; let reconnectAttempt = 0;
const dataSockets = new Map<string, RelayWebSocketLike>(); // connectionId -> ws const dataSockets = new Map<string, WebSocket>(); // connectionId -> ws
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null; let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null; let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
let controlLastSeenAt = 0; let controlLastSeenAt = 0;
@@ -178,7 +161,7 @@ export function startRelayTransport({
serverId, serverId,
role: "server", role: "server",
}); });
const socket = createWebSocket(url); const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
controlWs = socket; controlWs = socket;
let controlConnected = false; let controlConnected = false;
@@ -355,7 +338,7 @@ export function startRelayTransport({
role: "server", role: "server",
connectionId, connectionId,
}); });
const socket = createWebSocket(url); const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
dataSockets.set(connectionId, socket); dataSockets.set(connectionId, socket);
let attached = false; let attached = false;
@@ -414,7 +397,7 @@ export function startRelayTransport({
} }
async function attachEncryptedSocket( async function attachEncryptedSocket(
socket: RelayWebSocketLike, socket: WebSocket,
daemonKeyPair: KeyPair, daemonKeyPair: KeyPair,
logger: pino.Logger, logger: pino.Logger,
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>, attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
@@ -443,10 +426,7 @@ async function attachEncryptedSocket(
} }
} }
function createRelayTransportAdapter( function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): RelayTransport {
socket: RelayWebSocketLike,
logger: pino.Logger,
): RelayTransport {
const relayTransport: RelayTransport = { const relayTransport: RelayTransport = {
send: (data) => { send: (data) => {
try { try {
@@ -465,11 +445,10 @@ function createRelayTransportAdapter(
}; };
socket.on("message", (data, isBinary) => { socket.on("message", (data, isBinary) => {
relayTransport.onmessage?.(normalizeMessageData(data, isBinary === true)); relayTransport.onmessage?.(normalizeMessageData(data, isBinary));
}); });
socket.on("close", (code, reason) => { socket.on("close", (code, reason) => {
const closeCode = typeof code === "number" ? code : 1006; relayTransport.onclose?.(code, reason.toString());
relayTransport.onclose?.(closeCode, String(reason ?? ""));
}); });
socket.on("error", (err) => { socket.on("error", (err) => {
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));
@@ -11,14 +11,12 @@ import {
} from "../bootstrap.js"; } from "../bootstrap.js";
import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js"; import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js";
import { createTestAgentClients } from "./fake-agent-client.js"; import { createTestAgentClients } from "./fake-agent-client.js";
import type { PushNotificationSender } from "../push/notifications.js";
interface TestPaseoDaemonOptions { interface TestPaseoDaemonOptions {
downloadTokenTtlMs?: number; downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[]; corsAllowedOrigins?: string[];
listen?: string; listen?: string;
logger?: Parameters<typeof createPaseoDaemon>[1]; logger?: Parameters<typeof createPaseoDaemon>[1];
mcpDebug?: boolean;
relayEnabled?: boolean; relayEnabled?: boolean;
relayEndpoint?: string; relayEndpoint?: string;
agentClients?: Partial<Record<AgentProvider, AgentClient>>; agentClients?: Partial<Record<AgentProvider, AgentClient>>;
@@ -32,7 +30,6 @@ interface TestPaseoDaemonOptions {
voiceLlmModel?: string | null; voiceLlmModel?: string | null;
dictationFinalTimeoutMs?: number; dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"]; auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender;
} }
export interface TestPaseoDaemon { export interface TestPaseoDaemon {
@@ -153,14 +150,13 @@ async function prepareTestDaemonConfig(
hostnames: true, hostnames: true,
mcpEnabled: true, mcpEnabled: true,
staticDir, staticDir,
mcpDebug: options.mcpDebug ?? false, mcpDebug: false,
agentClients: options.agentClients ?? createTestAgentClients(), agentClients: options.agentClients ?? createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"), agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: options.relayEnabled ?? false, relayEnabled: options.relayEnabled ?? false,
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
appBaseUrl: "https://app.paseo.sh", appBaseUrl: "https://app.paseo.sh",
auth: options.auth, auth: options.auth,
pushNotificationSender: options.pushNotificationSender,
openai: options.openai, openai: options.openai,
speech: options.speech, speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null, voiceLlmProvider: options.voiceLlmProvider ?? null,
@@ -10,7 +10,6 @@ import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js"; import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
const wsModuleMock = vi.hoisted(() => { const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer { class MockWebSocketServer {
@@ -29,6 +28,11 @@ const wsModuleMock = vi.hoisted(() => {
return { MockWebSocketServer }; return { MockWebSocketServer };
}); });
const pushMocks = vi.hoisted(() => ({
getAllTokens: vi.fn(() => ["ExponentPushToken[token-1]"]),
sendPush: vi.fn(async () => {}),
}));
vi.mock("ws", () => ({ vi.mock("ws", () => ({
WebSocketServer: wsModuleMock.MockWebSocketServer, WebSocketServer: wsModuleMock.MockWebSocketServer,
})); }));
@@ -39,6 +43,19 @@ vi.mock("./session.js", () => ({
}, },
})); }));
vi.mock("./push/token-store.js", () => ({
PushTokenStore: class {
getAllTokens = pushMocks.getAllTokens;
removeToken = vi.fn();
},
}));
vi.mock("./push/push-service.js", () => ({
PushService: class {
sendPush = pushMocks.sendPush;
},
}));
import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
interface WebSocketServerInternals { interface WebSocketServerInternals {
@@ -64,16 +81,7 @@ function createLogger() {
return logger; return logger;
} }
class RecordingPushNotificationSender implements PushNotificationSender {
readonly sent: PushPayload[] = [];
async send(payload: PushPayload): Promise<void> {
this.sent.push(payload);
}
}
function createServer(agentManagerOverrides?: Record<string, unknown>) { function createServer(agentManagerOverrides?: Record<string, unknown>) {
const pushNotifications = new RecordingPushNotificationSender();
const agentManager = { const agentManager = {
setAgentAttentionCallback: vi.fn(), setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null), getAgent: vi.fn(() => null),
@@ -129,18 +137,9 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
})), })),
dispose: vi.fn(), dispose: vi.fn(),
}), }),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
pushNotifications,
); );
return { server, agentManager, pushNotifications }; return { server, agentManager };
} }
function createOpenSocket() { function createOpenSocket() {
@@ -209,7 +208,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
const getLastAssistantMessage = vi.fn( const getLastAssistantMessage = vi.fn(
async () => "**Done**. Updated `README.md` and [link](https://example.com).", async () => "**Done**. Updated `README.md` and [link](https://example.com).",
); );
const { server, pushNotifications } = createServer({ const { server } = createServer({
getAgent: vi.fn(() => ({ getAgent: vi.fn(() => ({
config: { title: null }, config: { title: null },
cwd: "/tmp/worktree", cwd: "/tmp/worktree",
@@ -224,23 +223,21 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished", reason: "finished",
}); });
expect(pushNotifications.sent).toEqual([ expect(pushMocks.sendPush).toHaveBeenCalledWith(["ExponentPushToken[token-1]"], {
{ title: "Agent finished",
title: "Agent finished", body: "Done. Updated README.md and link.",
body: "Done. Updated README.md and link.", data: {
data: { serverId: "srv-test",
serverId: "srv-test", agentId: "agent-1",
agentId: "agent-1", reason: "finished",
reason: "finished",
},
}, },
]); });
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1"); expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
}); });
it("sends push notifications regardless of UI label presence", async () => { it("sends push notifications regardless of UI label presence", async () => {
const getLastAssistantMessage = vi.fn(async () => "Done."); const getLastAssistantMessage = vi.fn(async () => "Done.");
const { server, pushNotifications } = createServer({ const { server } = createServer({
getAgent: vi.fn(() => ({ getAgent: vi.fn(() => ({
config: { title: null }, config: { title: null },
cwd: "/tmp/worktree", cwd: "/tmp/worktree",
@@ -256,12 +253,12 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished", reason: "finished",
}); });
expect(pushNotifications.sent).toHaveLength(1); expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2"); expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
}); });
it("routes a hidden stale focused browser tab's notification to the present Electron web client", async () => { it("routes a hidden stale focused browser tab's notification to the present Electron web client", async () => {
const { server, pushNotifications } = createServer(); const { server } = createServer();
const nowMs = Date.now(); const nowMs = Date.now();
const electronWs = connectClient(server, { const electronWs = connectClient(server, {
deviceType: "web", deviceType: "web",
@@ -284,11 +281,11 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
expect(readAttentionRequiredMessage(electronWs).shouldNotify).toBe(true); expect(readAttentionRequiredMessage(electronWs).shouldNotify).toBe(true);
expect(readAttentionRequiredMessage(firefoxWs).shouldNotify).toBe(false); expect(readAttentionRequiredMessage(firefoxWs).shouldNotify).toBe(false);
expect(pushNotifications.sent).toEqual([]); expect(pushMocks.sendPush).not.toHaveBeenCalled();
}); });
it("pushes non-error attention when the only connected client has never sent a heartbeat", async () => { it("pushes non-error attention when the only connected client has never sent a heartbeat", async () => {
const { server, pushNotifications } = createServer(); const { server } = createServer();
const ws = connectClient(server, null); const ws = connectClient(server, null);
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({ await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
@@ -298,11 +295,11 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
}); });
expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false); expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false);
expect(pushNotifications.sent).toHaveLength(1); expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
}); });
it("does not push error attention when the only connected client has never sent a heartbeat", async () => { it("does not push error attention when the only connected client has never sent a heartbeat", async () => {
const { server, pushNotifications } = createServer(); const { server } = createServer();
const ws = connectClient(server, null); const ws = connectClient(server, null);
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({ await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
@@ -312,6 +309,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
}); });
expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false); expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false);
expect(pushNotifications.sent).toEqual([]); expect(pushMocks.sendPush).not.toHaveBeenCalled();
}); });
}); });
@@ -0,0 +1,369 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import type { Server as HTTPServer } from "http";
import type pino from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { SessionOutboundMessage, WSOutboundMessage } from "./messages.js";
const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer {
readonly handlers = new Map<string, (...args: unknown[]) => void>();
on(event: string, handler: (...args: unknown[]) => void) {
this.handlers.set(event, handler);
return this;
}
close() {
// no-op
}
}
return { MockWebSocketServer };
});
vi.mock("ws", () => ({
WebSocketServer: wsModuleMock.MockWebSocketServer,
}));
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { wrapSessionMessage } from "./messages.js";
interface WebSocketServerInternals {
flushRuntimeMetrics(options?: { final?: boolean }): void;
sendToClient(ws: unknown, message: WSOutboundMessage): void;
sendBinaryToClient(ws: unknown, frame: Uint8Array): void;
sessions: Map<unknown, unknown>;
}
const RuntimeMetricsLogSchema = z.object({
outboundMessageTypesTop: z.array(z.tuple([z.string(), z.number()])),
outboundSessionMessageTypesTop: z.array(z.tuple([z.string(), z.number()])),
outboundAgentStreamTypesTop: z.array(z.tuple([z.string(), z.number()])),
outboundAgentStreamAgentsTop: z.array(z.tuple([z.string(), z.number()])),
outboundBinaryFrameTypesTop: z.array(z.tuple([z.string(), z.number()])),
bufferedAmount: z.object({
p95: z.number(),
max: z.number(),
}),
});
type RuntimeMetricsLog = z.infer<typeof RuntimeMetricsLogSchema>;
interface TestSocket {
readyState: number;
bufferedAmount: number;
sent: Array<string | Uint8Array | ArrayBuffer>;
afterSendBufferedAmounts: number[];
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: () => void;
on: () => void;
once: () => void;
}
function createLogger() {
const logger = {
child: vi.fn(() => logger),
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return logger;
}
function createServer(logger: ReturnType<typeof createLogger>) {
const daemonConfigStore = {
onChange: vi.fn(() => () => {}),
};
return new VoiceAssistantWebSocketServer(
{} as unknown as HTTPServer,
logger as unknown as pino.Logger,
"srv-test",
{
setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null),
getMetricsSnapshot: vi.fn(() => ({
totalAgents: 0,
idleAgents: 0,
runningAgents: 0,
pendingPermissionAgents: 0,
erroredAgents: 0,
})),
} as unknown as AgentManager,
{} as unknown as AgentStorage,
{} as unknown as DownloadTokenStore,
"/tmp/paseo-test",
daemonConfigStore as unknown as DaemonConfigStore,
null,
{ allowedOrigins: new Set() },
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
false,
"1.2.3-test",
undefined,
undefined,
undefined,
{} as unknown as FileBackedChatService,
{} as unknown as LoopService,
{} as unknown as ScheduleService,
{
subscribe: vi.fn(),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
} as unknown as CheckoutDiffManager,
);
}
function createSocket(afterSendBufferedAmounts: number[]): TestSocket {
const socket: TestSocket = {
readyState: 1,
bufferedAmount: 0,
sent: [],
afterSendBufferedAmounts,
send: vi.fn((data) => {
socket.sent.push(data);
socket.bufferedAmount = afterSendBufferedAmounts.shift() ?? socket.bufferedAmount;
}),
close: vi.fn(),
on: vi.fn(),
once: vi.fn(),
};
return socket;
}
function flushRuntimeMetrics(server: VoiceAssistantWebSocketServer): void {
(server as unknown as WebSocketServerInternals).flushRuntimeMetrics({ final: true });
}
function getRuntimeMetricsLog(logger: ReturnType<typeof createLogger>): RuntimeMetricsLog {
const metricsCall = logger.info.mock.calls.find((call) => call[1] === "ws_runtime_metrics");
expect(metricsCall).toBeDefined();
return RuntimeMetricsLogSchema.parse(metricsCall![0]);
}
function sendToClient(
server: VoiceAssistantWebSocketServer,
socket: TestSocket,
message: WSOutboundMessage,
) {
(server as unknown as WebSocketServerInternals).sendToClient(socket, message);
}
function sendBinaryToClient(
server: VoiceAssistantWebSocketServer,
socket: TestSocket,
frame: Uint8Array,
) {
(server as unknown as WebSocketServerInternals).sendBinaryToClient(socket, frame);
}
function attachSessionSocket(server: VoiceAssistantWebSocketServer, socket: TestSocket): void {
(server as unknown as WebSocketServerInternals).sessions.set(socket, {
session: {},
clientId: "client-1",
appVersion: null,
connectionLogger: createLogger(),
sockets: new Set([socket]),
externalDisconnectCleanupTimeout: null,
});
}
function agentStreamMessage(params: {
agentId: string;
event: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"];
}): WSOutboundMessage {
return wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: params.agentId,
event: params.event,
timestamp: "2026-04-17T00:00:00.000Z",
},
});
}
describe("VoiceAssistantWebSocketServer runtime metrics", () => {
it("records outbound message type counts in the ws runtime metrics window", () => {
const logger = createLogger();
const server = createServer(logger);
const broadcastSocket = createSocket([0]);
const directSocket = createSocket([0, 0]);
attachSessionSocket(server, broadcastSocket);
server.broadcast(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
);
sendToClient(
server,
directSocket,
wrapSessionMessage({
type: "status",
payload: {
status: "ok",
message: "ready",
},
}),
);
sendToClient(server, directSocket, { type: "pong" });
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.outboundMessageTypesTop).toEqual([
["session_message", 2],
["pong", 1],
]);
expect(metrics.outboundSessionMessageTypesTop).toEqual([
["agent_stream", 1],
["status", 1],
]);
});
it("records agent_stream subtypes and top agents", () => {
const logger = createLogger();
const server = createServer(logger);
const socket = createSocket([0, 0, 0, 0]);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "hello" },
},
}),
);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "reasoning", text: "thinking" },
},
}),
);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-2",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "there" },
},
}),
);
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.outboundAgentStreamTypesTop).toEqual([
["timeline:assistant_message", 2],
["timeline:reasoning", 1],
["turn_completed", 1],
]);
expect(metrics.outboundAgentStreamAgentsTop).toEqual([
["agent-1", 3],
["agent-2", 1],
]);
});
it("records bufferedAmount p95 and max from samples taken after send", () => {
const logger = createLogger();
const server = createServer(logger);
const broadcastSocket = createSocket([0]);
const directSocket = createSocket([10, 50]);
const binarySocket = createSocket([100]);
attachSessionSocket(server, broadcastSocket);
server.broadcast(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
);
sendToClient(
server,
directSocket,
wrapSessionMessage({
type: "status",
payload: { status: "ok" },
}),
);
sendToClient(server, directSocket, { type: "pong" });
sendBinaryToClient(server, binarySocket, new Uint8Array([1, 2, 3]));
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.bufferedAmount).toEqual({
p95: 100,
max: 100,
});
});
it("counts binary frames without decoding", () => {
const logger = createLogger();
const server = createServer(logger);
const socket = createSocket([12, 24]);
const frame: Uint8Array = new Proxy(Buffer.from([0xff, 0xfe, 0xfd, 0x00, 0xc0]), {
get(_target, property) {
throw new Error(`Binary frame payload was unexpectedly accessed via ${String(property)}`);
},
});
expect(() => sendBinaryToClient(server, socket, frame)).not.toThrow();
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.outboundBinaryFrameTypesTop).toEqual([["binary", 1]]);
});
});
+189 -32
View File
@@ -16,6 +16,7 @@ import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-sto
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import { import {
type ServerInfoStatusPayload, type ServerInfoStatusPayload,
type SessionOutboundMessage,
type WorkspaceSetupSnapshot, type WorkspaceSetupSnapshot,
type WSHelloMessage, type WSHelloMessage,
type WSInboundMessage, type WSInboundMessage,
@@ -39,7 +40,7 @@ import { buildProviderRegistry, createClientsFromRegistry } from "./agent/provid
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js"; import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; import { PushService } from "./push/push-service.js";
import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -57,10 +58,6 @@ import {
isBearerTokenValid, isBearerTokenValid,
type DaemonAuthConfig, type DaemonAuthConfig,
} from "./auth.js"; } from "./auth.js";
import {
WebSocketRuntimeMetricsWindow,
type WebSocketRuntimeCounters,
} from "./websocket/runtime-metrics.js";
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
@@ -273,6 +270,24 @@ interface SessionConnection {
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null; externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
} }
interface WebSocketRuntimeCounters {
connectedAwaitingHello: number;
helloResumed: number;
helloNew: number;
pendingDisconnected: number;
sessionDisconnectedWaitingReconnect: number;
sessionSocketDisconnectedAttached: number;
sessionCleanup: number;
validationFailed: number;
binaryBeforeHelloRejected: number;
pendingMessageRejectedBeforeHello: number;
missingConnectionForMessage: number;
unexpectedHelloOnActiveConnection: number;
relayExternalSocketAttached: number;
originRejected: number;
hostRejected: number;
}
const SLOW_REQUEST_THRESHOLD_MS = 500; const SLOW_REQUEST_THRESHOLD_MS = 500;
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
const HELLO_TIMEOUT_MS = 15_000; const HELLO_TIMEOUT_MS = 15_000;
@@ -343,7 +358,7 @@ export class VoiceAssistantWebSocketServer {
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly daemonConfigStore: DaemonConfigStore; private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore; private readonly pushTokenStore: PushTokenStore;
private readonly pushNotificationSender: PushNotificationSender; private readonly pushService: PushService;
private readonly mcpBaseUrl: string | null; private readonly mcpBaseUrl: string | null;
private speech!: SpeechService | null; private speech!: SpeechService | null;
private terminalManager!: TerminalManager | null; private terminalManager!: TerminalManager | null;
@@ -367,7 +382,33 @@ export class VoiceAssistantWebSocketServer {
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void) | ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
| null; | null;
private serverCapabilities: ServerCapabilities | undefined; private serverCapabilities: ServerCapabilities | undefined;
private readonly runtimeMetrics = new WebSocketRuntimeMetricsWindow(); private runtimeWindowStartedAt = Date.now();
private readonly runtimeCounters: WebSocketRuntimeCounters = {
connectedAwaitingHello: 0,
helloResumed: 0,
helloNew: 0,
pendingDisconnected: 0,
sessionDisconnectedWaitingReconnect: 0,
sessionSocketDisconnectedAttached: 0,
sessionCleanup: 0,
validationFailed: 0,
binaryBeforeHelloRejected: 0,
pendingMessageRejectedBeforeHello: 0,
missingConnectionForMessage: 0,
unexpectedHelloOnActiveConnection: 0,
relayExternalSocketAttached: 0,
originRejected: 0,
hostRejected: 0,
};
private readonly inboundMessageCounts = new Map<string, number>();
private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly outboundMessageCounts = new Map<string, number>();
private readonly outboundSessionMessageCounts = new Map<string, number>();
private readonly outboundAgentStreamCounts = new Map<string, number>();
private readonly outboundAgentStreamByAgentCounts = new Map<string, number>();
private readonly outboundBinaryFrameCounts = new Map<string, number>();
private readonly bufferedAmountSamples: number[] = [];
private readonly requestLatencies = new Map<string, number[]>();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null; private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private unsubscribeSpeechReadiness: (() => void) | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null;
private unsubscribeDaemonConfigChange: (() => void) | null = null; private unsubscribeDaemonConfigChange: (() => void) | null = null;
@@ -412,7 +453,6 @@ export class VoiceAssistantWebSocketServer {
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null, resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
workspaceGitService?: WorkspaceGitService, workspaceGitService?: WorkspaceGitService,
github?: GitHubService, github?: GitHubService,
pushNotificationSender?: PushNotificationSender,
) { ) {
this.logger = logger.child({ module: "websocket-server" }); this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId; this.serverId = serverId;
@@ -489,8 +529,7 @@ export class VoiceAssistantWebSocketServer {
const pushLogger = this.logger.child({ module: "push" }); const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json")); this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
this.pushNotificationSender = this.pushService = new PushService(pushLogger, this.pushTokenStore);
pushNotificationSender ?? createPushNotificationSender(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => { this.agentManager.setAgentAttentionCallback((params) => {
void this.broadcastAgentAttention(params).catch((err) => { void this.broadcastAgentAttention(params).catch((err) => {
@@ -627,7 +666,7 @@ export class VoiceAssistantWebSocketServer {
// WebSocket.OPEN = 1 // WebSocket.OPEN = 1
if (ws.readyState === 1) { if (ws.readyState === 1) {
ws.send(payload); ws.send(payload);
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); this.recordOutboundMessage(message, ws);
} }
} }
} }
@@ -744,7 +783,7 @@ export class VoiceAssistantWebSocketServer {
} }
try { try {
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); this.recordOutboundMessage(message, ws);
} catch (err) { } catch (err) {
this.logger.warn({ err }, "ws_send_failed"); this.logger.warn({ err }, "ws_send_failed");
} }
@@ -756,7 +795,7 @@ export class VoiceAssistantWebSocketServer {
} }
try { try {
ws.send(frame); ws.send(frame);
this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); this.recordOutboundBinaryFrame(ws);
} catch (err) { } catch (err) {
this.logger.warn({ err }, "ws_send_binary_failed"); this.logger.warn({ err }, "ws_send_binary_failed");
} }
@@ -1528,19 +1567,116 @@ export class VoiceAssistantWebSocketServer {
} }
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void { private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
this.runtimeMetrics.incrementCounter(counter); this.runtimeCounters[counter] += 1;
}
private incrementCount(map: Map<string, number>, key: string): void {
map.set(key, (map.get(key) ?? 0) + 1);
} }
private recordInboundMessageType(type: string): void { private recordInboundMessageType(type: string): void {
this.runtimeMetrics.recordInboundMessage(type); this.incrementCount(this.inboundMessageCounts, type);
} }
private recordInboundSessionRequestType(type: string): void { private recordInboundSessionRequestType(type: string): void {
this.runtimeMetrics.recordInboundSessionRequest(type); this.incrementCount(this.inboundSessionRequestCounts, type);
}
private recordOutboundMessage(message: WSOutboundMessage, ws: WebSocketLike): void {
if (message.type !== "session") {
this.incrementCount(this.outboundMessageCounts, message.type);
this.recordBufferedAmount(ws);
return;
}
this.incrementCount(this.outboundMessageCounts, "session_message");
this.incrementCount(this.outboundSessionMessageCounts, message.message.type);
if (message.message.type === "agent_stream") {
this.recordOutboundAgentStreamMessage(message.message.payload);
}
this.recordBufferedAmount(ws);
}
private recordOutboundAgentStreamMessage(
payload: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"],
): void {
const { agentId, event } = payload;
const eventType = event.type === "timeline" ? `timeline:${event.item.type}` : event.type;
this.incrementCount(this.outboundAgentStreamCounts, eventType);
this.incrementCount(this.outboundAgentStreamByAgentCounts, agentId);
}
private recordOutboundBinaryFrame(ws: WebSocketLike): void {
this.incrementCount(this.outboundBinaryFrameCounts, "binary");
this.recordBufferedAmount(ws);
}
private recordBufferedAmount(ws: WebSocketLike): void {
if (typeof ws.bufferedAmount !== "number") {
return;
}
this.bufferedAmountSamples.push(ws.bufferedAmount);
} }
private recordRequestLatency(type: string, durationMs: number): void { private recordRequestLatency(type: string, durationMs: number): void {
this.runtimeMetrics.recordRequestLatency(type, durationMs); let latencies = this.requestLatencies.get(type);
if (!latencies) {
latencies = [];
this.requestLatencies.set(type, latencies);
}
latencies.push(durationMs);
}
private getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}
private computeLatencyStats(): Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> {
const stats: Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> = [];
for (const [type, latencies] of this.requestLatencies) {
if (latencies.length === 0) continue;
latencies.sort((a, b) => a - b);
const count = latencies.length;
const minMs = Math.round(latencies[0]);
const maxMs = Math.round(latencies[count - 1]);
const p50Ms = Math.round(latencies[Math.floor(count / 2)]);
const totalMs = Math.round(latencies.reduce((sum, v) => sum + v, 0));
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
}
stats.sort((a, b) => b.totalMs - a.totalMs);
return stats.slice(0, 15);
}
private computeBufferedAmountStats(): {
p95: number;
max: number;
} {
if (this.bufferedAmountSamples.length === 0) {
return { p95: 0, max: 0 };
}
const samples = [...this.bufferedAmountSamples].sort((a, b) => a - b);
const p95Index = Math.ceil(samples.length * 0.95) - 1;
return {
p95: samples[p95Index] ?? 0,
max: samples[samples.length - 1] ?? 0,
};
} }
private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics { private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics {
@@ -1569,7 +1705,8 @@ export class VoiceAssistantWebSocketServer {
} }
private flushRuntimeMetrics(options?: { final?: boolean }): void { private flushRuntimeMetrics(options?: { final?: boolean }): void {
const runtimeMetrics = this.runtimeMetrics.snapshotAndReset(); const now = Date.now();
const windowMs = Math.max(0, now - this.runtimeWindowStartedAt);
const activeConnections = new Set<SessionConnection>(this.sessions.values()).size; const activeConnections = new Set<SessionConnection>(this.sessions.values()).size;
const activeSockets = this.sessions.size; const activeSockets = this.sessions.size;
const pendingConnections = this.pendingConnections.size; const pendingConnections = this.pendingConnections.size;
@@ -1578,11 +1715,13 @@ export class VoiceAssistantWebSocketServer {
connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null, connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null,
).length; ).length;
const sessionMetrics = this.collectSessionRuntimeMetrics(); const sessionMetrics = this.collectSessionRuntimeMetrics();
const latencyStats = this.computeLatencyStats();
const bufferedAmountStats = this.computeBufferedAmountStats();
const agentSnapshot = this.agentManager.getMetricsSnapshot(); const agentSnapshot = this.agentManager.getMetricsSnapshot();
this.logger.info( this.logger.info(
{ {
windowMs: runtimeMetrics.windowMs, windowMs,
final: Boolean(options?.final), final: Boolean(options?.final),
sessions: { sessions: {
activeConnections, activeConnections,
@@ -1593,21 +1732,37 @@ export class VoiceAssistantWebSocketServer {
activeSockets, activeSockets,
pendingConnections, pendingConnections,
}, },
counters: runtimeMetrics.counters, counters: { ...this.runtimeCounters },
inboundMessageTypesTop: runtimeMetrics.inboundMessageTypesTop, inboundMessageTypesTop: this.getTopCounts(this.inboundMessageCounts, 12),
inboundSessionRequestTypesTop: runtimeMetrics.inboundSessionRequestTypesTop, inboundSessionRequestTypesTop: this.getTopCounts(this.inboundSessionRequestCounts, 20),
outboundMessageTypesTop: runtimeMetrics.outboundMessageTypesTop, outboundMessageTypesTop: this.getTopCounts(this.outboundMessageCounts, 12),
outboundSessionMessageTypesTop: runtimeMetrics.outboundSessionMessageTypesTop, outboundSessionMessageTypesTop: this.getTopCounts(this.outboundSessionMessageCounts, 20),
outboundAgentStreamTypesTop: runtimeMetrics.outboundAgentStreamTypesTop, outboundAgentStreamTypesTop: this.getTopCounts(this.outboundAgentStreamCounts, 20),
outboundAgentStreamAgentsTop: runtimeMetrics.outboundAgentStreamAgentsTop, outboundAgentStreamAgentsTop: this.getTopCounts(this.outboundAgentStreamByAgentCounts, 20),
outboundBinaryFrameTypesTop: runtimeMetrics.outboundBinaryFrameTypesTop, outboundBinaryFrameTypesTop: this.getTopCounts(this.outboundBinaryFrameCounts, 12),
bufferedAmount: runtimeMetrics.bufferedAmount, bufferedAmount: bufferedAmountStats,
runtime: sessionMetrics, runtime: sessionMetrics,
latency: runtimeMetrics.latency, latency: latencyStats,
agents: agentSnapshot, agents: agentSnapshot,
}, },
"ws_runtime_metrics", "ws_runtime_metrics",
); );
for (const counter of Object.keys(this.runtimeCounters) as Array<
keyof WebSocketRuntimeCounters
>) {
this.runtimeCounters[counter] = 0;
}
this.inboundMessageCounts.clear();
this.inboundSessionRequestCounts.clear();
this.outboundMessageCounts.clear();
this.outboundSessionMessageCounts.clear();
this.outboundAgentStreamCounts.clear();
this.outboundAgentStreamByAgentCounts.clear();
this.outboundBinaryFrameCounts.clear();
this.bufferedAmountSamples.length = 0;
this.requestLatencies.clear();
this.runtimeWindowStartedAt = now;
} }
private getClientActivityState(session: Session): ClientPresenceState { private getClientActivityState(session: Session): ClientPresenceState {
@@ -1664,9 +1819,11 @@ export class VoiceAssistantWebSocketServer {
}); });
if (plan.shouldPush) { if (plan.shouldPush) {
void this.pushNotificationSender.send(notification).catch((err) => { const tokens = this.pushTokenStore.getAllTokens();
this.logger.warn({ err, agentId: params.agentId }, "Failed to send push notification"); this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
}); if (tokens.length > 0) {
void this.pushService.sendPush(tokens, notification);
}
} }
for (const [clientIndex, { ws }] of clientEntries.entries()) { for (const [clientIndex, { ws }] of clientEntries.entries()) {
@@ -1,202 +0,0 @@
import { describe, expect, it } from "vitest";
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
import { wrapSessionMessage } from "../messages.js";
import { WebSocketRuntimeMetricsWindow } from "./runtime-metrics.js";
function createMetricsWindow(): {
metrics: WebSocketRuntimeMetricsWindow;
advanceClock(ms: number): void;
} {
let now = 1_000;
return {
metrics: new WebSocketRuntimeMetricsWindow(() => now),
advanceClock(ms: number) {
now += ms;
},
};
}
function agentStreamMessage(params: {
agentId: string;
event: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"];
}): WSOutboundMessage {
return wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: params.agentId,
event: params.event,
timestamp: "2026-04-17T00:00:00.000Z",
},
});
}
describe("WebSocketRuntimeMetricsWindow", () => {
it("records outbound message type counts in the runtime metrics window", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
0,
);
metrics.recordOutboundMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "ok",
message: "ready",
},
}),
0,
);
metrics.recordOutboundMessage({ type: "pong" }, 0);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.outboundMessageTypesTop).toEqual([
["session_message", 2],
["pong", 1],
]);
expect(snapshot.outboundSessionMessageTypesTop).toEqual([
["agent_stream", 1],
["status", 1],
]);
});
it("records agent_stream subtypes and top agents", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "hello" },
},
}),
0,
);
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "reasoning", text: "thinking" },
},
}),
0,
);
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
0,
);
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-2",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "there" },
},
}),
0,
);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.outboundAgentStreamTypesTop).toEqual([
["timeline:assistant_message", 2],
["timeline:reasoning", 1],
["turn_completed", 1],
]);
expect(snapshot.outboundAgentStreamAgentsTop).toEqual([
["agent-1", 3],
["agent-2", 1],
]);
});
it("records bufferedAmount p95 and max from samples taken after send", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
0,
);
metrics.recordOutboundMessage(
wrapSessionMessage({
type: "status",
payload: { status: "ok" },
}),
10,
);
metrics.recordOutboundMessage({ type: "pong" }, 50);
metrics.recordOutboundBinaryFrame(100);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.bufferedAmount).toEqual({
p95: 100,
max: 100,
});
});
it("counts binary frames without decoding", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundBinaryFrame(24);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.outboundBinaryFrameTypesTop).toEqual([["binary", 1]]);
});
it("resets the runtime window after producing a snapshot", () => {
const { metrics, advanceClock } = createMetricsWindow();
metrics.incrementCounter("helloNew");
metrics.recordInboundMessage("session");
metrics.recordInboundSessionRequest("send");
metrics.recordRequestLatency("send", 12.4);
advanceClock(250);
const firstSnapshot = metrics.snapshotAndReset();
const secondSnapshot = metrics.snapshotAndReset();
expect(firstSnapshot.windowMs).toBe(250);
expect(firstSnapshot.counters.helloNew).toBe(1);
expect(firstSnapshot.inboundMessageTypesTop).toEqual([["session", 1]]);
expect(firstSnapshot.inboundSessionRequestTypesTop).toEqual([["send", 1]]);
expect(firstSnapshot.latency).toEqual([
{
type: "send",
count: 1,
minMs: 12,
maxMs: 12,
p50Ms: 12,
totalMs: 12,
},
]);
expect(secondSnapshot.windowMs).toBe(0);
expect(secondSnapshot.counters.helloNew).toBe(0);
expect(secondSnapshot.inboundMessageTypesTop).toEqual([]);
expect(secondSnapshot.inboundSessionRequestTypesTop).toEqual([]);
expect(secondSnapshot.latency).toEqual([]);
});
});
@@ -1,215 +0,0 @@
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
export interface WebSocketRuntimeCounters {
connectedAwaitingHello: number;
helloResumed: number;
helloNew: number;
pendingDisconnected: number;
sessionDisconnectedWaitingReconnect: number;
sessionSocketDisconnectedAttached: number;
sessionCleanup: number;
validationFailed: number;
binaryBeforeHelloRejected: number;
pendingMessageRejectedBeforeHello: number;
missingConnectionForMessage: number;
unexpectedHelloOnActiveConnection: number;
relayExternalSocketAttached: number;
originRejected: number;
hostRejected: number;
}
export interface WebSocketRuntimeMetricsSnapshot {
windowMs: number;
counters: WebSocketRuntimeCounters;
inboundMessageTypesTop: Array<[string, number]>;
inboundSessionRequestTypesTop: Array<[string, number]>;
outboundMessageTypesTop: Array<[string, number]>;
outboundSessionMessageTypesTop: Array<[string, number]>;
outboundAgentStreamTypesTop: Array<[string, number]>;
outboundAgentStreamAgentsTop: Array<[string, number]>;
outboundBinaryFrameTypesTop: Array<[string, number]>;
bufferedAmount: {
p95: number;
max: number;
};
latency: Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}>;
}
type Clock = () => number;
export class WebSocketRuntimeMetricsWindow {
private windowStartedAt: number;
private readonly counters: WebSocketRuntimeCounters = createRuntimeCounters();
private readonly inboundMessageCounts = new Map<string, number>();
private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly outboundMessageCounts = new Map<string, number>();
private readonly outboundSessionMessageCounts = new Map<string, number>();
private readonly outboundAgentStreamCounts = new Map<string, number>();
private readonly outboundAgentStreamByAgentCounts = new Map<string, number>();
private readonly outboundBinaryFrameCounts = new Map<string, number>();
private readonly bufferedAmountSamples: number[] = [];
private readonly requestLatencies = new Map<string, number[]>();
constructor(private readonly clock: Clock = Date.now) {
this.windowStartedAt = this.clock();
}
incrementCounter(counter: keyof WebSocketRuntimeCounters): void {
this.counters[counter] += 1;
}
recordInboundMessage(type: string): void {
incrementCount(this.inboundMessageCounts, type);
}
recordInboundSessionRequest(type: string): void {
incrementCount(this.inboundSessionRequestCounts, type);
}
recordOutboundMessage(message: WSOutboundMessage, bufferedAmount?: number): void {
if (message.type !== "session") {
incrementCount(this.outboundMessageCounts, message.type);
this.recordBufferedAmount(bufferedAmount);
return;
}
incrementCount(this.outboundMessageCounts, "session_message");
incrementCount(this.outboundSessionMessageCounts, message.message.type);
if (message.message.type === "agent_stream") {
this.recordOutboundAgentStreamMessage(message.message.payload);
}
this.recordBufferedAmount(bufferedAmount);
}
recordOutboundBinaryFrame(bufferedAmount?: number): void {
incrementCount(this.outboundBinaryFrameCounts, "binary");
this.recordBufferedAmount(bufferedAmount);
}
recordRequestLatency(type: string, durationMs: number): void {
let latencies = this.requestLatencies.get(type);
if (!latencies) {
latencies = [];
this.requestLatencies.set(type, latencies);
}
latencies.push(durationMs);
}
snapshotAndReset(): WebSocketRuntimeMetricsSnapshot {
const now = this.clock();
const snapshot: WebSocketRuntimeMetricsSnapshot = {
windowMs: Math.max(0, now - this.windowStartedAt),
counters: { ...this.counters },
inboundMessageTypesTop: getTopCounts(this.inboundMessageCounts, 12),
inboundSessionRequestTypesTop: getTopCounts(this.inboundSessionRequestCounts, 20),
outboundMessageTypesTop: getTopCounts(this.outboundMessageCounts, 12),
outboundSessionMessageTypesTop: getTopCounts(this.outboundSessionMessageCounts, 20),
outboundAgentStreamTypesTop: getTopCounts(this.outboundAgentStreamCounts, 20),
outboundAgentStreamAgentsTop: getTopCounts(this.outboundAgentStreamByAgentCounts, 20),
outboundBinaryFrameTypesTop: getTopCounts(this.outboundBinaryFrameCounts, 12),
bufferedAmount: this.computeBufferedAmountStats(),
latency: this.computeLatencyStats(),
};
this.reset(now);
return snapshot;
}
private recordOutboundAgentStreamMessage(
payload: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"],
): void {
const { agentId, event } = payload;
const eventType = event.type === "timeline" ? `timeline:${event.item.type}` : event.type;
incrementCount(this.outboundAgentStreamCounts, eventType);
incrementCount(this.outboundAgentStreamByAgentCounts, agentId);
}
private recordBufferedAmount(bufferedAmount: number | undefined): void {
if (typeof bufferedAmount !== "number") {
return;
}
this.bufferedAmountSamples.push(bufferedAmount);
}
private computeLatencyStats(): WebSocketRuntimeMetricsSnapshot["latency"] {
const stats: WebSocketRuntimeMetricsSnapshot["latency"] = [];
for (const [type, latencies] of this.requestLatencies) {
if (latencies.length === 0) continue;
const sortedLatencies = [...latencies].sort((a, b) => a - b);
const count = sortedLatencies.length;
const minMs = Math.round(sortedLatencies[0]);
const maxMs = Math.round(sortedLatencies[count - 1]);
const p50Ms = Math.round(sortedLatencies[Math.floor(count / 2)]);
const totalMs = Math.round(sortedLatencies.reduce((sum, value) => sum + value, 0));
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
}
stats.sort((a, b) => b.totalMs - a.totalMs);
return stats.slice(0, 15);
}
private computeBufferedAmountStats(): WebSocketRuntimeMetricsSnapshot["bufferedAmount"] {
if (this.bufferedAmountSamples.length === 0) {
return { p95: 0, max: 0 };
}
const samples = [...this.bufferedAmountSamples].sort((a, b) => a - b);
const p95Index = Math.ceil(samples.length * 0.95) - 1;
return {
p95: samples[p95Index] ?? 0,
max: samples[samples.length - 1] ?? 0,
};
}
private reset(windowStartedAt: number): void {
for (const counter of Object.keys(this.counters) as Array<keyof WebSocketRuntimeCounters>) {
this.counters[counter] = 0;
}
this.inboundMessageCounts.clear();
this.inboundSessionRequestCounts.clear();
this.outboundMessageCounts.clear();
this.outboundSessionMessageCounts.clear();
this.outboundAgentStreamCounts.clear();
this.outboundAgentStreamByAgentCounts.clear();
this.outboundBinaryFrameCounts.clear();
this.bufferedAmountSamples.length = 0;
this.requestLatencies.clear();
this.windowStartedAt = windowStartedAt;
}
}
function createRuntimeCounters(): WebSocketRuntimeCounters {
return {
connectedAwaitingHello: 0,
helloResumed: 0,
helloNew: 0,
pendingDisconnected: 0,
sessionDisconnectedWaitingReconnect: 0,
sessionSocketDisconnectedAttached: 0,
sessionCleanup: 0,
validationFailed: 0,
binaryBeforeHelloRejected: 0,
pendingMessageRejectedBeforeHello: 0,
missingConnectionForMessage: 0,
unexpectedHelloOnActiveConnection: 0,
relayExternalSocketAttached: 0,
originRejected: 0,
hostRejected: 0,
};
}
function incrementCount(map: Map<string, number>, key: string): void {
map.set(key, (map.get(key) ?? 0) + 1);
}
function getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}
@@ -13,6 +13,11 @@ import { isPlatform } from "../test-utils/platform.js";
const REPO_CWD = path.resolve("/tmp/repo"); const REPO_CWD = path.resolve("/tmp/repo");
interface ServiceInternals {
workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
scheduleWorkspaceRefresh(cwd: string, options: { force: boolean; reason: string }): void;
}
function createLogger() { function createLogger() {
const logger = { const logger = {
child: () => logger, child: () => logger,
@@ -735,8 +740,9 @@ describe("WorkspaceGitServiceImpl", () => {
const service = createService({ watch }); const service = createService({ watch });
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn()); const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(REPO_CWD);
expect(vi.getTimerCount()).toBe(1); expect(target?.fallbackRefreshInterval).not.toBeNull();
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -756,6 +762,7 @@ describe("WorkspaceGitServiceImpl", () => {
const plainCwd = path.join(os.tmpdir(), "plain"); const plainCwd = path.join(os.tmpdir(), "plain");
const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn()); const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn());
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(plainCwd);
expect(subscription.repoRoot).toBeNull(); expect(subscription.repoRoot).toBeNull();
const expectedRecursive = process.platform !== "linux"; const expectedRecursive = process.platform !== "linux";
@@ -764,13 +771,14 @@ describe("WorkspaceGitServiceImpl", () => {
{ recursive: expectedRecursive }, { recursive: expectedRecursive },
expect.any(Function), expect.any(Function),
); );
expect(vi.getTimerCount()).toBe(1); expect(target?.repoWatchPath).toBe(plainCwd);
expect(target?.fallbackRefreshInterval).not.toBeNull();
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
}); });
test("working tree changes notify watch listeners immediately", async () => { test("working tree changes notify listeners and schedule workspace refresh", async () => {
const watchCallbacks: Array<() => void> = []; const watchCallbacks: Array<() => void> = [];
const watch = vi.fn( const watch = vi.fn(
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => { (_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
@@ -779,6 +787,7 @@ describe("WorkspaceGitServiceImpl", () => {
}, },
); );
const service = createService({ watch }); const service = createService({ watch });
const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh");
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener); const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener);
@@ -787,6 +796,10 @@ describe("WorkspaceGitServiceImpl", () => {
watchCallbacks[0]?.(); watchCallbacks[0]?.();
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith(REPO_CWD, {
force: true,
reason: "working-tree-watch",
});
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -40,7 +40,7 @@ describe("connection offer", () => {
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer); expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer);
}); });
it("leaves relay TLS unset when absent", () => { it("defaults relay TLS to false when absent", () => {
expect( expect(
ConnectionOfferSchema.parse({ ConnectionOfferSchema.parse({
v: 2, v: 2,
@@ -52,7 +52,7 @@ describe("connection offer", () => {
v: 2, v: 2,
serverId: "server-123", serverId: "server-123",
daemonPublicKeyB64: "pubkey", daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.example.com:80" }, relay: { endpoint: "relay.example.com:80", useTls: false },
}); });
}); });
@@ -12,7 +12,7 @@ export const ConnectionOfferV2Schema = z.object({
daemonPublicKeyB64: z.string().min(1), daemonPublicKeyB64: z.string().min(1),
relay: z.object({ relay: z.object({
endpoint: z.string().min(1), endpoint: z.string().min(1),
useTls: z.boolean().optional(), useTls: z.boolean().optional().default(false),
}), }),
}); });
-134
View File
@@ -1,134 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import type { Task, TaskStatus } from "./types.js";
function serializeTask(task: Task): string {
const frontmatterLines = [
"---",
`id: ${task.id}`,
`title: ${task.title}`,
`status: ${task.status}`,
`deps: [${task.deps.join(", ")}]`,
`created: ${task.created}`,
];
if (task.parentId) {
frontmatterLines.push(`parentId: ${task.parentId}`);
}
if (task.assignee) {
frontmatterLines.push(`assignee: ${task.assignee}`);
}
if (task.priority !== undefined) {
frontmatterLines.push(`priority: ${task.priority}`);
}
frontmatterLines.push("---");
const frontmatter = frontmatterLines.join("\n");
let content = "";
if (task.body) {
content += task.body + "\n";
}
if (task.acceptanceCriteria.length > 0) {
content += "\n## Acceptance Criteria\n\n";
for (const criterion of task.acceptanceCriteria) {
content += `- [ ] ${criterion}\n`;
}
}
if (task.notes.length > 0) {
content += "\n## Notes\n";
for (const note of task.notes) {
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
}
}
return frontmatter + "\n\n" + content;
}
function parseTask(content: string): Task {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
if (!frontmatterMatch) {
throw new Error("Invalid task file: missing frontmatter");
}
const frontmatter = frontmatterMatch[1];
const fileBody = content.slice(frontmatterMatch[0].length);
const getValue = (key: string): string => {
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
return match ? match[1] : "";
};
const depsStr = getValue("deps");
const depsMatch = depsStr.match(/\[(.*)\]/);
const deps =
depsMatch && depsMatch[1].trim()
? depsMatch[1]
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [];
const notes: Task["notes"] = [];
const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/);
if (notesSection) {
const noteMatches = notesSection[1].matchAll(
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g,
);
for (const match of noteMatches) {
notes.push({
timestamp: match[1],
content: match[2].trim(),
});
}
}
const acceptanceCriteria: string[] = [];
const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/);
if (criteriaSection) {
const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm);
for (const match of criteriaMatches) {
acceptanceCriteria.push(match[1].trim());
}
}
let taskBody = fileBody;
const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/);
if (firstSection) {
taskBody = fileBody.slice(0, firstSection.index).trim();
}
taskBody = taskBody.trim();
const assignee = getValue("assignee");
const parentId = getValue("parentId");
const priorityStr = getValue("priority");
const priority = priorityStr ? parseInt(priorityStr, 10) : undefined;
return {
id: getValue("id"),
title: getValue("title"),
status: getValue("status") as TaskStatus,
deps,
parentId: parentId || undefined,
body: taskBody,
acceptanceCriteria,
notes,
created: getValue("created") || new Date().toISOString(),
assignee: assignee || undefined,
priority,
raw: content,
};
}
export async function readTaskDocument(filePath: string): Promise<Task> {
const content = await readFile(filePath, "utf-8");
return parseTask(content);
}
export async function writeTaskDocument(filePath: string, task: Task): Promise<void> {
await writeFile(filePath, serializeTask(task), "utf-8");
}
+131 -5
View File
@@ -1,19 +1,144 @@
import { readdir, mkdir, unlink } from "node:fs/promises"; import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { Task, TaskStore, CreateTaskOptions } from "./types.js"; import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js";
import { import {
isBlockedTask, isBlockedTask,
isReadyTask, isReadyTask,
loadScopedTaskGraph, loadScopedTaskGraph,
sortByPriorityThenCreated, sortByPriorityThenCreated,
} from "./task-graph.js"; } from "./task-graph.js";
import { readTaskDocument, writeTaskDocument } from "./task-document.js";
function generateId(): string { function generateId(): string {
return randomBytes(4).toString("hex"); return randomBytes(4).toString("hex");
} }
function serializeTask(task: Task): string {
const frontmatterLines = [
"---",
`id: ${task.id}`,
`title: ${task.title}`,
`status: ${task.status}`,
`deps: [${task.deps.join(", ")}]`,
`created: ${task.created}`,
];
if (task.parentId) {
frontmatterLines.push(`parentId: ${task.parentId}`);
}
if (task.assignee) {
frontmatterLines.push(`assignee: ${task.assignee}`);
}
if (task.priority !== undefined) {
frontmatterLines.push(`priority: ${task.priority}`);
}
frontmatterLines.push("---");
const frontmatter = frontmatterLines.join("\n");
let content = "";
if (task.body) {
content += task.body + "\n";
}
if (task.acceptanceCriteria.length > 0) {
content += "\n## Acceptance Criteria\n\n";
for (const criterion of task.acceptanceCriteria) {
content += `- [ ] ${criterion}\n`;
}
}
if (task.notes.length > 0) {
content += "\n## Notes\n";
for (const note of task.notes) {
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
}
}
return frontmatter + "\n\n" + content;
}
function parseTask(content: string): Task {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
if (!frontmatterMatch) {
throw new Error("Invalid task file: missing frontmatter");
}
const frontmatter = frontmatterMatch[1];
const fileBody = content.slice(frontmatterMatch[0].length);
const getValue = (key: string): string => {
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
return match ? match[1] : "";
};
const depsStr = getValue("deps");
const depsMatch = depsStr.match(/\[(.*)\]/);
const deps =
depsMatch && depsMatch[1].trim()
? depsMatch[1]
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [];
// Parse notes from body
const notes: Task["notes"] = [];
const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/);
if (notesSection) {
const noteMatches = notesSection[1].matchAll(
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g,
);
for (const match of noteMatches) {
notes.push({
timestamp: match[1],
content: match[2].trim(),
});
}
}
// Parse acceptance criteria
const acceptanceCriteria: string[] = [];
const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/);
if (criteriaSection) {
const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm);
for (const match of criteriaMatches) {
acceptanceCriteria.push(match[1].trim());
}
}
// Body is everything before ## Acceptance Criteria or ## Notes
let taskBody = fileBody;
const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/);
if (firstSection) {
taskBody = fileBody.slice(0, firstSection.index).trim();
}
taskBody = taskBody.trim();
const assignee = getValue("assignee");
const parentId = getValue("parentId");
const priorityStr = getValue("priority");
const priority = priorityStr ? parseInt(priorityStr, 10) : undefined;
return {
id: getValue("id"),
title: getValue("title"),
status: getValue("status") as TaskStatus,
deps,
parentId: parentId || undefined,
body: taskBody,
acceptanceCriteria,
notes,
created: getValue("created") || new Date().toISOString(),
assignee: assignee || undefined,
priority,
raw: content,
};
}
export class FileTaskStore implements TaskStore { export class FileTaskStore implements TaskStore {
constructor(private readonly dir: string) {} constructor(private readonly dir: string) {}
@@ -27,7 +152,8 @@ export class FileTaskStore implements TaskStore {
private async readTask(id: string): Promise<Task | null> { private async readTask(id: string): Promise<Task | null> {
try { try {
return await readTaskDocument(this.taskPath(id)); const content = await readFile(this.taskPath(id), "utf-8");
return parseTask(content);
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") { if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null; return null;
@@ -38,7 +164,7 @@ export class FileTaskStore implements TaskStore {
private async writeTask(task: Task): Promise<void> { private async writeTask(task: Task): Promise<void> {
await this.ensureDir(); await this.ensureDir();
await writeTaskDocument(this.taskPath(task.id), task); await writeFile(this.taskPath(task.id), serializeTask(task), "utf-8");
} }
async list(): Promise<Task[]> { async list(): Promise<Task[]> {
@@ -54,7 +54,6 @@ export function runGitCommand(
const child = spawnProcess("git", args, { const child = spawnProcess("git", args, {
cwd: options.cwd, cwd: options.cwd,
envOverlay: mergeEnvOverlays(options.env, options.envOverlay), envOverlay: mergeEnvOverlays(options.env, options.envOverlay),
shell: false,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
@@ -1,42 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runGitCommand } from "./run-git-command.js";
const tempDirs: string[] = [];
function makeTempRepo(): string {
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
tempDirs.push(repo);
return repo;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
describe("runGitCommand shell behavior", () => {
it("passes git arguments directly instead of through the platform shell", async () => {
const repo = makeTempRepo();
const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
const expandedName = "expanded-by-cmd";
await runGitCommand(["init"], { cwd: repo });
writeFileSync(path.join(repo, literalName), "literal\n");
writeFileSync(path.join(repo, expandedName), "expanded\n");
await runGitCommand(["add", literalName, expandedName], { cwd: repo });
const result = await runGitCommand(["ls-files", "--error-unmatch", literalName], {
cwd: repo,
envOverlay: {
PASEO_GIT_SHELL_SENTINEL: expandedName,
},
});
expect(result.stdout.trim()).toBe(literalName);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.74", "version": "0.1.71",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {