mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c46e5f8b | ||
|
|
10fdc4330d |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
|
||||||
Generated
+20
-23
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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,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 () => {};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
+68
-14
@@ -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,
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.74",
|
"version": "0.1.71",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+115
-72
@@ -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" };
|
||||||
|
|||||||
-7
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-4
@@ -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>;
|
||||||
|
|||||||
@@ -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\?/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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,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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user