mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +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
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: Nix Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "nix/**"
|
||||||
|
- "flake.nix"
|
||||||
|
- "flake.lock"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
- "packages/highlight/**"
|
||||||
|
- "packages/server/**"
|
||||||
|
- "packages/relay/**"
|
||||||
|
- "packages/cli/**"
|
||||||
|
- "scripts/update-nix.sh"
|
||||||
|
- "scripts/fix-lockfile.mjs"
|
||||||
|
- ".github/workflows/nix-build.yml"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "nix/**"
|
||||||
|
- "flake.nix"
|
||||||
|
- "flake.lock"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
- "packages/highlight/**"
|
||||||
|
- "packages/server/**"
|
||||||
|
- "packages/relay/**"
|
||||||
|
- "packages/cli/**"
|
||||||
|
- "scripts/update-nix.sh"
|
||||||
|
- "scripts/fix-lockfile.mjs"
|
||||||
|
- ".github/workflows/nix-build.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Update lockfile + Nix hash if stale
|
||||||
|
run: ./scripts/update-nix.sh
|
||||||
|
|
||||||
|
- name: Build Nix package
|
||||||
|
run: nix build .#default -o result
|
||||||
|
|
||||||
|
- name: Commit hash/lockfile updates (main push only)
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add package-lock.json nix/package.nix
|
||||||
|
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||||
|
git push
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
name: Nix Update Hash
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- "nix/**"
|
|
||||||
- "flake.nix"
|
|
||||||
- "flake.lock"
|
|
||||||
- "package.json"
|
|
||||||
- "package-lock.json"
|
|
||||||
- "packages/highlight/**"
|
|
||||||
- "packages/server/**"
|
|
||||||
- "packages/relay/**"
|
|
||||||
- "packages/cli/**"
|
|
||||||
- "scripts/update-nix.sh"
|
|
||||||
- "scripts/fix-lockfile.mjs"
|
|
||||||
- ".github/workflows/nix-update-hash.yml"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-hash:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/create-github-app-token@v1
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
|
|
||||||
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.ref }}
|
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- uses: cachix/install-nix-action@v31
|
|
||||||
with:
|
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
|
||||||
|
|
||||||
- name: Update lockfile + Nix hash if stale
|
|
||||||
run: ./scripts/update-nix.sh
|
|
||||||
|
|
||||||
- name: Build Nix package
|
|
||||||
run: nix build .#default -o result
|
|
||||||
|
|
||||||
- name: Commit hash/lockfile updates
|
|
||||||
run: |
|
|
||||||
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
|
|
||||||
git config user.name "paseo-ai[bot]"
|
|
||||||
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
|
|
||||||
git add package-lock.json nix/npm-deps.hash
|
|
||||||
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
|
|
||||||
git push
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
name: Nix
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- "nix/**"
|
|
||||||
- "flake.nix"
|
|
||||||
- "flake.lock"
|
|
||||||
- "package.json"
|
|
||||||
- "package-lock.json"
|
|
||||||
- "packages/highlight/**"
|
|
||||||
- "packages/server/**"
|
|
||||||
- "packages/relay/**"
|
|
||||||
- "packages/cli/**"
|
|
||||||
- "scripts/update-nix.sh"
|
|
||||||
- "scripts/fix-lockfile.mjs"
|
|
||||||
- ".github/workflows/nix.yml"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- uses: cachix/install-nix-action@v31
|
|
||||||
with:
|
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
|
||||||
|
|
||||||
- name: Update lockfile + Nix hash if stale
|
|
||||||
run: ./scripts/update-nix.sh
|
|
||||||
|
|
||||||
- name: Build Nix package
|
|
||||||
run: nix build .#default -o result
|
|
||||||
|
|
||||||
- name: Smoke Nix daemon
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
export PASEO_HOME
|
|
||||||
PASEO_HOME="$(mktemp -d)"
|
|
||||||
export PASEO_LISTEN=127.0.0.1:6767
|
|
||||||
|
|
||||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
|
||||||
kill "$DAEMON_PID"
|
|
||||||
wait "$DAEMON_PID" || true
|
|
||||||
fi
|
|
||||||
rm -rf "$PASEO_HOME"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
|
||||||
DAEMON_PID=$!
|
|
||||||
|
|
||||||
deadline=$((SECONDS + 30))
|
|
||||||
while (( SECONDS < deadline )); do
|
|
||||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
|
||||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
|
||||||
echo "$STATUS_JSON"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
|
||||||
echo "Nix daemon exited before becoming reachable."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "::group::daemon.log"
|
|
||||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
|
||||||
echo "::endgroup::"
|
|
||||||
|
|
||||||
echo "::group::paseo-server stdout/stderr"
|
|
||||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
|
||||||
echo "::endgroup::"
|
|
||||||
|
|
||||||
echo "::group::paseo daemon status"
|
|
||||||
./result/bin/paseo daemon status || true
|
|
||||||
echo "::endgroup::"
|
|
||||||
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Build Nix desktop package
|
|
||||||
run: nix build .#desktop -o result-desktop
|
|
||||||
@@ -1,55 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.75 - 2026-05-12
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
|
|
||||||
- NixOS: `services.paseo.settings` renders declarative daemon config to disk, and typed `services.paseo.relay` options auto-wire the relay endpoint. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
|
|
||||||
- NixOS: Paseo desktop is now packaged in the flake — `nix run github:getpaseo/paseo#desktop` launches the Electron app. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
|
|
||||||
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
|
|
||||||
- Copilot's **Allow All** mode (previously misnamed Autopilot) now actually suppresses tool, path, and URL permission prompts. ([#935](https://github.com/getpaseo/paseo/pull/935))
|
|
||||||
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
|
|
||||||
- iPhone HEIC photos now attach correctly from the image picker. ([#934](https://github.com/getpaseo/paseo/pull/934))
|
|
||||||
- Scheduled agents now archive automatically after each run instead of piling up in the active list. ([#945](https://github.com/getpaseo/paseo/pull/945))
|
|
||||||
- Windows: Codex command summaries show the underlying command instead of `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
|
|
||||||
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
|
||||||
|
|
||||||
## 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
|
||||||
@@ -73,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
|
||||||
|
|
||||||
|
|||||||
@@ -59,30 +59,6 @@ Required fields for custom providers:
|
|||||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||||
- `label` — display name in the UI
|
- `label` — display name in the UI
|
||||||
|
|
||||||
### Codex with an OpenAI-compatible endpoint
|
|
||||||
|
|
||||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"providers": {
|
|
||||||
"my-codex": {
|
|
||||||
"extends": "codex",
|
|
||||||
"label": "My Codex",
|
|
||||||
"env": {
|
|
||||||
"OPENAI_API_KEY": "sk-...",
|
|
||||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
|
||||||
},
|
|
||||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Z.AI (Zhipu) coding plan
|
## Z.AI (Zhipu) coding plan
|
||||||
|
|||||||
+2
-2
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
providers: Record<providerId, ProviderOverride>
|
providers: Record<providerId, ProviderOverride>
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||||
},
|
},
|
||||||
log: {
|
log: {
|
||||||
level, format,
|
level, format,
|
||||||
|
|||||||
+1
-8
@@ -39,14 +39,7 @@ In any worktree-style or portless setup, never assume default ports.
|
|||||||
|
|
||||||
### Daemon logs
|
### Daemon logs
|
||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||||
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
|
|
||||||
session, and agent-manager traces for stuck-state debugging.
|
|
||||||
|
|
||||||
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
|
||||||
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
|
|
||||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
|
||||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
|
||||||
|
|
||||||
## paseo.json service scripts
|
## paseo.json service scripts
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
|
||||||
@@ -26,18 +26,11 @@
|
|||||||
let
|
let
|
||||||
pkgs = pkgsFor system;
|
pkgs = pkgsFor system;
|
||||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||||
isLinux = nixpkgs.lib.elem system [
|
|
||||||
"x86_64-linux"
|
|
||||||
"aarch64-linux"
|
|
||||||
];
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = paseo;
|
default = paseo;
|
||||||
paseo = paseo;
|
paseo = paseo;
|
||||||
}
|
}
|
||||||
// nixpkgs.lib.optionalAttrs isLinux {
|
|
||||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
nixosModules.default = self.nixosModules.paseo;
|
nixosModules.default = self.nixosModules.paseo;
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
{
|
|
||||||
lib,
|
|
||||||
stdenv,
|
|
||||||
buildNpmPackage,
|
|
||||||
nodejs_22,
|
|
||||||
python3,
|
|
||||||
makeWrapper,
|
|
||||||
copyDesktopItems,
|
|
||||||
makeDesktopItem,
|
|
||||||
electron,
|
|
||||||
libuv,
|
|
||||||
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
|
|
||||||
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
|
|
||||||
# different value.
|
|
||||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
|
||||||
}:
|
|
||||||
|
|
||||||
buildNpmPackage rec {
|
|
||||||
pname = "paseo-desktop";
|
|
||||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
|
||||||
|
|
||||||
src = lib.cleanSourceWith {
|
|
||||||
src = ./..;
|
|
||||||
filter = path: type:
|
|
||||||
let
|
|
||||||
baseName = builtins.baseNameOf path;
|
|
||||||
relPath = lib.removePrefix (toString ./..) path;
|
|
||||||
in
|
|
||||||
# Exclude mobile-only platform code (we only need the web/electron build)
|
|
||||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
|
||||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
|
||||||
# Website is unrelated to the desktop app
|
|
||||||
&& !(lib.hasPrefix "/packages/website" relPath)
|
|
||||||
# Test fixtures and build artifacts
|
|
||||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
|
||||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
|
||||||
&& baseName != "node_modules"
|
|
||||||
&& baseName != ".git"
|
|
||||||
&& baseName != ".paseo"
|
|
||||||
&& baseName != ".DS_Store"
|
|
||||||
&& baseName != "release";
|
|
||||||
};
|
|
||||||
|
|
||||||
nodejs = nodejs_22;
|
|
||||||
inherit npmDepsHash;
|
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
|
||||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
|
||||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
python3 # for node-gyp (node-pty)
|
|
||||||
makeWrapper
|
|
||||||
copyDesktopItems
|
|
||||||
];
|
|
||||||
|
|
||||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
|
|
||||||
|
|
||||||
dontNpmBuild = true;
|
|
||||||
|
|
||||||
env = {
|
|
||||||
EXPO_NO_TELEMETRY = "1";
|
|
||||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
|
||||||
# to phone home during the build.
|
|
||||||
CI = "1";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
|
||||||
npm rebuild node-pty
|
|
||||||
|
|
||||||
# Daemon workspaces (highlight + relay + server + cli)
|
|
||||||
npm run build:daemon
|
|
||||||
|
|
||||||
# App workspace deps not covered by build:daemon
|
|
||||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
|
||||||
|
|
||||||
# Expo web export for the Electron renderer
|
|
||||||
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
|
|
||||||
|
|
||||||
# Desktop main process (tsc only — NOT electron-builder)
|
|
||||||
npm run build:main --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
runHook preInstall
|
|
||||||
|
|
||||||
mkdir -p $out/share/paseo-desktop $out/bin
|
|
||||||
|
|
||||||
# Preserve the monorepo layout so main.js's dev-mode path resolution
|
|
||||||
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
|
|
||||||
# without patching: invoked unpackaged via `electron path/to/main.js`,
|
|
||||||
# `app.isPackaged` is false, so these relative paths are used.
|
|
||||||
#
|
|
||||||
# Copy the entire packages/ tree (not just built artifacts) because npm
|
|
||||||
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
|
|
||||||
# Missing any workspace package leaves dangling symlinks and fails the
|
|
||||||
# noBrokenSymlinks output check. The cleanSourceWith filter above already
|
|
||||||
# drops the big platform-specific things (android/ios, website, tests).
|
|
||||||
cp package.json $out/share/paseo-desktop/
|
|
||||||
cp -a packages $out/share/paseo-desktop/
|
|
||||||
cp -a node_modules $out/share/paseo-desktop/
|
|
||||||
|
|
||||||
# Skills directory referenced at runtime by some agents
|
|
||||||
if [ -d skills ]; then
|
|
||||||
cp -a skills $out/share/paseo-desktop/
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Hicolor icon for desktop environments
|
|
||||||
install -Dm644 packages/desktop/assets/icon.png \
|
|
||||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
|
||||||
|
|
||||||
# Launcher wraps nixpkgs electron.
|
|
||||||
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
|
|
||||||
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
|
|
||||||
# `security.wrappers` via a NixOS module for users who want the sandbox.
|
|
||||||
#
|
|
||||||
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
|
|
||||||
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
|
|
||||||
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
|
|
||||||
# exist here). Point it at the `paseo://` protocol handler instead, which
|
|
||||||
# serves from `__dirname/../../app/dist` (our install layout matches).
|
|
||||||
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
|
|
||||||
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
|
|
||||||
--add-flags "--no-sandbox" \
|
|
||||||
--set EXPO_DEV_URL "paseo://app/"
|
|
||||||
|
|
||||||
copyDesktopItems
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
desktopItems = [
|
|
||||||
(makeDesktopItem {
|
|
||||||
name = "paseo-desktop";
|
|
||||||
desktopName = "Paseo";
|
|
||||||
genericName = "AI Coding Agents";
|
|
||||||
comment = "Self-hosted daemon for AI coding agents";
|
|
||||||
exec = "paseo-desktop";
|
|
||||||
icon = "paseo-desktop";
|
|
||||||
categories = [ "Development" ];
|
|
||||||
startupWMClass = "Paseo";
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
meta = {
|
|
||||||
description = "Paseo desktop app (Electron wrapper)";
|
|
||||||
homepage = "https://github.com/getpaseo/paseo";
|
|
||||||
license = lib.licenses.agpl3Plus;
|
|
||||||
mainProgram = "paseo-desktop";
|
|
||||||
platforms = lib.platforms.linux;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+3
-93
@@ -81,48 +81,7 @@ in
|
|||||||
enable = lib.mkOption {
|
enable = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = true;
|
default = true;
|
||||||
description = ''
|
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||||
Whether to enable relay-based remote access. When false, the daemon
|
|
||||||
runs with `--no-relay` and only accepts direct (LAN/loopback)
|
|
||||||
connections.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
mode = lib.mkOption {
|
|
||||||
type = lib.types.enum [ "hosted" "remote" ];
|
|
||||||
default = "hosted";
|
|
||||||
description = ''
|
|
||||||
How the daemon reaches the relay when `relay.enable = true`:
|
|
||||||
|
|
||||||
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
|
|
||||||
Preserves the current behavior; no extra options needed.
|
|
||||||
- `"remote"`: connect to a self-hosted relay at
|
|
||||||
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
|
|
||||||
`PASEO_RELAY_USE_TLS` for the daemon.
|
|
||||||
|
|
||||||
A `"local"` mode (running a relay on the same host as a systemd
|
|
||||||
unit) is not yet implemented — the relay package currently only
|
|
||||||
ships a Cloudflare Workers adapter. Tracked separately.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
host = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
example = "relay.example.com";
|
|
||||||
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
|
|
||||||
};
|
|
||||||
|
|
||||||
port = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 443;
|
|
||||||
description = "Relay port. Used when `relay.mode = \"remote\"`.";
|
|
||||||
};
|
|
||||||
|
|
||||||
useTls = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,50 +111,9 @@ in
|
|||||||
'';
|
'';
|
||||||
description = "Extra environment variables for the Paseo daemon.";
|
description = "Extra environment variables for the Paseo daemon.";
|
||||||
};
|
};
|
||||||
|
|
||||||
settings = lib.mkOption {
|
|
||||||
type = (pkgs.formats.json { }).type;
|
|
||||||
default = { };
|
|
||||||
example = lib.literalExpression ''
|
|
||||||
{
|
|
||||||
daemon.mcp = { enabled = true; injectIntoAgents = false; };
|
|
||||||
agents.providers.myAcp = {
|
|
||||||
extends = "acp";
|
|
||||||
label = "My Agent";
|
|
||||||
command = { path = "/run/current-system/sw/bin/my-acp"; };
|
|
||||||
};
|
|
||||||
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
description = ''
|
|
||||||
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
|
|
||||||
and installed on every service start.
|
|
||||||
|
|
||||||
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
|
|
||||||
or the mobile app toggling MCP injection / provider overrides) are
|
|
||||||
overwritten on the next restart. Pick one: manage via this option, or
|
|
||||||
manage via the CLI — not both.
|
|
||||||
|
|
||||||
The full schema is defined by `PersistedConfigSchema` in
|
|
||||||
`packages/server/src/server/persisted-config.ts`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable (
|
config = lib.mkIf cfg.enable {
|
||||||
let
|
|
||||||
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
assertions = [
|
|
||||||
{
|
|
||||||
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
|
|
||||||
message = ''
|
|
||||||
services.paseo.relay.host must be set when relay.mode = "remote".
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||||
isSystemUser = true;
|
isSystemUser = true;
|
||||||
group = cfg.group;
|
group = cfg.group;
|
||||||
@@ -213,10 +131,6 @@ in
|
|||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
|
||||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
|
||||||
'';
|
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
NODE_ENV = "production";
|
NODE_ENV = "production";
|
||||||
PASEO_HOME = cfg.dataDir;
|
PASEO_HOME = cfg.dataDir;
|
||||||
@@ -235,9 +149,6 @@ in
|
|||||||
PASEO_HOSTNAMES = "true";
|
PASEO_HOSTNAMES = "true";
|
||||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
|
|
||||||
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
|
|
||||||
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
|
|
||||||
} // cfg.environment;
|
} // cfg.environment;
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
@@ -261,6 +172,5 @@ in
|
|||||||
environment.systemPackages = [ cfg.package ];
|
environment.systemPackages = [ cfg.package ];
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||||
}
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
sha256-LczD9EmK6LuaJuZQu1v/q8zBE92LVynRR85dp6IdfCo=
|
|
||||||
+3
-12
@@ -7,15 +7,6 @@
|
|||||||
makeWrapper,
|
makeWrapper,
|
||||||
# node-pty needs libuv headers on Linux
|
# node-pty needs libuv headers on Linux
|
||||||
libuv,
|
libuv,
|
||||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
|
||||||
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
|
|
||||||
# can override via `.override { npmDepsHash = "sha256-..."; }` without
|
|
||||||
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
|
|
||||||
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
|
|
||||||
#
|
|
||||||
# The default is read from a sidecar file so the CI auto-updater can replace
|
|
||||||
# the hash with a single file write instead of a sed against this source.
|
|
||||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
|
||||||
}:
|
}:
|
||||||
|
|
||||||
buildNpmPackage rec {
|
buildNpmPackage rec {
|
||||||
@@ -49,9 +40,9 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
nodejs = nodejs_22;
|
nodejs = nodejs_22;
|
||||||
|
|
||||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
inherit npmDepsHash;
|
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+20
-36
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.75",
|
"version": "0.1.71",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.75",
|
"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",
|
||||||
@@ -21769,18 +21775,6 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-image-manipulator": {
|
|
||||||
"version": "14.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
|
|
||||||
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"expo-image-loader": "~6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-image-picker": {
|
"node_modules/expo-image-picker": {
|
||||||
"version": "17.0.10",
|
"version": "17.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
||||||
@@ -38860,7 +38854,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.75",
|
"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",
|
||||||
@@ -38898,7 +38892,6 @@
|
|||||||
"expo-file-system": "~19.0.17",
|
"expo-file-system": "~19.0.17",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-image-manipulator": "~14.0.8",
|
|
||||||
"expo-image-picker": "^17.0.8",
|
"expo-image-picker": "^17.0.8",
|
||||||
"expo-keep-awake": "^15.0.7",
|
"expo-keep-awake": "^15.0.7",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
@@ -38987,10 +38980,10 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.75",
|
"version": "0.1.71",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/server": "0.1.75",
|
"@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",
|
||||||
@@ -39033,7 +39026,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.75",
|
"version": "0.1.71",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -39082,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.75",
|
"version": "0.1.71",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.25",
|
"@types/react": "^18.0.25",
|
||||||
@@ -39118,7 +39111,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.75",
|
"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",
|
||||||
@@ -39144,7 +39137,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.75",
|
"version": "0.1.71",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -39159,18 +39152,18 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.75",
|
"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.75",
|
"@getpaseo/highlight": "0.1.71",
|
||||||
"@getpaseo/relay": "0.1.75",
|
"@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",
|
||||||
@@ -39351,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",
|
||||||
@@ -39707,7 +39691,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.75",
|
"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.75",
|
"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.75",
|
"version": "0.1.71",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -63,7 +63,6 @@
|
|||||||
"expo-file-system": "~19.0.17",
|
"expo-file-system": "~19.0.17",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-image-manipulator": "~14.0.8",
|
|
||||||
"expo-image-picker": "^17.0.8",
|
"expo-image-picker": "^17.0.8",
|
||||||
"expo-keep-awake": "^15.0.7",
|
"expo-keep-awake": "^15.0.7",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
|
|||||||
@@ -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 () => {};
|
||||||
|
|||||||
@@ -791,13 +791,10 @@ function DesktopSidebar({
|
|||||||
|
|
||||||
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||||
const desktopSidebarStyle = useMemo(
|
const desktopSidebarStyle = useMemo(
|
||||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
|
||||||
[resizeAnimatedStyle],
|
[resizeAnimatedStyle, insetsTop],
|
||||||
);
|
|
||||||
const desktopSidebarBorderStyle = useMemo(
|
|
||||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
|
||||||
[insetsTop],
|
|
||||||
);
|
);
|
||||||
|
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
|
||||||
const resizeHandleStyle = useMemo(
|
const resizeHandleStyle = useMemo(
|
||||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("expo-image-manipulator", () => ({
|
|
||||||
SaveFormat: { PNG: "png" },
|
|
||||||
ImageManipulator: {
|
|
||||||
manipulate: (_source: string) => ({
|
|
||||||
async renderAsync() {
|
|
||||||
return {
|
|
||||||
release() {},
|
|
||||||
async saveAsync(options: { format?: string }) {
|
|
||||||
return {
|
|
||||||
uri:
|
|
||||||
options.format === "png"
|
|
||||||
? "file:///cache/ImageManipulator/safe-picked.png"
|
|
||||||
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
release() {},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
|
|
||||||
|
|
||||||
describe("native image attachment picker", () => {
|
|
||||||
it("preserves native picked JPEG and PNG attachment inputs", async () => {
|
|
||||||
const result = await normalizePickedImageAssets([
|
|
||||||
{
|
|
||||||
uri: "file:///photos/IMG_0001.JPG",
|
|
||||||
mimeType: "image/jpeg",
|
|
||||||
fileName: "picked.jpeg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uri: "file:///photos/screenshot.png",
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "screenshot.png",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
|
|
||||||
mimeType: "image/jpeg",
|
|
||||||
fileName: "picked.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "screenshot.png",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
|
|
||||||
const result = await normalizePickedImageAssets([
|
|
||||||
{
|
|
||||||
uri: "file:///photos/IMG_0001.HEIC",
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "picked.png",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
|
|
||||||
mimeType: "image/png",
|
|
||||||
fileName: "picked.png",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
|
|
||||||
|
|
||||||
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
|
||||||
|
|
||||||
export interface PickedImageAttachmentInput {
|
|
||||||
source: PickedImageSource;
|
|
||||||
mimeType?: string | null;
|
|
||||||
fileName?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExpoImagePickerAssetLike {
|
|
||||||
uri: string;
|
|
||||||
mimeType?: string | null;
|
|
||||||
fileName?: string | null;
|
|
||||||
file?: File | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SupportedPickedImageFormat {
|
|
||||||
mimeType: "image/jpeg" | "image/png";
|
|
||||||
extension: "jpg" | "png";
|
|
||||||
}
|
|
||||||
|
|
||||||
const JPEG_FORMAT: SupportedPickedImageFormat = {
|
|
||||||
mimeType: "image/jpeg",
|
|
||||||
extension: "jpg",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PNG_FORMAT: SupportedPickedImageFormat = {
|
|
||||||
mimeType: "image/png",
|
|
||||||
extension: "png",
|
|
||||||
};
|
|
||||||
|
|
||||||
function extensionFromPath(path: string | null | undefined): string | null {
|
|
||||||
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
|
|
||||||
return match?.[1]?.toLowerCase() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
|
|
||||||
if (extension === "jpg" || extension === "jpeg") {
|
|
||||||
return JPEG_FORMAT;
|
|
||||||
}
|
|
||||||
if (extension === "png") {
|
|
||||||
return PNG_FORMAT;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function supportedFormatForMimeType(
|
|
||||||
mimeType: string | null | undefined,
|
|
||||||
): SupportedPickedImageFormat | null {
|
|
||||||
const normalizedMimeType = mimeType?.toLowerCase();
|
|
||||||
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
|
|
||||||
return JPEG_FORMAT;
|
|
||||||
}
|
|
||||||
if (normalizedMimeType === "image/png") {
|
|
||||||
return PNG_FORMAT;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickedAssetSupportedFormat(
|
|
||||||
asset: ExpoImagePickerAssetLike,
|
|
||||||
): SupportedPickedImageFormat | null {
|
|
||||||
const uriExtension = extensionFromPath(asset.uri);
|
|
||||||
if (uriExtension) {
|
|
||||||
return supportedFormatForExtension(uriExtension);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
|
|
||||||
supportedFormatForMimeType(asset.mimeType)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceFileExtension(
|
|
||||||
fileName: string | null | undefined,
|
|
||||||
extension: SupportedPickedImageFormat["extension"],
|
|
||||||
): string | null {
|
|
||||||
if (!fileName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportPickedImageAsPng(uri: string): Promise<string> {
|
|
||||||
const context = ImageManipulator.manipulate(uri);
|
|
||||||
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
image = await context.renderAsync();
|
|
||||||
const result = await image.saveAsync({
|
|
||||||
format: SaveFormat.PNG,
|
|
||||||
});
|
|
||||||
return result.uri;
|
|
||||||
} finally {
|
|
||||||
image?.release();
|
|
||||||
context.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function normalizePickedImageAssets(
|
|
||||||
assets: readonly ExpoImagePickerAssetLike[],
|
|
||||||
): Promise<PickedImageAttachmentInput[]> {
|
|
||||||
return await Promise.all(
|
|
||||||
assets.map(async (asset) => {
|
|
||||||
const supportedFormat = pickedAssetSupportedFormat(asset);
|
|
||||||
if (supportedFormat) {
|
|
||||||
return {
|
|
||||||
source: { kind: "file_uri", uri: asset.uri },
|
|
||||||
mimeType: supportedFormat.mimeType,
|
|
||||||
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const convertedUri = await exportPickedImageAsPng(asset.uri);
|
|
||||||
|
|
||||||
return {
|
|
||||||
source: { kind: "file_uri", uri: convertedUri },
|
|
||||||
mimeType: PNG_FORMAT.mimeType,
|
|
||||||
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
|
|
||||||
throw new Error("Desktop dialog API is not available on native.");
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -720,16 +720,9 @@ function SettingsSidebar({
|
|||||||
}, [hosts, localServerId]);
|
}, [hosts, localServerId]);
|
||||||
const isDesktopApp = isElectronRuntime();
|
const isDesktopApp = isElectronRuntime();
|
||||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const isDesktop = layout === "desktop";
|
const isDesktop = layout === "desktop";
|
||||||
const containerStyle = useMemo(
|
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||||
() => [
|
|
||||||
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
|
|
||||||
isDesktop ? { paddingTop: insets.top } : null,
|
|
||||||
],
|
|
||||||
[insets.top, isDesktop],
|
|
||||||
);
|
|
||||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||||
|
|||||||
@@ -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.75",
|
"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.75",
|
"@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.75",
|
"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,4 +1,3 @@
|
|||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||||
@@ -71,34 +70,6 @@ function desktopSettingsWithManagement(enabled: boolean) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockChildProcess = EventEmitter & {
|
|
||||||
stdout: EventEmitter;
|
|
||||||
stderr: EventEmitter;
|
|
||||||
pid: number;
|
|
||||||
spawnfile: string;
|
|
||||||
spawnargs: string[];
|
|
||||||
unref: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createMockChildProcess(): MockChildProcess {
|
|
||||||
const child = new EventEmitter() as MockChildProcess;
|
|
||||||
child.stdout = new EventEmitter();
|
|
||||||
child.stderr = new EventEmitter();
|
|
||||||
child.pid = 1234;
|
|
||||||
child.spawnfile = "node";
|
|
||||||
child.spawnargs = ["node", "daemon.js"];
|
|
||||||
child.unref = vi.fn();
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFailedStartupOutput(child: MockChildProcess): void {
|
|
||||||
setImmediate(() => {
|
|
||||||
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
|
|
||||||
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
|
|
||||||
child.emit("exit", 1, null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("daemon-manager commands", () => {
|
describe("daemon-manager commands", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
|
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
|
||||||
@@ -194,61 +165,4 @@ describe("daemon-manager commands", () => {
|
|||||||
"--json",
|
"--json",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a reachable daemon when the PID file is stale", async () => {
|
|
||||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
|
||||||
localDaemon: "stale_pid",
|
|
||||||
connectedDaemon: "reachable",
|
|
||||||
serverId: "server-1",
|
|
||||||
pid: 7675,
|
|
||||||
listen: "127.0.0.1:6767",
|
|
||||||
hostname: "dev-host",
|
|
||||||
daemonVersion: "1.2.2",
|
|
||||||
desktopManaged: true,
|
|
||||||
});
|
|
||||||
const handlers = createDaemonCommandHandlers();
|
|
||||||
|
|
||||||
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
|
|
||||||
serverId: "server-1",
|
|
||||||
status: "running",
|
|
||||||
listen: "127.0.0.1:6767",
|
|
||||||
hostname: "dev-host",
|
|
||||||
pid: null,
|
|
||||||
home: "/tmp/paseo-home",
|
|
||||||
version: "1.2.2",
|
|
||||||
desktopManaged: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.spawnProcess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bounds captured daemon startup output", async () => {
|
|
||||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
|
||||||
localDaemon: "stopped",
|
|
||||||
connectedDaemon: "unreachable",
|
|
||||||
serverId: "",
|
|
||||||
});
|
|
||||||
mocks.spawnProcess.mockImplementation(() => {
|
|
||||||
const child = createMockChildProcess();
|
|
||||||
scheduleFailedStartupOutput(child);
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
const handlers = createDaemonCommandHandlers();
|
|
||||||
|
|
||||||
let thrown: Error | null = null;
|
|
||||||
try {
|
|
||||||
await handlers.start_desktop_daemon();
|
|
||||||
} catch (error) {
|
|
||||||
thrown = error instanceof Error ? error : new Error(String(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(thrown).toBeInstanceOf(Error);
|
|
||||||
const message = thrown?.message ?? "";
|
|
||||||
expect(message).toContain("Daemon failed to start: exit code 1");
|
|
||||||
expect(message).toContain("output truncated to the last 65536 chars");
|
|
||||||
expect(message).toContain("stdout-tail");
|
|
||||||
expect(message).toContain("stderr-tail");
|
|
||||||
expect(message.length).toBeLessThan(150_000);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ const DAEMON_LOG_FILENAME = "daemon.log";
|
|||||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||||
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
|
|
||||||
|
|
||||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||||
|
|
||||||
@@ -70,11 +69,6 @@ interface DesktopPairingOffer {
|
|||||||
qr: string | null;
|
qr: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StartupOutputCapture {
|
|
||||||
text: string;
|
|
||||||
truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReleaseChannel(
|
function parseReleaseChannel(
|
||||||
args: Record<string, unknown> | undefined,
|
args: Record<string, unknown> | undefined,
|
||||||
): AppReleaseChannel | undefined {
|
): AppReleaseChannel | undefined {
|
||||||
@@ -151,30 +145,6 @@ function tailFile(filePath: string, lines = 50): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStartupOutputCapture(): StartupOutputCapture {
|
|
||||||
return { text: "", truncated: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
|
|
||||||
const nextText = capture.text + chunk.toString();
|
|
||||||
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
|
|
||||||
return { text: nextText, truncated: capture.truncated };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
|
|
||||||
truncated: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStartupOutput(capture: StartupOutputCapture): string {
|
|
||||||
if (!capture.truncated) {
|
|
||||||
return capture.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
|
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
|
||||||
log.info("[desktop daemon]", message, {
|
log.info("[desktop daemon]", message, {
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
@@ -227,28 +197,17 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
|||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
||||||
const connectedDaemon =
|
const running = localDaemon === "running";
|
||||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
|
||||||
const hasRunningLocalProcess = localDaemon === "running";
|
|
||||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
|
||||||
const apiReachable = connectedDaemon === "reachable";
|
|
||||||
let status: DesktopDaemonState = "stopped";
|
|
||||||
if (apiReachable || hasRunningLocalProcess) {
|
|
||||||
status = "running";
|
|
||||||
} else if (localDaemon === "unresponsive") {
|
|
||||||
status = "errored";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
|
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
|
||||||
status,
|
status: running ? "running" : "stopped",
|
||||||
listen: typeof payload.listen === "string" ? payload.listen : null,
|
listen: typeof payload.listen === "string" ? payload.listen : null,
|
||||||
hostname:
|
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||||
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
|
pid: running && typeof payload.pid === "number" ? payload.pid : null,
|
||||||
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
|
|
||||||
home,
|
home,
|
||||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||||
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
|
desktopManaged: payload.desktopManaged === true,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -289,17 +248,15 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
|
|||||||
|
|
||||||
function buildStartupFailureError(
|
function buildStartupFailureError(
|
||||||
result: { code: number | null; signal: string | null; error?: Error },
|
result: { code: number | null; signal: string | null; error?: Error },
|
||||||
stdout: StartupOutputCapture,
|
stdout: string,
|
||||||
stderr: StartupOutputCapture,
|
stderr: string,
|
||||||
): Error {
|
): Error {
|
||||||
const reason = result.error
|
const reason = result.error
|
||||||
? result.error.message
|
? result.error.message
|
||||||
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
|
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
|
||||||
const parts = [`Daemon failed to start: ${reason}`];
|
const parts = [`Daemon failed to start: ${reason}`];
|
||||||
const formattedStderr = formatStartupOutput(stderr).trim();
|
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
|
||||||
const formattedStdout = formatStartupOutput(stdout).trim();
|
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
|
||||||
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
|
|
||||||
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
|
|
||||||
const logs = tailFile(logFilePath(), 15);
|
const logs = tailFile(logFilePath(), 15);
|
||||||
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
|
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
|
||||||
return new Error(parts.join("\n\n"));
|
return new Error(parts.join("\n\n"));
|
||||||
@@ -380,13 +337,13 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = createStartupOutputCapture();
|
let stdout = "";
|
||||||
let stderr = createStartupOutputCapture();
|
let stderr = "";
|
||||||
child.stdout!.on("data", (data: Buffer) => {
|
child.stdout!.on("data", (data: Buffer) => {
|
||||||
stdout = appendStartupOutput(stdout, data);
|
stdout += data.toString();
|
||||||
});
|
});
|
||||||
child.stderr!.on("data", (data: Buffer) => {
|
child.stderr!.on("data", (data: Buffer) => {
|
||||||
stderr = appendStartupOutput(stderr, data);
|
stderr += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||||
@@ -424,8 +381,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
||||||
childPid: child.pid ?? null,
|
childPid: child.pid ?? null,
|
||||||
exitedEarly: result.exitedEarly,
|
exitedEarly: result.exitedEarly,
|
||||||
stdout: formatStartupOutput(stdout).slice(0, 2000),
|
stdout: stdout.slice(0, 2000),
|
||||||
stderr: formatStartupOutput(stderr).slice(0, 2000),
|
stderr: stderr.slice(0, 2000),
|
||||||
...(result.exitedEarly
|
...(result.exitedEarly
|
||||||
? {
|
? {
|
||||||
exitCode: result.code,
|
exitCode: result.code,
|
||||||
|
|||||||
@@ -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.75",
|
"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.75",
|
"version": "0.1.71",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.75",
|
"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.75",
|
"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.75",
|
"@getpaseo/highlight": "0.1.71",
|
||||||
"@getpaseo/relay": "0.1.75",
|
"@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",
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
||||||
import { loadPersistedConfig } from "../src/server/persisted-config.js";
|
import { loadPersistedConfig } from "../src/server/persisted-config.js";
|
||||||
import { runSupervisor } from "./supervisor.js";
|
import { runSupervisor } from "./supervisor.js";
|
||||||
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
|
||||||
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||||
|
|
||||||
|
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
|
const DEFAULT_LOG_ROTATE_SIZE = "10m";
|
||||||
|
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
|
||||||
|
|
||||||
interface DaemonRunnerConfig {
|
interface DaemonRunnerConfig {
|
||||||
devMode: boolean;
|
devMode: boolean;
|
||||||
workerArgs: string[];
|
workerArgs: string[];
|
||||||
@@ -74,6 +77,28 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
|
|||||||
return existsSync(runnerPath) ? runnerPath : null;
|
return existsSync(runnerPath) ? runnerPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSupervisorLogFile(
|
||||||
|
paseoHome: string,
|
||||||
|
persistedConfig: ReturnType<typeof loadPersistedConfig>,
|
||||||
|
) {
|
||||||
|
const configuredFile = persistedConfig.log?.file;
|
||||||
|
const configuredPath = configuredFile?.path;
|
||||||
|
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
|
||||||
|
if (configuredPath) {
|
||||||
|
logPath = path.isAbsolute(configuredPath)
|
||||||
|
? configuredPath
|
||||||
|
: path.resolve(paseoHome, configuredPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: logPath,
|
||||||
|
rotate: {
|
||||||
|
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
|
||||||
|
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const config = parseConfig(process.argv.slice(2));
|
const config = parseConfig(process.argv.slice(2));
|
||||||
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
||||||
@@ -88,7 +113,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const paseoHome = resolvePaseoHome(workerEnv);
|
const paseoHome = resolvePaseoHome(workerEnv);
|
||||||
const persistedConfig = loadPersistedConfig(paseoHome);
|
const persistedConfig = loadPersistedConfig(paseoHome);
|
||||||
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);
|
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await acquirePidLock(paseoHome, null, {
|
await acquirePidLock(paseoHome, null, {
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import type { loadPersistedConfig } from "../src/server/persisted-config.js";
|
|
||||||
|
|
||||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
|
||||||
const DEFAULT_LOG_ROTATE_SIZE = "10m";
|
|
||||||
const DEFAULT_LOG_ROTATE_MAX_FILES = 3;
|
|
||||||
|
|
||||||
export function resolveSupervisorLogFile(
|
|
||||||
paseoHome: string,
|
|
||||||
persistedConfig: ReturnType<typeof loadPersistedConfig>,
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
) {
|
|
||||||
const configuredFile = persistedConfig.log?.file;
|
|
||||||
const configuredPath = configuredFile?.path;
|
|
||||||
const envRotateSize = env.PASEO_LOG_ROTATE_SIZE?.trim();
|
|
||||||
const envRotateMaxFiles = parseOptionalPositiveInteger(env.PASEO_LOG_ROTATE_COUNT);
|
|
||||||
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
|
|
||||||
if (configuredPath) {
|
|
||||||
logPath = path.isAbsolute(configuredPath)
|
|
||||||
? configuredPath
|
|
||||||
: path.resolve(paseoHome, configuredPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: logPath,
|
|
||||||
rotate: {
|
|
||||||
maxSize: configuredFile?.rotate?.maxSize ?? envRotateSize ?? DEFAULT_LOG_ROTATE_SIZE,
|
|
||||||
maxFiles:
|
|
||||||
configuredFile?.rotate?.maxFiles ?? envRotateMaxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOptionalPositiveInteger(value: string | undefined): number | undefined {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number.parseInt(value.trim(), 10);
|
|
||||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { isPlatform } from "../src/test-utils/platform.js";
|
import { isPlatform } from "../src/test-utils/platform.js";
|
||||||
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||||
@@ -88,57 +87,6 @@ async function runSupervisorFixture(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("supervisor durable logging", () => {
|
describe("supervisor durable logging", () => {
|
||||||
test("resolves rotation defaults", () => {
|
|
||||||
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
|
|
||||||
const logFile = resolveSupervisorLogFile(paseoHome, {}, {});
|
|
||||||
|
|
||||||
expect(logFile).toEqual({
|
|
||||||
path: path.join(paseoHome, "daemon.log"),
|
|
||||||
rotate: { maxSize: "10m", maxFiles: 3 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lets persisted rotation override env rotation defaults", () => {
|
|
||||||
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
|
|
||||||
const logFile = resolveSupervisorLogFile(
|
|
||||||
paseoHome,
|
|
||||||
{
|
|
||||||
log: {
|
|
||||||
file: {
|
|
||||||
path: "logs/daemon.log",
|
|
||||||
rotate: { maxSize: "25m", maxFiles: 4 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
PASEO_LOG_ROTATE_SIZE: "200m",
|
|
||||||
PASEO_LOG_ROTATE_COUNT: "12",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(logFile).toEqual({
|
|
||||||
path: path.resolve(paseoHome, "logs", "daemon.log"),
|
|
||||||
rotate: { maxSize: "25m", maxFiles: 4 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses env rotation when persisted rotation is absent", () => {
|
|
||||||
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
|
|
||||||
const logFile = resolveSupervisorLogFile(
|
|
||||||
paseoHome,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
PASEO_LOG_ROTATE_SIZE: "50m",
|
|
||||||
PASEO_LOG_ROTATE_COUNT: "8",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(logFile).toEqual({
|
|
||||||
path: path.join(paseoHome, "daemon.log"),
|
|
||||||
rotate: { maxSize: "50m", maxFiles: 8 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("writes supervised worker stdout and stderr to daemon.log", async () => {
|
test("writes supervised worker stdout and stderr to daemon.log", async () => {
|
||||||
const result = await runSupervisorFixture({
|
const result = await runSupervisorFixture({
|
||||||
workerSource: `
|
workerSource: `
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -716,7 +716,6 @@ test("createAgent passes daemon launch env through the provider launch context",
|
|||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
});
|
});
|
||||||
expect(client.lastLaunchContext).toEqual({
|
expect(client.lastLaunchContext).toEqual({
|
||||||
agentId: snapshot.id,
|
|
||||||
env: {
|
env: {
|
||||||
PASEO_AGENT_ID: snapshot.id,
|
PASEO_AGENT_ID: snapshot.id,
|
||||||
},
|
},
|
||||||
@@ -1180,7 +1179,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(client.lastResumeLaunchContext).toEqual({
|
expect(client.lastResumeLaunchContext).toEqual({
|
||||||
agentId: resumed.id,
|
|
||||||
env: {
|
env: {
|
||||||
PASEO_AGENT_ID: resumed.id,
|
PASEO_AGENT_ID: resumed.id,
|
||||||
},
|
},
|
||||||
@@ -1285,7 +1283,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(client.lastCreateLaunchContext).toEqual({
|
expect(client.lastCreateLaunchContext).toEqual({
|
||||||
agentId: snapshot.id,
|
|
||||||
env: {
|
env: {
|
||||||
PASEO_AGENT_ID: snapshot.id,
|
PASEO_AGENT_ID: snapshot.id,
|
||||||
},
|
},
|
||||||
@@ -1296,7 +1293,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(client.lastResumeLaunchContext).toEqual({
|
expect(client.lastResumeLaunchContext).toEqual({
|
||||||
agentId: snapshot.id,
|
|
||||||
env: {
|
env: {
|
||||||
PASEO_AGENT_ID: snapshot.id,
|
PASEO_AGENT_ID: snapshot.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,33 +10,31 @@ import type { Logger } from "pino";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentCapabilityFlags,
|
||||||
type AgentCapabilityFlags,
|
AgentClient,
|
||||||
type AgentClient,
|
AgentCreateSessionOptions,
|
||||||
type AgentCreateSessionOptions,
|
AgentFeature,
|
||||||
type AgentFeature,
|
AgentLaunchContext,
|
||||||
type AgentLaunchContext,
|
AgentSlashCommand,
|
||||||
type AgentSlashCommand,
|
AgentMode,
|
||||||
type AgentMode,
|
AgentPermissionRequest,
|
||||||
type AgentPermissionRequest,
|
AgentPermissionResponse,
|
||||||
type AgentPermissionResponse,
|
AgentPermissionResult,
|
||||||
type AgentPermissionResult,
|
AgentPersistenceHandle,
|
||||||
type AgentPersistenceHandle,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentProvider,
|
||||||
type AgentProvider,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentRunResult,
|
||||||
type AgentRunResult,
|
AgentSession,
|
||||||
type AgentSession,
|
AgentSessionConfig,
|
||||||
type AgentSessionConfig,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
AgentTimelineItem,
|
||||||
type AgentTimelineItem,
|
AgentUsage,
|
||||||
type AgentUsage,
|
AgentRuntimeInfo,
|
||||||
type AgentRuntimeInfo,
|
ListPersistedAgentsOptions,
|
||||||
type ListPersistedAgentsOptions,
|
PersistedAgentDescriptor,
|
||||||
type 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,
|
||||||
@@ -68,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>;
|
||||||
@@ -1006,28 +1005,18 @@ export class AgentManager {
|
|||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId,
|
agentId,
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: agent.activeForegroundTurnId ?? undefined,
|
|
||||||
lifecycle: agent.lifecycle,
|
lifecycle: agent.lifecycle,
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||||
pendingPermissions: agent.pendingPermissions.size,
|
pendingPermissions: agent.pendingPermissions.size,
|
||||||
},
|
},
|
||||||
"agent.manager.close.start",
|
"closeAgent: start",
|
||||||
);
|
);
|
||||||
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
||||||
await agent.session.close();
|
await agent.session.close();
|
||||||
this.timelineStore.delete(agentId);
|
this.timelineStore.delete(agentId);
|
||||||
await this.persistSnapshot(closedAgent);
|
await this.persistSnapshot(closedAgent);
|
||||||
this.emitClosedAgent(closedAgent, { persist: false });
|
this.emitClosedAgent(closedAgent, { persist: false });
|
||||||
this.logger.trace(
|
this.logger.trace({ agentId }, "closeAgent: completed");
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: closedAgent.provider,
|
|
||||||
sessionId: closedAgent.persistence?.sessionId ?? undefined,
|
|
||||||
},
|
|
||||||
"agent.manager.close.complete",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||||
@@ -1082,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);
|
||||||
|
|
||||||
@@ -1258,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);
|
||||||
@@ -1481,28 +1494,22 @@ export class AgentManager {
|
|||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId,
|
agentId,
|
||||||
provider: existingAgent.provider,
|
|
||||||
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
|
||||||
lifecycle: existingAgent.lifecycle,
|
lifecycle: existingAgent.lifecycle,
|
||||||
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
|
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
|
||||||
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
||||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
promptType: typeof prompt === "string" ? "string" : "structured",
|
||||||
hasRunOptions: Boolean(options),
|
hasRunOptions: Boolean(options),
|
||||||
},
|
},
|
||||||
"agent.manager.stream.request",
|
"streamAgent: requested",
|
||||||
);
|
);
|
||||||
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
|
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId,
|
agentId,
|
||||||
provider: existingAgent.provider,
|
|
||||||
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
|
||||||
lifecycle: existingAgent.lifecycle,
|
lifecycle: existingAgent.lifecycle,
|
||||||
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
||||||
},
|
},
|
||||||
"agent.manager.stream.reject",
|
"streamAgent: rejected because a foreground run is already in flight",
|
||||||
);
|
);
|
||||||
throw new Error(`Agent ${agentId} already has an active run`);
|
throw new Error(`Agent ${agentId} already has an active run`);
|
||||||
}
|
}
|
||||||
@@ -1539,13 +1546,10 @@ export class AgentManager {
|
|||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId,
|
agentId,
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
lifecycle: agent.lifecycle,
|
lifecycle: agent.lifecycle,
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||||
},
|
},
|
||||||
"agent.manager.stream.start",
|
"streamAgent: started",
|
||||||
);
|
);
|
||||||
|
|
||||||
turnStream = this.foregroundRuns.createTurnStream(turnId);
|
turnStream = this.foregroundRuns.createTurnStream(turnId);
|
||||||
@@ -1597,14 +1601,11 @@ export class AgentManager {
|
|||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
lifecycle: mutableAgent.lifecycle,
|
lifecycle: mutableAgent.lifecycle,
|
||||||
terminalError,
|
terminalError,
|
||||||
pendingReplacement: mutableAgent.pendingReplacement,
|
pendingReplacement: mutableAgent.pendingReplacement,
|
||||||
},
|
},
|
||||||
"agent.manager.finalize",
|
"finalizeForegroundTurn: applying terminal state",
|
||||||
);
|
);
|
||||||
if (!shouldHoldBusyForReplacement) {
|
if (!shouldHoldBusyForReplacement) {
|
||||||
this.touchUpdatedAt(mutableAgent);
|
this.touchUpdatedAt(mutableAgent);
|
||||||
@@ -2394,16 +2395,6 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
|
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: getAgentStreamEventTurnId(event),
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.enqueue",
|
|
||||||
);
|
|
||||||
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
|
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
|
||||||
const next = previous
|
const next = previous
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -2415,16 +2406,6 @@ export class AgentManager {
|
|||||||
if (current.session == null) {
|
if (current.session == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: current.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: getAgentStreamEventTurnId(event),
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.dequeue",
|
|
||||||
);
|
|
||||||
await this.dispatchSessionEvent(current, event);
|
await this.dispatchSessionEvent(current, event);
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
@@ -2448,19 +2429,8 @@ export class AgentManager {
|
|||||||
agent: ActiveManagedAgent,
|
agent: ActiveManagedAgent,
|
||||||
event: AgentStreamEvent,
|
event: AgentStreamEvent,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const turnId = getAgentStreamEventTurnId(event);
|
const turnId = (event as { turnId?: string }).turnId;
|
||||||
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
|
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: agent.id,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
matchingWaiterCount: matchingWaiters.length,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.dispatch_session_event",
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
|
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
|
||||||
|
|
||||||
@@ -2471,18 +2441,6 @@ export class AgentManager {
|
|||||||
this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
|
this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
|
||||||
terminal: isTurnTerminalEvent(event),
|
terminal: isTurnTerminalEvent(event),
|
||||||
});
|
});
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: agent.id,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
notifiedWaiterCount: matchingWaiters.length,
|
|
||||||
terminal: isTurnTerminalEvent(event),
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.notify_waiters",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveInitialPersistedTitle(
|
private async resolveInitialPersistedTitle(
|
||||||
@@ -2595,7 +2553,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
|
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
|
||||||
const turnId = getAgentStreamEventTurnId(event);
|
const turnId = (event as { turnId?: string }).turnId;
|
||||||
if (turnId == null) {
|
if (turnId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2606,16 +2564,6 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.foregroundRuns.notifyAgentWaiters(agent, event);
|
this.foregroundRuns.notifyAgentWaiters(agent, event);
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.notify_waiters.coalesced",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleStreamEvent(
|
private async handleStreamEvent(
|
||||||
@@ -2623,9 +2571,8 @@ export class AgentManager {
|
|||||||
event: AgentStreamEvent,
|
event: AgentStreamEvent,
|
||||||
options?: HandleStreamEventOptions,
|
options?: HandleStreamEventOptions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
const eventTurnId = (event as { turnId?: string }).turnId;
|
||||||
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
|
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
|
||||||
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
|
|
||||||
if (
|
if (
|
||||||
eventTurnId &&
|
eventTurnId &&
|
||||||
isTurnTerminalEvent(event) &&
|
isTurnTerminalEvent(event) &&
|
||||||
@@ -2638,7 +2585,6 @@ export class AgentManager {
|
|||||||
if (!options?.fromHistory) {
|
if (!options?.fromHistory) {
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
if (this.agentStreamCoalescer.handle(agent.id, event)) {
|
if (this.agentStreamCoalescer.handle(agent.id, event)) {
|
||||||
this.traceCoalescerBuffered(agent, event, eventTurnId);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.agentStreamCoalescer.flushFor(agent.id);
|
this.agentStreamCoalescer.flushFor(agent.id);
|
||||||
@@ -2666,71 +2612,9 @@ export class AgentManager {
|
|||||||
this.dispatchStream(agent.id, event);
|
this.dispatchStream(agent.id, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
|
|
||||||
|
|
||||||
return flags.shouldNotifyWaiters;
|
return flags.shouldNotifyWaiters;
|
||||||
}
|
}
|
||||||
|
|
||||||
private traceHandleStreamEventStart(
|
|
||||||
agent: ActiveManagedAgent,
|
|
||||||
event: AgentStreamEvent,
|
|
||||||
turnId: string | undefined,
|
|
||||||
isForegroundEvent: boolean,
|
|
||||||
): void {
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: agent.id,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
lifecycle: agent.lifecycle,
|
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
||||||
isForegroundEvent,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.handle_stream_event.start",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private traceCoalescerBuffered(
|
|
||||||
agent: ActiveManagedAgent,
|
|
||||||
event: AgentStreamEvent,
|
|
||||||
turnId: string | undefined,
|
|
||||||
): void {
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: agent.id,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.coalescer.buffer",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private traceHandleStreamEventEnd(
|
|
||||||
agent: ActiveManagedAgent,
|
|
||||||
event: AgentStreamEvent,
|
|
||||||
turnId: string | undefined,
|
|
||||||
flags: StreamEventFlags,
|
|
||||||
): void {
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: agent.id,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId,
|
|
||||||
lifecycle: agent.lifecycle,
|
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
||||||
shouldDispatchEvent: flags.shouldDispatchEvent,
|
|
||||||
shouldNotifyWaiters: flags.shouldNotifyWaiters,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.handle_stream_event.end",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatchStreamEventByType(params: {
|
private dispatchStreamEventByType(params: {
|
||||||
agent: ActiveManagedAgent;
|
agent: ActiveManagedAgent;
|
||||||
event: AgentStreamEvent;
|
event: AgentStreamEvent;
|
||||||
@@ -2887,13 +2771,11 @@ export class AgentManager {
|
|||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: eventTurnId,
|
|
||||||
lifecycle: agent.lifecycle,
|
lifecycle: agent.lifecycle,
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||||
|
eventTurnId,
|
||||||
},
|
},
|
||||||
"agent.manager.turn.completed",
|
"handleStreamEvent: turn_completed",
|
||||||
);
|
);
|
||||||
agent.lastUsage = event.usage;
|
agent.lastUsage = event.usage;
|
||||||
agent.lastError = undefined;
|
agent.lastError = undefined;
|
||||||
@@ -2920,9 +2802,6 @@ export class AgentManager {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{
|
{
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: eventTurnId,
|
|
||||||
lifecycle: agent.lifecycle,
|
lifecycle: agent.lifecycle,
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||||
eventTurnId,
|
eventTurnId,
|
||||||
@@ -2963,14 +2842,11 @@ export class AgentManager {
|
|||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: eventTurnId,
|
|
||||||
lifecycle: agent.lifecycle,
|
lifecycle: agent.lifecycle,
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||||
eventTurnId,
|
eventTurnId,
|
||||||
},
|
},
|
||||||
"agent.manager.turn.canceled",
|
"handleStreamEvent: turn_canceled",
|
||||||
);
|
);
|
||||||
if (!isForegroundEvent && !agent.pendingReplacement) {
|
if (!isForegroundEvent && !agent.pendingReplacement) {
|
||||||
agent.lifecycle = "idle";
|
agent.lifecycle = "idle";
|
||||||
@@ -2991,13 +2867,11 @@ export class AgentManager {
|
|||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: eventTurnId,
|
|
||||||
lifecycle: agent.lifecycle,
|
lifecycle: agent.lifecycle,
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||||
|
eventTurnId,
|
||||||
},
|
},
|
||||||
"agent.manager.turn.started",
|
"handleStreamEvent: turn_started",
|
||||||
);
|
);
|
||||||
if (!isForegroundEvent) {
|
if (!isForegroundEvent) {
|
||||||
agent.lifecycle = "running";
|
agent.lifecycle = "running";
|
||||||
@@ -3143,20 +3017,6 @@ export class AgentManager {
|
|||||||
|
|
||||||
this.syncFeaturesFromSession(agent);
|
this.syncFeaturesFromSession(agent);
|
||||||
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: agent.id,
|
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: agent.activeForegroundTurnId ?? undefined,
|
|
||||||
lifecycle: agent.lifecycle,
|
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
||||||
pendingPermissions: agent.pendingPermissions.size,
|
|
||||||
persist: options?.persist !== false,
|
|
||||||
},
|
|
||||||
"agent.manager.emit_state",
|
|
||||||
);
|
|
||||||
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: "agent_state",
|
type: "agent_state",
|
||||||
agent: { ...agent },
|
agent: { ...agent },
|
||||||
@@ -3284,18 +3144,6 @@ export class AgentManager {
|
|||||||
event: AgentStreamEvent,
|
event: AgentStreamEvent,
|
||||||
metadata?: { seq?: number; epoch?: string },
|
metadata?: { seq?: number; epoch?: string },
|
||||||
): void {
|
): void {
|
||||||
const agent = this.agents.get(agentId);
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: event.provider,
|
|
||||||
sessionId: agent?.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: getAgentStreamEventTurnId(event),
|
|
||||||
metadata,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"agent.manager.dispatch_stream",
|
|
||||||
);
|
|
||||||
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
|
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3391,7 +3239,6 @@ export class AgentManager {
|
|||||||
|
|
||||||
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
||||||
return {
|
return {
|
||||||
agentId,
|
|
||||||
env: {
|
env: {
|
||||||
PASEO_AGENT_ID: agentId,
|
PASEO_AGENT_ID: agentId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,19 +17,6 @@ export function startAgentRun(
|
|||||||
logger: Logger,
|
logger: Logger,
|
||||||
options?: StartAgentRunOptions,
|
options?: StartAgentRunOptions,
|
||||||
): { outOfBand: boolean } {
|
): { outOfBand: boolean } {
|
||||||
const snapshot = agentManager.getAgent(agentId);
|
|
||||||
logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: snapshot?.provider,
|
|
||||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
|
||||||
turnId: snapshot?.activeForegroundTurnId ?? undefined,
|
|
||||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
|
||||||
hasRunOptions: Boolean(options?.runOptions),
|
|
||||||
replaceRunning: Boolean(options?.replaceRunning),
|
|
||||||
},
|
|
||||||
"agent.session.start_stream.request",
|
|
||||||
);
|
|
||||||
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
||||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||||
@@ -41,38 +28,12 @@ export function startAgentRun(
|
|||||||
const iterator = shouldReplace
|
const iterator = shouldReplace
|
||||||
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
|
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
|
||||||
: agentManager.streamAgent(agentId, prompt, runOptions);
|
: agentManager.streamAgent(agentId, prompt, runOptions);
|
||||||
logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: snapshot?.provider,
|
|
||||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
|
||||||
shouldReplace,
|
|
||||||
},
|
|
||||||
"agent.session.start_stream.iterator_returned",
|
|
||||||
);
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
for await (const _ of iterator) {
|
for await (const _ of iterator) {
|
||||||
// Events are broadcast via AgentManager subscribers.
|
// Events are broadcast via AgentManager subscribers.
|
||||||
}
|
}
|
||||||
logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: snapshot?.provider,
|
|
||||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
|
||||||
},
|
|
||||||
"agent.session.iterator.drained",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.trace(
|
|
||||||
{
|
|
||||||
agentId,
|
|
||||||
provider: snapshot?.provider,
|
|
||||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
|
||||||
err: error,
|
|
||||||
},
|
|
||||||
"agent.session.iterator.error",
|
|
||||||
);
|
|
||||||
logger.error({ err: error, agentId }, "Agent stream failed");
|
logger.error({ err: error, agentId }, "Agent stream failed");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -358,10 +358,6 @@ export type AgentStreamEvent =
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
|
|
||||||
return "turnId" in event ? event.turnId : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
|
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
|
||||||
|
|
||||||
export type AgentPermissionUpdate = AgentMetadata;
|
export type AgentPermissionUpdate = AgentMetadata;
|
||||||
@@ -480,7 +476,6 @@ export interface AgentSessionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentLaunchContext {
|
export interface AgentLaunchContext {
|
||||||
agentId?: string;
|
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { getAgentStreamEventTurnId, type AgentStreamEvent } from "./agent-sdk-types.js";
|
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
export interface ForegroundTurnWaiter {
|
export interface ForegroundTurnWaiter {
|
||||||
turnId: string;
|
turnId: string;
|
||||||
@@ -105,7 +105,7 @@ export class ForegroundRunState {
|
|||||||
event: AgentStreamEvent,
|
event: AgentStreamEvent,
|
||||||
options?: { terminal?: boolean },
|
options?: { terminal?: boolean },
|
||||||
): void {
|
): void {
|
||||||
const waiters = this.getMatchingWaiters(agent, getAgentStreamEventTurnId(event));
|
const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
|
||||||
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
|
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,3 +226,7 @@ function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
|
|||||||
pendingRun.settled = true;
|
pendingRun.settled = true;
|
||||||
pendingRun.resolveSettled();
|
pendingRun.resolveSettled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventTurnId(event: AgentStreamEvent): string | undefined {
|
||||||
|
return (event as { turnId?: string }).turnId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface AgentModeVisuals {
|
|||||||
|
|
||||||
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
|
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
|
||||||
AgentModeVisuals & {
|
AgentModeVisuals & {
|
||||||
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
|
|
||||||
isUnattended?: boolean;
|
isUnattended?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,9 +96,9 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
|
|||||||
colorTier: "planning",
|
colorTier: "planning",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "allow-all",
|
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||||
label: "Allow All",
|
label: "Autopilot",
|
||||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
description: "Autonomous mode that runs until task completion without user interaction",
|
||||||
icon: "ShieldOff",
|
icon: "ShieldOff",
|
||||||
colorTier: "dangerous",
|
colorTier: "dangerous",
|
||||||
isUnattended: true,
|
isUnattended: true,
|
||||||
|
|||||||
@@ -67,21 +67,10 @@ export interface BuildProviderRegistryOptions {
|
|||||||
isDev?: boolean;
|
isDev?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderClientFactoryOptions extends Pick<
|
|
||||||
BuildProviderRegistryOptions,
|
|
||||||
"workspaceGitService"
|
|
||||||
> {
|
|
||||||
customProvider?: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
extends: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProviderClientFactory = (
|
type ProviderClientFactory = (
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
options?: ProviderClientFactoryOptions,
|
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||||
) => AgentClient;
|
) => AgentClient;
|
||||||
|
|
||||||
interface ResolvedProvider {
|
interface ResolvedProvider {
|
||||||
@@ -103,7 +92,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
|||||||
codex: (logger, runtimeSettings, options) =>
|
codex: (logger, runtimeSettings, options) =>
|
||||||
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
||||||
workspaceGitService: options?.workspaceGitService,
|
workspaceGitService: options?.workspaceGitService,
|
||||||
customProvider: options?.customProvider,
|
|
||||||
}),
|
}),
|
||||||
copilot: (logger, runtimeSettings) =>
|
copilot: (logger, runtimeSettings) =>
|
||||||
new CopilotACPAgentClient({
|
new CopilotACPAgentClient({
|
||||||
@@ -522,11 +510,10 @@ function addDerivedProviders(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseProviderId = override.extends;
|
const baseProvider = resolvedProviders.get(override.extends);
|
||||||
const baseProvider = resolvedProviders.get(baseProviderId);
|
|
||||||
if (!baseProvider) {
|
if (!baseProvider) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Custom provider '${providerId}' extends unknown provider '${baseProviderId}'`,
|
`Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +522,7 @@ function addDerivedProviders(
|
|||||||
toRuntimeSettings(override),
|
toRuntimeSettings(override),
|
||||||
);
|
);
|
||||||
const baseDefinition = baseProvider.definition;
|
const baseDefinition = baseProvider.definition;
|
||||||
const baseFactory = getProviderClientFactory(baseProviderId);
|
const baseFactory = getProviderClientFactory(override.extends);
|
||||||
|
|
||||||
resolvedProviders.set(providerId, {
|
resolvedProviders.set(providerId, {
|
||||||
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
||||||
@@ -543,15 +530,8 @@ function addDerivedProviders(
|
|||||||
profileModels: override.models ?? [],
|
profileModels: override.models ?? [],
|
||||||
additionalModels: override.additionalModels ?? [],
|
additionalModels: override.additionalModels ?? [],
|
||||||
enabled: override.enabled !== false,
|
enabled: override.enabled !== false,
|
||||||
derivedFromProviderId: baseProviderId,
|
derivedFromProviderId: override.extends,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
|
||||||
baseFactory(logger, mergedRuntimeSettings, {
|
|
||||||
customProvider: {
|
|
||||||
id: providerId,
|
|
||||||
label: override.label ?? providerId,
|
|
||||||
extends: baseProviderId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,6 @@ import {
|
|||||||
resolveACPModeSelection,
|
resolveACPModeSelection,
|
||||||
resolveACPModelSelection,
|
resolveACPModelSelection,
|
||||||
} from "./acp-agent.js";
|
} from "./acp-agent.js";
|
||||||
import {
|
|
||||||
COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
COPILOT_MODES,
|
|
||||||
beforeCopilotModeWriter,
|
|
||||||
transformCopilotConfigOptions,
|
|
||||||
transformCopilotModeId,
|
|
||||||
transformCopilotSessionResponse,
|
|
||||||
writeCopilotProviderMode,
|
|
||||||
} from "./copilot-acp-agent.js";
|
|
||||||
import { transformPiModels } from "./pi-direct-agent.js";
|
import { transformPiModels } from "./pi-direct-agent.js";
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
@@ -149,73 +140,6 @@ function selectConfigOption(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
|
|
||||||
return new ACPAgentSession(
|
|
||||||
{
|
|
||||||
provider: "copilot",
|
|
||||||
cwd: "/tmp/paseo-acp-test",
|
|
||||||
modeId: modeId ?? undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "copilot",
|
|
||||||
logger: createTestLogger(),
|
|
||||||
defaultCommand: ["copilot", "--acp"],
|
|
||||||
defaultModes: COPILOT_MODES,
|
|
||||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
|
||||||
configOptionsTransformer: transformCopilotConfigOptions,
|
|
||||||
modeIdTransformer: transformCopilotModeId,
|
|
||||||
providerModeWriter: writeCopilotProviderMode,
|
|
||||||
beforeModeWriter: beforeCopilotModeWriter,
|
|
||||||
capabilities: {
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: true,
|
|
||||||
supportsMcpServers: true,
|
|
||||||
supportsReasoningStream: true,
|
|
||||||
supportsToolInvocations: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function copilotModeConfigOption(currentValue: string): SessionConfigOption {
|
|
||||||
return {
|
|
||||||
id: "mode",
|
|
||||||
name: "Mode",
|
|
||||||
category: "mode",
|
|
||||||
type: "select",
|
|
||||||
currentValue,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
||||||
name: "Agent",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
|
||||||
name: "Plan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
|
||||||
name: "Autopilot",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigOption {
|
|
||||||
return {
|
|
||||||
id: "allow_all",
|
|
||||||
name: "Allow All",
|
|
||||||
category: "permissions",
|
|
||||||
type: "select",
|
|
||||||
currentValue,
|
|
||||||
options: [
|
|
||||||
{ value: "on", name: "On" },
|
|
||||||
{ value: "off", name: "Off" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
|
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
|
||||||
if (category === "mode") {
|
if (category === "mode") {
|
||||||
return "Mode";
|
return "Mode";
|
||||||
@@ -266,7 +190,7 @@ function prepareConfiguredOverrideSession(
|
|||||||
|
|
||||||
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
|
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
const childLogger = { warn: vi.fn() };
|
||||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||||
const session = createSessionWithConfig({}, logger);
|
const session = createSessionWithConfig({}, logger);
|
||||||
const setSessionConfigOption = vi.fn(async () => ({
|
const setSessionConfigOption = vi.fn(async () => ({
|
||||||
@@ -653,7 +577,7 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
expect(await validSession.getCurrentMode()).toBe("default");
|
expect(await validSession.getCurrentMode()).toBe("default");
|
||||||
|
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
const childLogger = { warn: vi.fn() };
|
||||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||||
const invalidSession = createSessionWithConfig(
|
const invalidSession = createSessionWithConfig(
|
||||||
{ modeId: "acceptEdits", model: "opus" },
|
{ modeId: "acceptEdits", model: "opus" },
|
||||||
@@ -872,10 +796,12 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("passes generic ACP permission requests through to the user", async () => {
|
test("passes Copilot Autopilot ACP permission requests through to the user", async () => {
|
||||||
|
// Zed parity: Copilot Autopilot no longer auto-approves ACP permission requests,
|
||||||
|
// so the accepted UX regression is that every tool request reaches the user UI.
|
||||||
const session = createSessionWithConfig({
|
const session = createSessionWithConfig({
|
||||||
provider: "cursor-acp",
|
provider: "copilot",
|
||||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||||
});
|
});
|
||||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||||
const permissionOptions: PermissionOption[] = [
|
const permissionOptions: PermissionOption[] = [
|
||||||
@@ -909,126 +835,6 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
outcome: { outcome: "selected", optionId: "allow-once" },
|
outcome: { outcome: "selected", optionId: "allow-once" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
|
|
||||||
const setSessionConfigOption = vi.fn(async () => ({
|
|
||||||
configOptions: [
|
|
||||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
||||||
copilotAllowAllConfigOption("on"),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const setSessionMode = vi.fn(async () => undefined);
|
|
||||||
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
|
|
||||||
const { internals } = prepareConfiguredOverrideSession(session, {
|
|
||||||
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
||||||
availableModes: COPILOT_MODES,
|
|
||||||
configOptions: [
|
|
||||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
||||||
copilotAllowAllConfigOption("off"),
|
|
||||||
],
|
|
||||||
connection: { setSessionConfigOption, setSessionMode },
|
|
||||||
});
|
|
||||||
const events: AgentStreamEvent[] = [];
|
|
||||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
|
||||||
await internals.applyConfiguredOverrides();
|
|
||||||
unsubscribe();
|
|
||||||
|
|
||||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
||||||
sessionId: "session-1",
|
|
||||||
configId: "allow_all",
|
|
||||||
value: "on",
|
|
||||||
});
|
|
||||||
expect(setSessionMode).not.toHaveBeenCalled();
|
|
||||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
|
||||||
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts Copilot's legacy autopilot mode ID as Allow All", async () => {
|
|
||||||
const setSessionConfigOption = vi.fn(async () => ({
|
|
||||||
configOptions: [
|
|
||||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
||||||
copilotAllowAllConfigOption("on"),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const setSessionMode = vi.fn(async () => undefined);
|
|
||||||
const session = createCopilotSessionWithConfig();
|
|
||||||
prepareConfiguredOverrideSession(session, {
|
|
||||||
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
||||||
availableModes: COPILOT_MODES,
|
|
||||||
configOptions: [
|
|
||||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
||||||
copilotAllowAllConfigOption("off"),
|
|
||||||
],
|
|
||||||
connection: { setSessionConfigOption, setSessionMode },
|
|
||||||
});
|
|
||||||
|
|
||||||
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#autopilot");
|
|
||||||
|
|
||||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
||||||
sessionId: "session-1",
|
|
||||||
configId: "allow_all",
|
|
||||||
value: "on",
|
|
||||||
});
|
|
||||||
expect(setSessionMode).not.toHaveBeenCalled();
|
|
||||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching Copilot away from Allow All turns allow_all off before setting the ACP mode", async () => {
|
|
||||||
const setSessionConfigOption = vi.fn(async (input: { value: string }) => ({
|
|
||||||
configOptions: [
|
|
||||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
||||||
copilotAllowAllConfigOption(input.value === "on" ? "on" : "off"),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const setSessionMode = vi.fn(async () => undefined);
|
|
||||||
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
|
|
||||||
prepareConfiguredOverrideSession(session, {
|
|
||||||
currentMode: COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
availableModes: COPILOT_MODES,
|
|
||||||
configOptions: [
|
|
||||||
copilotModeConfigOption(COPILOT_ALLOW_ALL_MODE_ID),
|
|
||||||
copilotAllowAllConfigOption("on"),
|
|
||||||
],
|
|
||||||
connection: { setSessionConfigOption, setSessionMode },
|
|
||||||
});
|
|
||||||
|
|
||||||
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#agent");
|
|
||||||
|
|
||||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
||||||
sessionId: "session-1",
|
|
||||||
configId: "allow_all",
|
|
||||||
value: "off",
|
|
||||||
});
|
|
||||||
expect(setSessionMode).toHaveBeenCalledWith({
|
|
||||||
sessionId: "session-1",
|
|
||||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("trusts Copilot allow_all config updates as the current mode source", async () => {
|
|
||||||
const session = createCopilotSessionWithConfig();
|
|
||||||
const internals = asInternals<ACPSessionInternals>(session);
|
|
||||||
|
|
||||||
const events = internals.translateSessionUpdate({
|
|
||||||
sessionUpdate: "config_option_update",
|
|
||||||
configOptions: [
|
|
||||||
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
||||||
copilotAllowAllConfigOption("on"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(events).toMatchObject([
|
|
||||||
{
|
|
||||||
type: "mode_changed",
|
|
||||||
provider: "copilot",
|
|
||||||
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
availableModes: expect.arrayContaining([
|
|
||||||
expect.objectContaining({ id: COPILOT_ALLOW_ALL_MODE_ID, label: "Allow All" }),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deriveModelDefinitionsFromACP", () => {
|
describe("deriveModelDefinitionsFromACP", () => {
|
||||||
|
|||||||
@@ -54,36 +54,35 @@ import {
|
|||||||
} from "@agentclientprotocol/sdk";
|
} from "@agentclientprotocol/sdk";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentCapabilityFlags,
|
||||||
type AgentCapabilityFlags,
|
AgentClient,
|
||||||
type AgentClient,
|
AgentLaunchContext,
|
||||||
type AgentLaunchContext,
|
AgentMetadata,
|
||||||
type AgentMetadata,
|
AgentMode,
|
||||||
type AgentMode,
|
AgentModelDefinition,
|
||||||
type AgentModelDefinition,
|
AgentPermissionRequest,
|
||||||
type AgentPermissionRequest,
|
AgentPermissionRequestKind,
|
||||||
type AgentPermissionRequestKind,
|
AgentPermissionResponse,
|
||||||
type AgentPermissionResponse,
|
AgentPersistenceHandle,
|
||||||
type AgentPersistenceHandle,
|
AgentPromptContentBlock,
|
||||||
type AgentPromptContentBlock,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentRunResult,
|
||||||
type AgentRunResult,
|
AgentRuntimeInfo,
|
||||||
type AgentRuntimeInfo,
|
AgentSession,
|
||||||
type AgentSession,
|
AgentSessionConfig,
|
||||||
type AgentSessionConfig,
|
AgentSlashCommand,
|
||||||
type AgentSlashCommand,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
AgentTimelineItem,
|
||||||
type AgentTimelineItem,
|
AgentUsage,
|
||||||
type AgentUsage,
|
ListModesOptions,
|
||||||
type ListModesOptions,
|
ListModelsOptions,
|
||||||
type ListModelsOptions,
|
ListPersistedAgentsOptions,
|
||||||
type ListPersistedAgentsOptions,
|
McpServerConfig,
|
||||||
type McpServerConfig,
|
PersistedAgentDescriptor,
|
||||||
type PersistedAgentDescriptor,
|
ToolCallDetail,
|
||||||
type ToolCallDetail,
|
ToolCallTimelineItem,
|
||||||
type ToolCallTimelineItem,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
createProviderEnvSpec,
|
createProviderEnvSpec,
|
||||||
@@ -227,13 +226,7 @@ interface ACPAgentClientOptions {
|
|||||||
defaultModes?: AgentMode[];
|
defaultModes?: AgentMode[];
|
||||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
|
||||||
modeIdTransformer?: (modeId: string) => string | null;
|
|
||||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
providerModeWriter?: (
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
) => Promise<ACPProviderModeWriteResult>;
|
|
||||||
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
|
|
||||||
thinkingOptionWriter?: (
|
thinkingOptionWriter?: (
|
||||||
connection: ClientSideConnection,
|
connection: ClientSideConnection,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -252,13 +245,7 @@ interface ACPAgentSessionOptions {
|
|||||||
defaultModes: AgentMode[];
|
defaultModes: AgentMode[];
|
||||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
|
||||||
modeIdTransformer?: (modeId: string) => string | null;
|
|
||||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
providerModeWriter?: (
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
) => Promise<ACPProviderModeWriteResult>;
|
|
||||||
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
|
|
||||||
thinkingOptionWriter?: (
|
thinkingOptionWriter?: (
|
||||||
connection: ClientSideConnection,
|
connection: ClientSideConnection,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -266,7 +253,6 @@ interface ACPAgentSessionOptions {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
capabilities: AgentCapabilityFlags;
|
capabilities: AgentCapabilityFlags;
|
||||||
handle?: AgentPersistenceHandle;
|
handle?: AgentPersistenceHandle;
|
||||||
agentId?: string;
|
|
||||||
launchEnv?: Record<string, string>;
|
launchEnv?: Record<string, string>;
|
||||||
waitForInitialCommands?: boolean;
|
waitForInitialCommands?: boolean;
|
||||||
initialCommandsWaitTimeoutMs?: number;
|
initialCommandsWaitTimeoutMs?: number;
|
||||||
@@ -351,26 +337,6 @@ interface ACPModelSelection {
|
|||||||
hasAvailableModels: boolean;
|
hasAvailableModels: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ACPProviderModeWriterContext {
|
|
||||||
connection: ClientSideConnection;
|
|
||||||
sessionId: string;
|
|
||||||
requestedModeId: string;
|
|
||||||
currentModeId: string | null;
|
|
||||||
selection: ACPModeSelection;
|
|
||||||
configOptions: SessionConfigOption[];
|
|
||||||
logger: Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ACPProviderModeWriteResult {
|
|
||||||
handled: boolean;
|
|
||||||
currentModeId?: string;
|
|
||||||
configOptions?: SessionConfigOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ACPBeforeModeWriteResult {
|
|
||||||
configOptions?: SessionConfigOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
|
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -499,17 +465,7 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
private readonly sessionResponseTransformer?: (
|
private readonly sessionResponseTransformer?: (
|
||||||
response: SessionStateResponse,
|
response: SessionStateResponse,
|
||||||
) => SessionStateResponse;
|
) => SessionStateResponse;
|
||||||
private readonly configOptionsTransformer?: (
|
|
||||||
configOptions: SessionConfigOption[],
|
|
||||||
) => SessionConfigOption[];
|
|
||||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
|
||||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
private readonly providerModeWriter?: (
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
) => Promise<ACPProviderModeWriteResult>;
|
|
||||||
private readonly beforeModeWriter?: (
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
) => Promise<ACPBeforeModeWriteResult>;
|
|
||||||
private readonly thinkingOptionWriter?: (
|
private readonly thinkingOptionWriter?: (
|
||||||
connection: ClientSideConnection,
|
connection: ClientSideConnection,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -521,20 +477,13 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
constructor(options: ACPAgentClientOptions) {
|
constructor(options: ACPAgentClientOptions) {
|
||||||
this.provider = options.provider;
|
this.provider = options.provider;
|
||||||
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
|
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
|
||||||
this.logger = options.logger.child({
|
this.logger = options.logger.child({ module: "agent", provider: options.provider });
|
||||||
module: "agent",
|
|
||||||
provider: options.provider,
|
|
||||||
});
|
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.defaultCommand = options.defaultCommand;
|
this.defaultCommand = options.defaultCommand;
|
||||||
this.defaultModes = options.defaultModes ?? [];
|
this.defaultModes = options.defaultModes ?? [];
|
||||||
this.modelTransformer = options.modelTransformer;
|
this.modelTransformer = options.modelTransformer;
|
||||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
|
||||||
this.modeIdTransformer = options.modeIdTransformer;
|
|
||||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||||
this.providerModeWriter = options.providerModeWriter;
|
|
||||||
this.beforeModeWriter = options.beforeModeWriter;
|
|
||||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||||
@@ -555,14 +504,9 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
defaultModes: this.defaultModes,
|
defaultModes: this.defaultModes,
|
||||||
modelTransformer: this.modelTransformer,
|
modelTransformer: this.modelTransformer,
|
||||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||||
configOptionsTransformer: this.configOptionsTransformer,
|
|
||||||
modeIdTransformer: this.modeIdTransformer,
|
|
||||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||||
providerModeWriter: this.providerModeWriter,
|
|
||||||
beforeModeWriter: this.beforeModeWriter,
|
|
||||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||||
capabilities: this.capabilities,
|
capabilities: this.capabilities,
|
||||||
agentId: launchContext?.agentId,
|
|
||||||
launchEnv: launchContext?.env,
|
launchEnv: launchContext?.env,
|
||||||
waitForInitialCommands: this.waitForInitialCommands,
|
waitForInitialCommands: this.waitForInitialCommands,
|
||||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||||
@@ -601,15 +545,10 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
defaultModes: this.defaultModes,
|
defaultModes: this.defaultModes,
|
||||||
modelTransformer: this.modelTransformer,
|
modelTransformer: this.modelTransformer,
|
||||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||||
configOptionsTransformer: this.configOptionsTransformer,
|
|
||||||
modeIdTransformer: this.modeIdTransformer,
|
|
||||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||||
providerModeWriter: this.providerModeWriter,
|
|
||||||
beforeModeWriter: this.beforeModeWriter,
|
|
||||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||||
capabilities: this.capabilities,
|
capabilities: this.capabilities,
|
||||||
handle,
|
handle,
|
||||||
agentId: launchContext?.agentId,
|
|
||||||
launchEnv: launchContext?.env,
|
launchEnv: launchContext?.env,
|
||||||
waitForInitialCommands: this.waitForInitialCommands,
|
waitForInitialCommands: this.waitForInitialCommands,
|
||||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||||
@@ -808,16 +747,7 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
|
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
|
||||||
const transformed = this.sessionResponseTransformer
|
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
|
||||||
? this.sessionResponseTransformer(response)
|
|
||||||
: response;
|
|
||||||
if (!this.configOptionsTransformer || !transformed.configOptions) {
|
|
||||||
return transformed;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...transformed,
|
|
||||||
configOptions: this.configOptionsTransformer(transformed.configOptions),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,23 +763,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
private readonly sessionResponseTransformer?: (
|
private readonly sessionResponseTransformer?: (
|
||||||
response: SessionStateResponse,
|
response: SessionStateResponse,
|
||||||
) => SessionStateResponse;
|
) => SessionStateResponse;
|
||||||
private readonly configOptionsTransformer?: (
|
|
||||||
configOptions: SessionConfigOption[],
|
|
||||||
) => SessionConfigOption[];
|
|
||||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
|
||||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
private readonly providerModeWriter?: (
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
) => Promise<ACPProviderModeWriteResult>;
|
|
||||||
private readonly beforeModeWriter?: (
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
) => Promise<ACPBeforeModeWriteResult>;
|
|
||||||
private readonly thinkingOptionWriter?: (
|
private readonly thinkingOptionWriter?: (
|
||||||
connection: ClientSideConnection,
|
connection: ClientSideConnection,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
thinkingOptionId: string,
|
thinkingOptionId: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
private readonly agentId?: string;
|
|
||||||
private readonly launchEnv?: Record<string, string>;
|
private readonly launchEnv?: Record<string, string>;
|
||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
||||||
@@ -895,14 +814,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
this.defaultModes = options.defaultModes;
|
this.defaultModes = options.defaultModes;
|
||||||
this.modelTransformer = options.modelTransformer;
|
this.modelTransformer = options.modelTransformer;
|
||||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
|
||||||
this.modeIdTransformer = options.modeIdTransformer;
|
|
||||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||||
this.providerModeWriter = options.providerModeWriter;
|
|
||||||
this.beforeModeWriter = options.beforeModeWriter;
|
|
||||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||||
this.availableModes = options.defaultModes;
|
this.availableModes = options.defaultModes;
|
||||||
this.agentId = options.agentId;
|
|
||||||
this.launchEnv = options.launchEnv;
|
this.launchEnv = options.launchEnv;
|
||||||
this.initialHandle = options.handle;
|
this.initialHandle = options.handle;
|
||||||
this.config = { ...config, provider: options.provider };
|
this.config = { ...config, provider: options.provider };
|
||||||
@@ -1154,25 +1068,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
throw new Error("ACP session not initialized");
|
throw new Error("ACP session not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = this.createProviderModeWriterContext(modeId, selection);
|
|
||||||
const providerResult = this.providerModeWriter
|
|
||||||
? await this.providerModeWriter(context)
|
|
||||||
: { handled: false };
|
|
||||||
if (providerResult.handled) {
|
|
||||||
this.currentMode = providerResult.currentModeId ?? modeId;
|
|
||||||
if (providerResult.configOptions) {
|
|
||||||
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
|
|
||||||
}
|
|
||||||
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
|
|
||||||
this.pushEvent({
|
|
||||||
type: "mode_changed",
|
|
||||||
provider: this.provider,
|
|
||||||
currentModeId: this.currentMode,
|
|
||||||
availableModes: [...this.availableModes],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.hasAvailableModes) {
|
if (selection.hasAvailableModes) {
|
||||||
if (!selection.availableMode) {
|
if (!selection.availableMode) {
|
||||||
this.warnInvalidSelection(
|
this.warnInvalidSelection(
|
||||||
@@ -1183,32 +1078,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const modeOption = selection.configOption;
|
|
||||||
if (!modeOption) {
|
|
||||||
throw new Error(`${this.provider} does not expose ACP mode switching`);
|
|
||||||
}
|
|
||||||
if (!selection.configChoice) {
|
|
||||||
this.warnInvalidSelection(
|
|
||||||
modeId,
|
|
||||||
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
|
|
||||||
modeOption.options,
|
|
||||||
)
|
|
||||||
.map((option) => option.value)
|
|
||||||
.join(", ")}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.beforeModeWriter) {
|
|
||||||
const beforeResult = await this.beforeModeWriter(context);
|
|
||||||
if (beforeResult?.configOptions) {
|
|
||||||
this.configOptions = this.transformConfigOptions(beforeResult.configOptions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.hasAvailableModes) {
|
|
||||||
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
|
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
|
||||||
this.currentMode = modeId;
|
this.currentMode = modeId;
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
@@ -1224,6 +1094,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
if (!modeOption) {
|
if (!modeOption) {
|
||||||
throw new Error(`${this.provider} does not expose ACP mode switching`);
|
throw new Error(`${this.provider} does not expose ACP mode switching`);
|
||||||
}
|
}
|
||||||
|
if (!selection.configChoice) {
|
||||||
|
this.warnInvalidSelection(
|
||||||
|
modeId,
|
||||||
|
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
|
||||||
|
modeOption.options,
|
||||||
|
)
|
||||||
|
.map((option) => option.value)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.connection.setSessionConfigOption({
|
const response = await this.connection.setSessionConfigOption({
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
@@ -1246,24 +1127,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createProviderModeWriterContext(
|
|
||||||
requestedModeId: string,
|
|
||||||
selection: ACPModeSelection,
|
|
||||||
): ACPProviderModeWriterContext {
|
|
||||||
if (!this.connection || !this.sessionId) {
|
|
||||||
throw new Error("ACP session not initialized");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
connection: this.connection,
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
requestedModeId,
|
|
||||||
currentModeId: this.currentMode,
|
|
||||||
selection,
|
|
||||||
configOptions: this.configOptions,
|
|
||||||
logger: this.logger,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async setModel(modelId: string | null): Promise<void> {
|
async setModel(modelId: string | null): Promise<void> {
|
||||||
if (!this.connection || !this.sessionId) {
|
if (!this.connection || !this.sessionId) {
|
||||||
throw new Error("ACP session not initialized");
|
throw new Error("ACP session not initialized");
|
||||||
@@ -1418,9 +1281,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
requestedValue: string;
|
requestedValue: string;
|
||||||
label: string;
|
label: string;
|
||||||
}): string {
|
}): string {
|
||||||
this.configOptions = this.transformConfigOptions(response.configOptions);
|
this.configOptions = response.configOptions;
|
||||||
const responseOption = findSelectConfigOption({
|
const responseOption = findSelectConfigOption({
|
||||||
configOptions: this.configOptions,
|
configOptions: response.configOptions,
|
||||||
category,
|
category,
|
||||||
id: configId,
|
id: configId,
|
||||||
});
|
});
|
||||||
@@ -1546,7 +1409,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||||
// Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through.
|
// Match Zed acp.rs:3189-3220 — pure pass-through. Accepted UX regression: Copilot Autopilot will now prompt the user for every tool request.
|
||||||
const requestId = randomUUID();
|
const requestId = randomUUID();
|
||||||
let toolSnapshot =
|
let toolSnapshot =
|
||||||
this.toolCalls.get(params.toolCall.toolCallId) ??
|
this.toolCalls.get(params.toolCall.toolCallId) ??
|
||||||
@@ -1576,31 +1439,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: this.provider,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
rawEvent: params,
|
|
||||||
},
|
|
||||||
"provider.acp.raw_event",
|
|
||||||
);
|
|
||||||
if (params.sessionId !== this.sessionId) {
|
if (params.sessionId !== this.sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = this.translateSessionUpdate(params.update);
|
const events = this.translateSessionUpdate(params.update);
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: this.provider,
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? undefined,
|
|
||||||
rawEvent: params,
|
|
||||||
events,
|
|
||||||
},
|
|
||||||
"provider.acp.parsed_event",
|
|
||||||
);
|
|
||||||
if (this.replayingHistory) {
|
if (this.replayingHistory) {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.type === "timeline") {
|
if (event.type === "timeline") {
|
||||||
@@ -1779,7 +1622,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
? this.sessionResponseTransformer(response)
|
? this.sessionResponseTransformer(response)
|
||||||
: response;
|
: response;
|
||||||
|
|
||||||
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
|
this.configOptions = transformed.configOptions ?? [];
|
||||||
|
|
||||||
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
|
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
|
||||||
this.availableModes = modeInfo.modes;
|
this.availableModes = modeInfo.modes;
|
||||||
@@ -1792,16 +1635,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
|
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private transformConfigOptions(configOptions: SessionConfigOption[]): SessionConfigOption[] {
|
|
||||||
return this.configOptionsTransformer
|
|
||||||
? this.configOptionsTransformer(configOptions)
|
|
||||||
: configOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private transformModeId(modeId: string): string | null {
|
|
||||||
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyConfiguredOverrides(): Promise<void> {
|
private async applyConfiguredOverrides(): Promise<void> {
|
||||||
const configuredModeId = this.config.modeId;
|
const configuredModeId = this.config.modeId;
|
||||||
if (configuredModeId && configuredModeId !== this.currentMode) {
|
if (configuredModeId && configuredModeId !== this.currentMode) {
|
||||||
@@ -1939,11 +1772,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
|
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
|
||||||
this.currentMode = this.transformModeId(update.currentModeId);
|
this.currentMode = update.currentModeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
|
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
|
||||||
this.configOptions = this.transformConfigOptions(update.configOptions);
|
this.configOptions = update.configOptions;
|
||||||
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
||||||
const nextMode = modeInfo.currentModeId;
|
const nextMode = modeInfo.currentModeId;
|
||||||
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
|
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
|
||||||
@@ -2031,16 +1864,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pushEvent(event: AgentStreamEvent): void {
|
private pushEvent(event: AgentStreamEvent): void {
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: this.provider,
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
"provider.acp.event_emit",
|
|
||||||
);
|
|
||||||
for (const subscriber of this.subscribers) {
|
for (const subscriber of this.subscribers) {
|
||||||
subscriber(event);
|
subscriber(event);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-12
@@ -14,11 +14,7 @@ import { tmpdir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import {
|
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
getAgentStreamEventTurnId,
|
|
||||||
type AgentSession,
|
|
||||||
type AgentStreamEvent,
|
|
||||||
} from "../../agent-sdk-types.js";
|
|
||||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
|
||||||
@@ -41,14 +37,15 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
|
||||||
type EventWithTurnId = AgentStreamEvent & { turnId: string };
|
type EventWithTurnId = AgentStreamEvent & { turnId: string };
|
||||||
|
|
||||||
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
|
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
|
||||||
return getAgentStreamEventTurnId(event) !== undefined;
|
return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
|
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
|
||||||
return events.filter((e) => getAgentStreamEventTurnId(e) === turnId);
|
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
|
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
|
||||||
@@ -160,7 +157,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
|
|||||||
// Invariant 2: Every turn_started has exactly one matching terminal
|
// Invariant 2: Every turn_started has exactly one matching terminal
|
||||||
const turnStartedIds = events
|
const turnStartedIds = events
|
||||||
.filter((e) => e.type === "turn_started" && hasTurnId(e))
|
.filter((e) => e.type === "turn_started" && hasTurnId(e))
|
||||||
.map((e) => e.turnId);
|
.map((e) => (e as EventWithTurnId).turnId);
|
||||||
|
|
||||||
for (const turnId of turnStartedIds) {
|
for (const turnId of turnStartedIds) {
|
||||||
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
|
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
|
||||||
@@ -280,7 +277,7 @@ test("Test 3: Lifecycle doesn't get stuck in running", async () => {
|
|||||||
|
|
||||||
// Any turn_started after terminal must have a different turnId
|
// Any turn_started after terminal must have a different turnId
|
||||||
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
|
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
|
||||||
expect(ts.turnId).not.toBe(turnId);
|
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
assertInvariants(events, [turnId]);
|
assertInvariants(events, [turnId]);
|
||||||
@@ -316,9 +313,8 @@ test("Test 4: Autonomous run", async () => {
|
|||||||
|
|
||||||
// Autonomous turn_started with a different turnId
|
// Autonomous turn_started with a different turnId
|
||||||
const autoStarts = afterForeground.filter(
|
const autoStarts = afterForeground.filter(
|
||||||
(e): e is EventWithTurnId =>
|
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||||
e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
) as EventWithTurnId[];
|
||||||
);
|
|
||||||
if (autoStarts.length === 0) {
|
if (autoStarts.length === 0) {
|
||||||
assertInvariants(events, [fgTurnId]);
|
assertInvariants(events, [fgTurnId]);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -42,35 +42,34 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../prov
|
|||||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentPermissionAction,
|
||||||
type AgentPermissionAction,
|
AgentCapabilityFlags,
|
||||||
type AgentCapabilityFlags,
|
AgentClient,
|
||||||
type AgentClient,
|
AgentCreateSessionOptions,
|
||||||
type AgentCreateSessionOptions,
|
AgentLaunchContext,
|
||||||
type AgentLaunchContext,
|
AgentMetadata,
|
||||||
type AgentMetadata,
|
AgentMode,
|
||||||
type AgentMode,
|
AgentModelDefinition,
|
||||||
type AgentModelDefinition,
|
AgentPermissionRequest,
|
||||||
type AgentPermissionRequest,
|
AgentPermissionRequestKind,
|
||||||
type AgentPermissionRequestKind,
|
AgentPermissionResponse,
|
||||||
type AgentPermissionResponse,
|
AgentPermissionUpdate,
|
||||||
type AgentPermissionUpdate,
|
AgentPersistenceHandle,
|
||||||
type AgentPersistenceHandle,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentRunResult,
|
||||||
type AgentRunResult,
|
AgentSession,
|
||||||
type AgentSession,
|
AgentSessionConfig,
|
||||||
type AgentSessionConfig,
|
AgentSlashCommand,
|
||||||
type AgentSlashCommand,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
AgentTimelineItem,
|
||||||
type AgentTimelineItem,
|
AgentUsage,
|
||||||
type AgentUsage,
|
AgentRuntimeInfo,
|
||||||
type AgentRuntimeInfo,
|
ListModelsOptions,
|
||||||
type ListModelsOptions,
|
ListPersistedAgentsOptions,
|
||||||
type ListPersistedAgentsOptions,
|
McpServerConfig,
|
||||||
type McpServerConfig,
|
PersistedAgentDescriptor,
|
||||||
type PersistedAgentDescriptor,
|
|
||||||
} from "../../agent-sdk-types.js";
|
} from "../../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
createProviderEnv,
|
createProviderEnv,
|
||||||
@@ -251,7 +250,6 @@ interface ClaudeAgentSessionOptions {
|
|||||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
handle?: AgentPersistenceHandle;
|
handle?: AgentPersistenceHandle;
|
||||||
agentId?: string;
|
|
||||||
launchEnv?: Record<string, string>;
|
launchEnv?: Record<string, string>;
|
||||||
persistSession?: boolean;
|
persistSession?: boolean;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -1154,6 +1152,8 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
|
||||||
|
|
||||||
export class ClaudeAgentClient implements AgentClient {
|
export class ClaudeAgentClient implements AgentClient {
|
||||||
readonly provider = "claude" as const;
|
readonly provider = "claude" as const;
|
||||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
@@ -1181,7 +1181,6 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
return new ClaudeAgentSession(claudeConfig, {
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
runtimeSettings: this.runtimeSettings,
|
runtimeSettings: this.runtimeSettings,
|
||||||
agentId: launchContext?.agentId,
|
|
||||||
launchEnv: launchContext?.env,
|
launchEnv: launchContext?.env,
|
||||||
persistSession: options?.persistSession,
|
persistSession: options?.persistSession,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
@@ -1210,7 +1209,6 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
runtimeSettings: this.runtimeSettings,
|
runtimeSettings: this.runtimeSettings,
|
||||||
handle,
|
handle,
|
||||||
agentId: launchContext?.agentId,
|
|
||||||
launchEnv: launchContext?.env,
|
launchEnv: launchContext?.env,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
queryFactory: this.queryFactory,
|
queryFactory: this.queryFactory,
|
||||||
@@ -1479,7 +1477,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private readonly config: ClaudeAgentConfig;
|
private readonly config: ClaudeAgentConfig;
|
||||||
private readonly launchEnv?: Record<string, string>;
|
private readonly launchEnv?: Record<string, string>;
|
||||||
private readonly agentId?: string;
|
|
||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly persistSession?: boolean;
|
private readonly persistSession?: boolean;
|
||||||
@@ -1529,11 +1526,10 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.launchEnv = options.launchEnv;
|
this.launchEnv = options.launchEnv;
|
||||||
this.agentId = options.agentId;
|
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.persistSession = options.persistSession;
|
this.persistSession = options.persistSession;
|
||||||
this.logger = options.logger.child({ agentId: this.agentId });
|
this.logger = options.logger;
|
||||||
this.queryFactory = options.queryFactory;
|
this.queryFactory = options.queryFactory;
|
||||||
this.resolveBinary = options.resolveBinary;
|
this.resolveBinary = options.resolveBinary;
|
||||||
const handle = options.handle;
|
const handle = options.handle;
|
||||||
@@ -1873,16 +1869,13 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId: this.agentId,
|
claudeSessionId: this.claudeSessionId,
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
turnState: this.turnState,
|
turnState: this.turnState,
|
||||||
hasQuery: Boolean(this.query),
|
hasQuery: Boolean(this.query),
|
||||||
hasInput: Boolean(this.input),
|
hasInput: Boolean(this.input),
|
||||||
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
|
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
|
||||||
},
|
},
|
||||||
"provider.claude.session_close.start",
|
"Claude session close: start",
|
||||||
);
|
);
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||||
@@ -1917,13 +1910,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
|
||||||
agentId: this.agentId,
|
"Claude session close: completed",
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnState: this.turnState,
|
|
||||||
},
|
|
||||||
"provider.claude.session_close.complete",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2200,41 +2188,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
label: string,
|
label: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
this.logger.trace(
|
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
label,
|
|
||||||
},
|
|
||||||
"provider.claude.query_operation.skip",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
this.logger.trace(
|
this.logger.trace({ label }, "Claude query operation wait start");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
label,
|
|
||||||
},
|
|
||||||
"provider.claude.query_operation.start",
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await withTimeout(promise, 3_000, "timeout");
|
await withTimeout(promise, 3_000, "timeout");
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{ label, durationMs: Date.now() - startedAt },
|
||||||
agentId: this.agentId,
|
"Claude query operation settled",
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
label,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
},
|
|
||||||
"provider.claude.query_operation.settled",
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
|
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
|
||||||
@@ -2630,16 +2593,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pump = this.runQueryPump().catch((error) => {
|
const pump = this.runQueryPump().catch((error) => {
|
||||||
this.logger.trace(
|
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
err: error,
|
|
||||||
},
|
|
||||||
"provider.claude.query_pump.exit_unexpected",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.queryPumpPromise = pump;
|
this.queryPumpPromise = pump;
|
||||||
@@ -2655,34 +2609,24 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
try {
|
try {
|
||||||
activeQuery = await this.ensureQuery();
|
activeQuery = await this.ensureQuery();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.trace(
|
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
err: error,
|
|
||||||
},
|
|
||||||
"provider.claude.query_pump.init_failed",
|
|
||||||
);
|
|
||||||
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
|
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let consecutiveInterruptAbortRecoveries = 0;
|
let consecutiveInterruptAbortRecoveries = 0;
|
||||||
const logRawMessage = (message: SDKMessage): void => {
|
const logRawMessage = (message: SDKMessage): void => {
|
||||||
|
if (!claudeDebug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId: this.agentId,
|
claudeSessionId: this.claudeSessionId,
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
messageType: message.type,
|
messageType: message.type,
|
||||||
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
||||||
messageUuid: "uuid" in message ? message.uuid : undefined,
|
messageUuid: "uuid" in message ? message.uuid : undefined,
|
||||||
rawEvent: message,
|
|
||||||
},
|
},
|
||||||
"provider.claude.raw_event",
|
"Claude query pump: raw SDK message",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||||
@@ -2795,18 +2739,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||||
const identifiers = readEventIdentifiers(message);
|
const identifiers = readEventIdentifiers(message);
|
||||||
|
|
||||||
this.logger.trace(
|
if (claudeDebug) {
|
||||||
{
|
this.logger.trace(
|
||||||
agentId: this.agentId,
|
{
|
||||||
provider: "claude",
|
claudeSessionId: this.claudeSessionId,
|
||||||
sessionId: this.claudeSessionId,
|
messageType: message.type,
|
||||||
turnId: turnId ?? undefined,
|
turnId,
|
||||||
messageType: message.type,
|
},
|
||||||
identifiers,
|
"Claude query pump: SDK message",
|
||||||
rawEvent: message,
|
);
|
||||||
},
|
}
|
||||||
"provider.claude.parsed_event",
|
|
||||||
);
|
|
||||||
|
|
||||||
const messageEvents = this.translateMessageToEvents(message, {
|
const messageEvents = this.translateMessageToEvents(message, {
|
||||||
suppressAssistantText: true,
|
suppressAssistantText: true,
|
||||||
@@ -2874,6 +2816,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{
|
{
|
||||||
|
claudeSessionId: this.claudeSessionId,
|
||||||
error: staleResumeError,
|
error: staleResumeError,
|
||||||
},
|
},
|
||||||
"Claude resumed session no longer exists; invalidating persisted session",
|
"Claude resumed session no longer exists; invalidating persisted session",
|
||||||
@@ -2903,15 +2846,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private async interruptActiveTurn(): Promise<void> {
|
private async interruptActiveTurn(): Promise<void> {
|
||||||
const queryToInterrupt = this.query;
|
const queryToInterrupt = this.query;
|
||||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||||
this.logger.trace(
|
this.logger.trace("interruptActiveTurn: no query to interrupt");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
|
||||||
},
|
|
||||||
"provider.claude.interrupt.no_query",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pendingInterruptAbort = true;
|
this.pendingInterruptAbort = true;
|
||||||
@@ -3520,16 +3455,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private notifySubscribers(event: AgentStreamEvent): void {
|
private notifySubscribers(event: AgentStreamEvent): void {
|
||||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||||
const tagged = turnId ? { ...event, turnId } : event;
|
const tagged = turnId ? { ...event, turnId } : event;
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: this.claudeSessionId,
|
|
||||||
turnId: getAgentStreamEventTurnId(tagged),
|
|
||||||
event: tagged,
|
|
||||||
},
|
|
||||||
"provider.claude.event_emit",
|
|
||||||
);
|
|
||||||
for (const callback of this.subscribers) {
|
for (const callback of this.subscribers) {
|
||||||
try {
|
try {
|
||||||
callback(tagged);
|
callback(tagged);
|
||||||
|
|||||||
+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, readFileSync, 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,12 +17,11 @@ 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";
|
||||||
import { buildProviderRegistry } from "../provider-registry.js";
|
|
||||||
|
|
||||||
interface CollaborationModeRecord {
|
interface CollaborationModeRecord {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,7 +32,6 @@ interface CollaborationModeRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CodexSessionTestAccess {
|
interface CodexSessionTestAccess {
|
||||||
ensureThreadLoaded(): Promise<void>;
|
|
||||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||||
handleNotification(method: string, params: unknown): void;
|
handleNotification(method: string, params: unknown): void;
|
||||||
loadPersistedHistory(): Promise<void>;
|
loadPersistedHistory(): Promise<void>;
|
||||||
@@ -104,98 +100,32 @@ function markdownImageSource(markdown: string): string {
|
|||||||
return match[1].replace(/\\\)/g, ")");
|
return match[1].replace(/\\\)/g, ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
type CapturedFakeCodexRecord = Record<string, unknown>;
|
function waitForNextPermission(
|
||||||
|
session: AgentSession,
|
||||||
async function runCustomCodexProviderTurn(
|
events: AgentStreamEvent[],
|
||||||
providerId: string,
|
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
||||||
baseUrl: string,
|
const existing = events.find(
|
||||||
): Promise<CapturedFakeCodexRecord[]> {
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-"));
|
event.type === "permission_requested",
|
||||||
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
|
|
||||||
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
|
|
||||||
writeFileSync(
|
|
||||||
fakeAppServerPath,
|
|
||||||
`
|
|
||||||
const fs = require("node:fs");
|
|
||||||
|
|
||||||
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
fs.appendFileSync(capturePath, JSON.stringify({
|
|
||||||
kind: "env",
|
|
||||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
|
||||||
}) + "\\n");
|
|
||||||
|
|
||||||
function record(method, params) {
|
|
||||||
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultFor(method) {
|
|
||||||
if (method === "initialize") return {};
|
|
||||||
if (method === "collaborationMode/list") return { data: [] };
|
|
||||||
if (method === "skills/list") return { data: [] };
|
|
||||||
if (method === "config/read") return { config: {} };
|
|
||||||
if (method === "getUserSavedConfig") return { config: {} };
|
|
||||||
if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] };
|
|
||||||
if (method === "thread/start") return { thread: { id: "thread-1" } };
|
|
||||||
if (method === "turn/start") return {};
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
process.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;
|
|
||||||
const message = JSON.parse(line);
|
|
||||||
record(message.method, message.params);
|
|
||||||
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
);
|
);
|
||||||
|
if (existing) {
|
||||||
const registry = buildProviderRegistry(createTestLogger(), {
|
return Promise.resolve(existing);
|
||||||
providerOverrides: {
|
|
||||||
[providerId]: {
|
|
||||||
extends: "codex",
|
|
||||||
label: "Custom Codex",
|
|
||||||
command: [process.execPath, fakeAppServerPath],
|
|
||||||
env: {
|
|
||||||
OPENAI_API_KEY: "sk-custom",
|
|
||||||
OPENAI_BASE_URL: baseUrl,
|
|
||||||
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const session = await registry[providerId].createClient(createTestLogger()).createSession({
|
|
||||||
provider: providerId,
|
|
||||||
cwd: "/workspace/project",
|
|
||||||
modeId: "auto",
|
|
||||||
model: "custom-model",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await session.startTurn("use the custom endpoint");
|
|
||||||
return readFileSync(capturedRequestsPath, "utf8")
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord);
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
|
return new Promise((resolve, reject) => {
|
||||||
const threadStart = records.find((record) => record.method === "thread/start");
|
const timeout = setTimeout(() => {
|
||||||
const params = threadStart?.params as Record<string, unknown> | undefined;
|
unsubscribe();
|
||||||
return params?.config;
|
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", () => {
|
||||||
@@ -261,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();
|
||||||
@@ -285,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: [] }),
|
||||||
@@ -294,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({
|
||||||
@@ -331,128 +262,14 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("configures Codex app-server to use a custom provider base URL", async () => {
|
|
||||||
const capturedRequests = await runCustomCodexProviderTurn(
|
|
||||||
"codex-iisb",
|
|
||||||
"https://custom-relay.example.com",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(capturedRequests[0]).toEqual({
|
|
||||||
kind: "env",
|
|
||||||
OPENAI_API_KEY: "sk-custom",
|
|
||||||
OPENAI_BASE_URL: "https://custom-relay.example.com",
|
|
||||||
});
|
|
||||||
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
|
|
||||||
model_provider: "codex-iisb",
|
|
||||||
model_providers: {
|
|
||||||
"codex-iisb": {
|
|
||||||
name: "Custom Codex",
|
|
||||||
base_url: "https://custom-relay.example.com/v1",
|
|
||||||
env_key: "OPENAI_API_KEY",
|
|
||||||
requires_openai_auth: false,
|
|
||||||
wire_api: "responses",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not append v1 twice for custom Codex provider base URLs", async () => {
|
|
||||||
const capturedRequests = await runCustomCodexProviderTurn(
|
|
||||||
"codex-custom",
|
|
||||||
"https://custom-relay.example.com/v1/",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
|
|
||||||
model_provider: "codex-custom",
|
|
||||||
model_providers: {
|
|
||||||
"codex-custom": expect.objectContaining({
|
|
||||||
base_url: "https://custom-relay.example.com/v1",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
|
|
||||||
const threadRequests: string[] = [];
|
|
||||||
const appServer = createFakeCodexAppServer({
|
|
||||||
"thread/loaded/list": () => {
|
|
||||||
threadRequests.push("thread/loaded/list");
|
|
||||||
return { data: [] };
|
|
||||||
},
|
|
||||||
"thread/resume": () => {
|
|
||||||
threadRequests.push("thread/resume");
|
|
||||||
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
|
|
||||||
},
|
|
||||||
"thread/start": () => {
|
|
||||||
threadRequests.push("thread/start");
|
|
||||||
return { thread: { id: "replacement-empty-thread-id" } };
|
|
||||||
},
|
|
||||||
"thread/read": () => {
|
|
||||||
threadRequests.push("thread/read");
|
|
||||||
return { thread: { turns: [] } };
|
|
||||||
},
|
|
||||||
getUserSavedConfig: () => {
|
|
||||||
threadRequests.push("getUserSavedConfig");
|
|
||||||
return { config: {} };
|
|
||||||
},
|
|
||||||
"config/read": () => {
|
|
||||||
threadRequests.push("config/read");
|
|
||||||
return { config: {} };
|
|
||||||
},
|
|
||||||
"model/list": () => {
|
|
||||||
threadRequests.push("model/list");
|
|
||||||
return {
|
|
||||||
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
|
||||||
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
|
|
||||||
Promise.resolve(false);
|
|
||||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
|
||||||
provider,
|
|
||||||
).spawnAppServer = async () => appServer.child;
|
|
||||||
|
|
||||||
const outcome = await Promise.race([
|
|
||||||
provider
|
|
||||||
.resumeSession({
|
|
||||||
sessionId: "archived-thread-id",
|
|
||||||
metadata: {
|
|
||||||
cwd: "/tmp/codex-question-test",
|
|
||||||
modeId: "auto",
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
() => "resolved" as const,
|
|
||||||
(error) => {
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
|
||||||
expect((error as Error).message).toContain(
|
|
||||||
"no rollout found for thread id archived-thread-id",
|
|
||||||
);
|
|
||||||
return "rejected" as const;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (outcome === "timed_out") {
|
|
||||||
appServer.child.kill("SIGTERM");
|
|
||||||
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume"]);
|
|
||||||
expect(outcome).toBe("rejected");
|
|
||||||
appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
|
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
|
||||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
||||||
const cwd = path.join(tempDir, "repo", "packages", "app");
|
const cwd = path.join(tempDir, "repo", "packages", "app");
|
||||||
@@ -463,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 {
|
||||||
@@ -475,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 });
|
||||||
}
|
}
|
||||||
@@ -1114,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 }> = [];
|
||||||
@@ -1209,37 +984,6 @@ describe("Codex app-server provider", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
|
|
||||||
const session = createSession({ thinkingOptionId: "medium" });
|
|
||||||
session.currentThreadId = "archived-thread-id";
|
|
||||||
const requests: Array<{ method: string; params: unknown }> = [];
|
|
||||||
session.client = {
|
|
||||||
request: vi.fn(async (method: string, params: unknown) => {
|
|
||||||
requests.push({ method, params });
|
|
||||||
if (method === "thread/loaded/list") {
|
|
||||||
return { data: [] };
|
|
||||||
}
|
|
||||||
if (method === "thread/resume") {
|
|
||||||
throw new Error("no rollout found for thread id archived-thread-id");
|
|
||||||
}
|
|
||||||
if (method === "thread/start") {
|
|
||||||
return { thread: { id: "replacement-empty-thread-id" } };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
|
|
||||||
"no rollout found for thread id archived-thread-id",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(session.currentThreadId).toBe("archived-thread-id");
|
|
||||||
expect(requests).toEqual([
|
|
||||||
{ method: "thread/loaded/list", params: {} },
|
|
||||||
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("appends blank-line spacing to /goal status messages", async () => {
|
test("appends blank-line spacing to /goal status messages", async () => {
|
||||||
const requests: Array<{ method: string; params: unknown }> = [];
|
const requests: Array<{ method: string; params: unknown }> = [];
|
||||||
const session = createSession({}, { goalsEnabled: true });
|
const session = createSession({}, { goalsEnabled: true });
|
||||||
@@ -2046,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" });
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentPermissionAction,
|
||||||
type AgentPermissionAction,
|
AgentCapabilityFlags,
|
||||||
type AgentCapabilityFlags,
|
AgentClient,
|
||||||
type AgentClient,
|
AgentCreateSessionOptions,
|
||||||
type AgentCreateSessionOptions,
|
AgentFeature,
|
||||||
type AgentFeature,
|
AgentLaunchContext,
|
||||||
type AgentLaunchContext,
|
AgentMode,
|
||||||
type AgentMode,
|
AgentModelDefinition,
|
||||||
type AgentModelDefinition,
|
McpServerConfig,
|
||||||
type McpServerConfig,
|
AgentPersistenceHandle,
|
||||||
type AgentPersistenceHandle,
|
AgentPermissionRequest,
|
||||||
type AgentPermissionRequest,
|
AgentPermissionResponse,
|
||||||
type AgentPermissionResponse,
|
AgentPermissionResult,
|
||||||
type AgentPermissionResult,
|
AgentPromptContentBlock,
|
||||||
type AgentPromptContentBlock,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentRunResult,
|
||||||
type AgentRunResult,
|
AgentRuntimeInfo,
|
||||||
type AgentRuntimeInfo,
|
AgentSession,
|
||||||
type AgentSession,
|
AgentSessionConfig,
|
||||||
type AgentSessionConfig,
|
AgentSlashCommand,
|
||||||
type AgentSlashCommand,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
AgentTimelineItem,
|
||||||
type AgentTimelineItem,
|
ToolCallTimelineItem,
|
||||||
type ToolCallTimelineItem,
|
AgentUsage,
|
||||||
type AgentUsage,
|
ListModelsOptions,
|
||||||
type ListModelsOptions,
|
ListPersistedAgentsOptions,
|
||||||
type ListPersistedAgentsOptions,
|
PersistedAgentDescriptor,
|
||||||
type PersistedAgentDescriptor,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
@@ -56,10 +55,7 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
|
|||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||||
import {
|
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||||
CodexAppServerClient,
|
|
||||||
type CodexAppServerTraceContext,
|
|
||||||
} from "./codex/app-server-transport.js";
|
|
||||||
import {
|
import {
|
||||||
renderProviderImageOutputAsAssistantMarkdown,
|
renderProviderImageOutputAsAssistantMarkdown,
|
||||||
type ProviderImageOutput,
|
type ProviderImageOutput,
|
||||||
@@ -178,16 +174,9 @@ interface CodexAppServerClientLike {
|
|||||||
|
|
||||||
interface CodexAppServerAgentDeps {
|
interface CodexAppServerAgentDeps {
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||||
customProvider?: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
extends: string;
|
|
||||||
};
|
|
||||||
customCodexConfig?: Record<string, unknown> | null;
|
|
||||||
_createCodexClient?: (
|
_createCodexClient?: (
|
||||||
child: ChildProcessWithoutNullStreams,
|
child: ChildProcessWithoutNullStreams,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
getTraceContext: () => CodexAppServerTraceContext,
|
|
||||||
) => CodexAppServerClientLike;
|
) => CodexAppServerClientLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2536,50 +2525,6 @@ function buildCodexAppServerInitializeParams(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const withoutTrailingSlashes = trimmed.replace(/\/+$/u, "");
|
|
||||||
if (withoutTrailingSlashes.endsWith("/v1")) {
|
|
||||||
return withoutTrailingSlashes;
|
|
||||||
}
|
|
||||||
return `${withoutTrailingSlashes}/v1`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCodexCustomProviderConfig(
|
|
||||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
|
||||||
customProvider: CodexAppServerAgentDeps["customProvider"],
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
if (customProvider?.extends !== CODEX_PROVIDER) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const baseUrl = runtimeSettings?.env?.OPENAI_BASE_URL;
|
|
||||||
if (typeof baseUrl !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedBaseUrl = normalizeOpenAICompatibleBaseUrl(baseUrl);
|
|
||||||
if (!normalizedBaseUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const providerConfig: Record<string, unknown> = {
|
|
||||||
name: customProvider.label,
|
|
||||||
base_url: normalizedBaseUrl,
|
|
||||||
wire_api: "responses",
|
|
||||||
};
|
|
||||||
if (runtimeSettings?.env?.OPENAI_API_KEY?.trim()) {
|
|
||||||
providerConfig.env_key = "OPENAI_API_KEY";
|
|
||||||
providerConfig.requires_openai_auth = false;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
model_provider: customProvider.id,
|
|
||||||
model_providers: {
|
|
||||||
[customProvider.id]: providerConfig,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CodexSubAgentCallState {
|
interface CodexSubAgentCallState {
|
||||||
callId: string;
|
callId: string;
|
||||||
toolCall: ToolCallTimelineItem;
|
toolCall: ToolCallTimelineItem;
|
||||||
@@ -2658,13 +2603,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private readonly deps: CodexAppServerAgentDeps = {},
|
private readonly deps: CodexAppServerAgentDeps = {},
|
||||||
private readonly ephemeral: boolean = false,
|
private readonly ephemeral: boolean = false,
|
||||||
private readonly goalsEnabled: boolean = false,
|
private readonly goalsEnabled: boolean = false,
|
||||||
private readonly agentId?: string,
|
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({
|
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
||||||
module: "agent",
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
agentId: this.agentId,
|
|
||||||
});
|
|
||||||
if (config.modeId === undefined) {
|
if (config.modeId === undefined) {
|
||||||
throw new Error("Codex agent requires modeId to be specified");
|
throw new Error("Codex agent requires modeId to be specified");
|
||||||
}
|
}
|
||||||
@@ -2701,7 +2641,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.connected) return;
|
if (this.connected) return;
|
||||||
const child = await this.spawnAppServer();
|
const child = await this.spawnAppServer();
|
||||||
this.client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
|
this.client = new CodexAppServerClient(child, this.logger);
|
||||||
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
||||||
this.registerRequestHandlers();
|
this.registerRequestHandlers();
|
||||||
|
|
||||||
@@ -2719,14 +2659,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.connected = true;
|
this.connected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private traceContext(): CodexAppServerTraceContext {
|
|
||||||
return {
|
|
||||||
agentId: this.agentId,
|
|
||||||
sessionId: this.currentThreadId ?? undefined,
|
|
||||||
turnId: this.activeForegroundTurnId ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadCollaborationModes(): Promise<void> {
|
private async loadCollaborationModes(): Promise<void> {
|
||||||
if (!this.client) return;
|
if (!this.client) return;
|
||||||
try {
|
try {
|
||||||
@@ -2747,16 +2679,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.trace(
|
this.logger.trace({ error }, "Failed to load collaboration modes");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
sessionId: this.currentThreadId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? undefined,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
"provider.codex.metadata.collaboration_modes_failed",
|
|
||||||
);
|
|
||||||
this.collaborationModes = [];
|
this.collaborationModes = [];
|
||||||
}
|
}
|
||||||
this.refreshResolvedCollaborationMode();
|
this.refreshResolvedCollaborationMode();
|
||||||
@@ -2788,16 +2711,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.cachedSkills = skills;
|
this.cachedSkills = skills;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.trace(
|
this.logger.trace({ error }, "Failed to load skills list");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
sessionId: this.currentThreadId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? undefined,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
"provider.codex.metadata.skills_failed",
|
|
||||||
);
|
|
||||||
this.cachedSkills = [];
|
this.cachedSkills = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2987,10 +2901,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
await this.client.request("thread/resume", params);
|
await this.client.request("thread/resume", params);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const threadId = this.currentThreadId;
|
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
this.currentThreadId = null;
|
||||||
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
|
await this.ensureThread();
|
||||||
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3674,9 +3587,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (this.config.extra?.codex) {
|
if (this.config.extra?.codex) {
|
||||||
Object.assign(innerConfig, this.config.extra.codex);
|
Object.assign(innerConfig, this.config.extra.codex);
|
||||||
}
|
}
|
||||||
if (this.deps.customCodexConfig) {
|
|
||||||
Object.assign(innerConfig, this.deps.customCodexConfig);
|
|
||||||
}
|
|
||||||
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
|
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3694,16 +3604,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private notifySubscribers(event: AgentStreamEvent): void {
|
private notifySubscribers(event: AgentStreamEvent): void {
|
||||||
const turnId = this.activeForegroundTurnId;
|
const turnId = this.activeForegroundTurnId;
|
||||||
const tagged = turnId ? { ...event, turnId } : event;
|
const tagged = turnId ? { ...event, turnId } : event;
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
sessionId: this.currentThreadId,
|
|
||||||
turnId: getAgentStreamEventTurnId(tagged),
|
|
||||||
event: tagged,
|
|
||||||
},
|
|
||||||
"provider.codex.event_emit",
|
|
||||||
);
|
|
||||||
for (const callback of this.subscribers) {
|
for (const callback of this.subscribers) {
|
||||||
try {
|
try {
|
||||||
callback(tagged);
|
callback(tagged);
|
||||||
@@ -3719,7 +3619,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private handleNotification(method: string, params: unknown): void {
|
private handleNotification(method: string, params: unknown): void {
|
||||||
const parsed = CodexNotificationSchema.parse({ method, params });
|
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||||
this.traceParsedNotification(method, params, parsed);
|
|
||||||
switch (parsed.kind) {
|
switch (parsed.kind) {
|
||||||
case "thread_started":
|
case "thread_started":
|
||||||
this.handleThreadStartedNotification(parsed);
|
this.handleThreadStartedNotification(parsed);
|
||||||
@@ -3776,25 +3675,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private traceParsedNotification(
|
|
||||||
method: string,
|
|
||||||
params: unknown,
|
|
||||||
parsed: z.infer<typeof CodexNotificationSchema>,
|
|
||||||
): void {
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
sessionId: this.currentThreadId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? undefined,
|
|
||||||
method,
|
|
||||||
params,
|
|
||||||
parsed,
|
|
||||||
},
|
|
||||||
"provider.codex.parsed_event",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null {
|
private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null {
|
||||||
if (!threadId || threadId === this.currentThreadId) {
|
if (!threadId || threadId === this.currentThreadId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -3906,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(
|
||||||
@@ -4387,17 +4270,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.warnedUnknownNotificationMethods.add(method);
|
this.warnedUnknownNotificationMethods.add(method);
|
||||||
this.logger.trace(
|
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
sessionId: this.currentThreadId,
|
|
||||||
turnId: this.activeForegroundTurnId ?? undefined,
|
|
||||||
method,
|
|
||||||
params,
|
|
||||||
},
|
|
||||||
"provider.codex.event_unhandled",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private warnInvalidNotificationPayload(method: string, params: unknown): void {
|
private warnInvalidNotificationPayload(method: string, params: unknown): void {
|
||||||
@@ -4658,16 +4531,6 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
private readonly deps: CodexAppServerAgentDeps = {},
|
private readonly deps: CodexAppServerAgentDeps = {},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private sessionDeps(): CodexAppServerAgentDeps {
|
|
||||||
return {
|
|
||||||
...this.deps,
|
|
||||||
customCodexConfig: buildCodexCustomProviderConfig(
|
|
||||||
this.runtimeSettings,
|
|
||||||
this.deps.customProvider,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveGoalsEnabled(): Promise<boolean> {
|
private resolveGoalsEnabled(): Promise<boolean> {
|
||||||
if (!this.goalsEnabledPromise) {
|
if (!this.goalsEnabledPromise) {
|
||||||
this.goalsEnabledPromise = (async () => {
|
this.goalsEnabledPromise = (async () => {
|
||||||
@@ -4675,14 +4538,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||||
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
|
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
|
||||||
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
|
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
|
||||||
this.logger.trace(
|
this.logger.trace({ versionOutput, enabled }, "Resolved codex goals feature gate");
|
||||||
{
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
versionOutput,
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
"provider.codex.config.goals_resolved",
|
|
||||||
);
|
|
||||||
return enabled;
|
return enabled;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
|
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
|
||||||
@@ -4695,7 +4551,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
|
|
||||||
private async spawnAppServer(
|
private async spawnAppServer(
|
||||||
launchEnv?: Record<string, string>,
|
launchEnv?: Record<string, string>,
|
||||||
options?: { goalsEnabled?: boolean; agentId?: string },
|
options?: { goalsEnabled?: boolean },
|
||||||
): Promise<ChildProcessWithoutNullStreams> {
|
): Promise<ChildProcessWithoutNullStreams> {
|
||||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||||
const args = [...launchPrefix.args, "app-server"];
|
const args = [...launchPrefix.args, "app-server"];
|
||||||
@@ -4704,12 +4560,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
agentId: options?.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
launchPrefix,
|
launchPrefix,
|
||||||
goalsEnabled: options?.goalsEnabled === true,
|
goalsEnabled: options?.goalsEnabled === true,
|
||||||
},
|
},
|
||||||
"provider.codex.spawn",
|
"Spawning Codex app server",
|
||||||
);
|
);
|
||||||
const child = spawnProcess(launchPrefix.command, args, {
|
const child = spawnProcess(launchPrefix.command, args, {
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
@@ -4741,12 +4595,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
sessionConfig,
|
sessionConfig,
|
||||||
null,
|
null,
|
||||||
this.logger,
|
this.logger,
|
||||||
() =>
|
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
this.deps,
|
||||||
this.sessionDeps(),
|
|
||||||
options?.persistSession === false,
|
options?.persistSession === false,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
launchContext?.agentId,
|
|
||||||
);
|
);
|
||||||
await session.connect();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
@@ -4769,12 +4621,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
merged,
|
merged,
|
||||||
handle,
|
handle,
|
||||||
this.logger,
|
this.logger,
|
||||||
() =>
|
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
this.deps,
|
||||||
this.sessionDeps(),
|
|
||||||
false,
|
false,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
launchContext?.agentId,
|
|
||||||
);
|
);
|
||||||
await session.connect();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
@@ -4785,7 +4635,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
): Promise<PersistedAgentDescriptor[]> {
|
): Promise<PersistedAgentDescriptor[]> {
|
||||||
const child = await this.spawnAppServer();
|
const child = await this.spawnAppServer();
|
||||||
const client =
|
const client =
|
||||||
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
|
this.deps._createCodexClient?.(child, this.logger) ??
|
||||||
new CodexAppServerClient(child, this.logger);
|
new CodexAppServerClient(child, this.logger);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,12 +35,6 @@ interface PendingRequest {
|
|||||||
type RequestHandler = (params: unknown) => unknown;
|
type RequestHandler = (params: unknown) => unknown;
|
||||||
type NotificationHandler = (method: string, params: unknown) => void;
|
type NotificationHandler = (method: string, params: unknown) => void;
|
||||||
|
|
||||||
export interface CodexAppServerTraceContext {
|
|
||||||
agentId?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
turnId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -60,24 +54,6 @@ function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
|
|||||||
return typeof msg.method === "string" && msg.id === undefined;
|
return typeof msg.method === "string" && msg.id === undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readProviderSessionId(params: unknown): string | undefined {
|
|
||||||
if (!isRecord(params)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return typeof params.threadId === "string" ? params.threadId : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readProviderTurnId(params: unknown): string | undefined {
|
|
||||||
if (!isRecord(params)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof params.turnId === "string") {
|
|
||||||
return params.turnId;
|
|
||||||
}
|
|
||||||
const turn = params.turn;
|
|
||||||
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CodexAppServerClient {
|
export class CodexAppServerClient {
|
||||||
private readonly rl: readline.Interface;
|
private readonly rl: readline.Interface;
|
||||||
private readonly pending = new Map<number, PendingRequest>();
|
private readonly pending = new Map<number, PendingRequest>();
|
||||||
@@ -90,7 +66,6 @@ export class CodexAppServerClient {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly child: ChildProcessWithoutNullStreams,
|
private readonly child: ChildProcessWithoutNullStreams,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
|
|
||||||
) {
|
) {
|
||||||
this.rl = readline.createInterface({ input: child.stdout });
|
this.rl = readline.createInterface({ input: child.stdout });
|
||||||
this.rl.on("line", (line) => {
|
this.rl.on("line", (line) => {
|
||||||
@@ -249,19 +224,6 @@ export class CodexAppServerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isJsonRpcNotification(raw)) {
|
if (isJsonRpcNotification(raw)) {
|
||||||
const traceContext = this.getTraceContext();
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
agentId: traceContext.agentId,
|
|
||||||
sessionId: traceContext.sessionId ?? readProviderSessionId(raw.params),
|
|
||||||
turnId: traceContext.turnId ?? readProviderTurnId(raw.params),
|
|
||||||
method: raw.method,
|
|
||||||
params: raw.params,
|
|
||||||
rawEvent: raw,
|
|
||||||
},
|
|
||||||
"provider.codex.raw_event",
|
|
||||||
);
|
|
||||||
this.notificationHandler?.(raw.method, raw.params);
|
this.notificationHandler?.(raw.method, raw.params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,39 +64,6 @@ describe("codex tool-call mapper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
|
|
||||||
const item = mapCodexToolCallFromThreadItem({
|
|
||||||
type: "commandExecution",
|
|
||||||
id: "codex-call-wrapper-pwsh-string",
|
|
||||||
status: "running",
|
|
||||||
command:
|
|
||||||
'"C:\\Users\\example\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe" -NoLogo -NoProfile -Command "echo hello"',
|
|
||||||
cwd: "C:\\repo",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(item?.detail).toEqual({
|
|
||||||
type: "shell",
|
|
||||||
command: "echo hello",
|
|
||||||
cwd: "C:\\repo",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unwraps cmd wrapper arrays for commandExecution on Windows", () => {
|
|
||||||
const item = mapCodexToolCallFromThreadItem({
|
|
||||||
type: "commandExecution",
|
|
||||||
id: "codex-call-wrapper-cmd-array",
|
|
||||||
status: "running",
|
|
||||||
command: ["cmd.exe", "/c", "echo hello"],
|
|
||||||
cwd: "C:\\repo",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(item?.detail).toEqual({
|
|
||||||
type: "shell",
|
|
||||||
command: "echo hello",
|
|
||||||
cwd: "C:\\repo",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
|
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
|
||||||
const item = mapCodexToolCallFromThreadItem({
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
type: "commandExecution",
|
type: "commandExecution",
|
||||||
|
|||||||
@@ -200,48 +200,21 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
|||||||
|
|
||||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||||
const trimmed = command.trim();
|
const trimmed = command.trim();
|
||||||
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||||
if (unixWrapperMatch) {
|
if (!wrapperMatch) {
|
||||||
const candidate = unixWrapperMatch[1]?.trim() ?? "";
|
|
||||||
if (!candidate) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return stripMatchingEdgeQuotes(candidate);
|
|
||||||
}
|
|
||||||
const windowsWrapperMatch = trimmed.match(
|
|
||||||
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i,
|
|
||||||
);
|
|
||||||
if (!windowsWrapperMatch) {
|
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? "";
|
const candidate = wrapperMatch[1]?.trim() ?? "";
|
||||||
if (!wrappedCommand) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i);
|
|
||||||
if (!commandMatch) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
const candidate = commandMatch[1]?.trim() ?? "";
|
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
return stripMatchingEdgeQuotes(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripMatchingEdgeQuotes(value: string): string {
|
|
||||||
if (
|
if (
|
||||||
(value.startsWith('"') && value.endsWith('"')) ||
|
(candidate.startsWith('"') && candidate.endsWith('"')) ||
|
||||||
(value.startsWith("'") && value.endsWith("'"))
|
(candidate.startsWith("'") && candidate.endsWith("'"))
|
||||||
) {
|
) {
|
||||||
return value.slice(1, -1);
|
return candidate.slice(1, -1);
|
||||||
}
|
}
|
||||||
return value;
|
return candidate;
|
||||||
}
|
|
||||||
|
|
||||||
function isWindowsShellCommand(command: string): boolean {
|
|
||||||
const normalized = command.replace(/^["']|["']$/g, "");
|
|
||||||
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
||||||
@@ -263,14 +236,6 @@ function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
|||||||
const unwrapped = parts[2]?.trim();
|
const unwrapped = parts[2]?.trim();
|
||||||
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
|
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
parts.length >= 3 &&
|
|
||||||
isWindowsShellCommand(parts[0] ?? "") &&
|
|
||||||
/^(-command|-c|\/c)$/i.test(parts[1] ?? "")
|
|
||||||
) {
|
|
||||||
const unwrapped = parts.slice(2).join(" ").trim();
|
|
||||||
return unwrapped.length > 0 ? stripMatchingEdgeQuotes(unwrapped) : undefined;
|
|
||||||
}
|
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
|
||||||
|
|
||||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
import { findExecutable } from "../../../utils/executable.js";
|
import { findExecutable } from "../../../utils/executable.js";
|
||||||
import {
|
import { ACPAgentClient } from "./acp-agent.js";
|
||||||
ACPAgentClient,
|
|
||||||
type ACPBeforeModeWriteResult,
|
|
||||||
type ACPProviderModeWriteResult,
|
|
||||||
type ACPProviderModeWriterContext,
|
|
||||||
type SessionStateResponse,
|
|
||||||
} from "./acp-agent.js";
|
|
||||||
import {
|
import {
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
@@ -29,31 +22,21 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
const COPILOT_MODES: AgentMode[] = [
|
||||||
const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
|
|
||||||
const COPILOT_AUTOPILOT_MODE_ID =
|
|
||||||
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
|
|
||||||
export const COPILOT_ALLOW_ALL_MODE_ID = "allow-all";
|
|
||||||
const COPILOT_ALLOW_ALL_CONFIG_ID = "allow_all";
|
|
||||||
const COPILOT_ALLOW_ALL_ON = "on";
|
|
||||||
const COPILOT_ALLOW_ALL_OFF = "off";
|
|
||||||
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
|
|
||||||
|
|
||||||
export const COPILOT_MODES: AgentMode[] = [
|
|
||||||
{
|
{
|
||||||
id: COPILOT_AGENT_MODE_ID,
|
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||||
label: "Agent",
|
label: "Agent",
|
||||||
description: "Default agent mode for conversational interactions",
|
description: "Default agent mode for conversational interactions",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: COPILOT_PLAN_MODE_ID,
|
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||||
label: "Plan",
|
label: "Plan",
|
||||||
description: "Plan mode for creating and executing multi-step plans",
|
description: "Plan mode for creating and executing multi-step plans",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: COPILOT_ALLOW_ALL_MODE_ID,
|
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||||
label: "Allow All",
|
label: "Autopilot",
|
||||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
description: "Autonomous mode that runs until task completion without user interaction",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -70,11 +53,6 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
|||||||
runtimeSettings: options.runtimeSettings,
|
runtimeSettings: options.runtimeSettings,
|
||||||
defaultCommand: ["copilot", "--acp"],
|
defaultCommand: ["copilot", "--acp"],
|
||||||
defaultModes: COPILOT_MODES,
|
defaultModes: COPILOT_MODES,
|
||||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
|
||||||
configOptionsTransformer: transformCopilotConfigOptions,
|
|
||||||
modeIdTransformer: transformCopilotModeId,
|
|
||||||
providerModeWriter: writeCopilotProviderMode,
|
|
||||||
beforeModeWriter: beforeCopilotModeWriter,
|
|
||||||
capabilities: COPILOT_CAPABILITIES,
|
capabilities: COPILOT_CAPABILITIES,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -135,126 +113,3 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformCopilotSessionResponse(
|
|
||||||
response: SessionStateResponse,
|
|
||||||
): SessionStateResponse {
|
|
||||||
if (!response.modes) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
const allowAllEnabled = isCopilotAllowAllEnabled(response.configOptions ?? []);
|
|
||||||
return {
|
|
||||||
...response,
|
|
||||||
modes: {
|
|
||||||
...response.modes,
|
|
||||||
availableModes: response.modes.availableModes
|
|
||||||
?.filter(
|
|
||||||
(mode) => mode.id !== COPILOT_AUTOPILOT_MODE_ID && mode.id !== COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
)
|
|
||||||
.concat({
|
|
||||||
id: COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
name: "Allow All",
|
|
||||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
|
||||||
}),
|
|
||||||
currentModeId: allowAllEnabled
|
|
||||||
? COPILOT_ALLOW_ALL_MODE_ID
|
|
||||||
: (transformCopilotModeId(response.modes.currentModeId ?? COPILOT_AGENT_MODE_ID) ??
|
|
||||||
COPILOT_AGENT_MODE_ID),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformCopilotConfigOptions(
|
|
||||||
configOptions: SessionConfigOption[],
|
|
||||||
): SessionConfigOption[] {
|
|
||||||
const allowAllEnabled = isCopilotAllowAllEnabled(configOptions);
|
|
||||||
return configOptions.map((option) => {
|
|
||||||
if (option.type !== "select" || option.category !== "mode") {
|
|
||||||
return option;
|
|
||||||
}
|
|
||||||
// Trust Copilot's allow_all config value as the source of truth when it changes in-process.
|
|
||||||
const options = flattenCopilotModeOptions(option.options)
|
|
||||||
.filter(
|
|
||||||
(choice) =>
|
|
||||||
choice.value !== COPILOT_AUTOPILOT_MODE_ID && choice.value !== COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
)
|
|
||||||
.concat({
|
|
||||||
value: COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
name: "Allow All",
|
|
||||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...option,
|
|
||||||
currentValue: allowAllEnabled
|
|
||||||
? COPILOT_ALLOW_ALL_MODE_ID
|
|
||||||
: (transformCopilotModeId(option.currentValue) ?? COPILOT_AGENT_MODE_ID),
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenCopilotModeOptions(
|
|
||||||
options: SelectConfigOption["options"],
|
|
||||||
): Array<{ value: string; name: string; description?: string | null }> {
|
|
||||||
const flattened: Array<{ value: string; name: string; description?: string | null }> = [];
|
|
||||||
for (const option of options) {
|
|
||||||
if ("value" in option) {
|
|
||||||
flattened.push(option);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
flattened.push(...option.options);
|
|
||||||
}
|
|
||||||
return flattened;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformCopilotModeId(modeId: string): string | null {
|
|
||||||
return modeId === COPILOT_AUTOPILOT_MODE_ID ? COPILOT_AGENT_MODE_ID : modeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeCopilotProviderMode(
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
): Promise<ACPProviderModeWriteResult> {
|
|
||||||
// COMPAT(copilotAutopilotMode): added in v0.1.75, remove after 2026-11-12 once old clients no longer send Copilot's old ACP autopilot mode ID.
|
|
||||||
const requestsAllowAll =
|
|
||||||
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID ||
|
|
||||||
context.requestedModeId === COPILOT_AUTOPILOT_MODE_ID;
|
|
||||||
if (!requestsAllowAll) {
|
|
||||||
return { handled: false };
|
|
||||||
}
|
|
||||||
const response = await context.connection.setSessionConfigOption({
|
|
||||||
sessionId: context.sessionId,
|
|
||||||
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
|
|
||||||
value: COPILOT_ALLOW_ALL_ON,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
handled: true,
|
|
||||||
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
|
|
||||||
configOptions: response.configOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function beforeCopilotModeWriter(
|
|
||||||
context: ACPProviderModeWriterContext,
|
|
||||||
): Promise<ACPBeforeModeWriteResult> {
|
|
||||||
if (
|
|
||||||
context.currentModeId !== COPILOT_ALLOW_ALL_MODE_ID ||
|
|
||||||
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID
|
|
||||||
) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const response = await context.connection.setSessionConfigOption({
|
|
||||||
sessionId: context.sessionId,
|
|
||||||
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
|
|
||||||
value: COPILOT_ALLOW_ALL_OFF,
|
|
||||||
});
|
|
||||||
return { configOptions: response.configOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCopilotAllowAllEnabled(configOptions: SessionConfigOption[]): boolean {
|
|
||||||
return configOptions.some(
|
|
||||||
(option) =>
|
|
||||||
option.type === "select" &&
|
|
||||||
option.id === COPILOT_ALLOW_ALL_CONFIG_ID &&
|
|
||||||
option.currentValue === COPILOT_ALLOW_ALL_ON,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,34 +13,33 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentCapabilityFlags,
|
||||||
type AgentCapabilityFlags,
|
AgentClient,
|
||||||
type AgentClient,
|
AgentCreateSessionOptions,
|
||||||
type AgentCreateSessionOptions,
|
AgentLaunchContext,
|
||||||
type AgentLaunchContext,
|
AgentMode,
|
||||||
type AgentMode,
|
AgentModelDefinition,
|
||||||
type AgentModelDefinition,
|
AgentPermissionRequest,
|
||||||
type AgentPermissionRequest,
|
AgentPermissionResponse,
|
||||||
type AgentPermissionResponse,
|
AgentPersistenceHandle,
|
||||||
type AgentPersistenceHandle,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentRunResult,
|
||||||
type AgentRunResult,
|
AgentRuntimeInfo,
|
||||||
type AgentRuntimeInfo,
|
AgentSession,
|
||||||
type AgentSession,
|
AgentSessionConfig,
|
||||||
type AgentSessionConfig,
|
AgentSlashCommand,
|
||||||
type AgentSlashCommand,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
AgentTimelineItem,
|
||||||
type AgentTimelineItem,
|
AgentUsage,
|
||||||
type AgentUsage,
|
ListModelsOptions,
|
||||||
type ListModelsOptions,
|
ListModesOptions,
|
||||||
type ListModesOptions,
|
ListPersistedAgentsOptions,
|
||||||
type ListPersistedAgentsOptions,
|
McpServerConfig,
|
||||||
type McpServerConfig,
|
PersistedAgentDescriptor,
|
||||||
type PersistedAgentDescriptor,
|
ToolCallDetail,
|
||||||
type ToolCallDetail,
|
ToolCallTimelineItem,
|
||||||
type ToolCallTimelineItem,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
@@ -75,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[] = [
|
||||||
{
|
{
|
||||||
@@ -821,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");
|
||||||
@@ -967,7 +950,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
launchContext?: AgentLaunchContext,
|
_launchContext?: AgentLaunchContext,
|
||||||
options?: AgentCreateSessionOptions,
|
options?: AgentCreateSessionOptions,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const openCodeConfig = this.assertConfig(config);
|
const openCodeConfig = this.assertConfig(config);
|
||||||
@@ -1001,11 +984,9 @@ 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,
|
||||||
launchContext?.agentId,
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
acquisition.release();
|
acquisition.release();
|
||||||
@@ -1016,7 +997,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
async resumeSession(
|
async resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
launchContext?: AgentLaunchContext,
|
_launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
@@ -1044,11 +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,
|
|
||||||
launchContext?.agentId,
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
acquisition.release();
|
acquisition.release();
|
||||||
@@ -1280,28 +1258,6 @@ export interface OpenCodeEventTranslationState {
|
|||||||
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenCodeTraceData {
|
|
||||||
turnId?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenCodeTraceMessage =
|
|
||||||
| "provider.opencode.prompt_async.start"
|
|
||||||
| "provider.opencode.prompt_async.response"
|
|
||||||
| "provider.opencode.prompt_async.throw"
|
|
||||||
| "provider.opencode.subscribe.start"
|
|
||||||
| "provider.opencode.subscribe.ready"
|
|
||||||
| "provider.opencode.stream.eof"
|
|
||||||
| "provider.opencode.turn.fail_eof"
|
|
||||||
| "provider.opencode.subscribe.error"
|
|
||||||
| "provider.opencode.raw_event"
|
|
||||||
| "provider.opencode.event.skip"
|
|
||||||
| "provider.opencode.parsed_event"
|
|
||||||
| "provider.opencode.parsed_event.skip_active"
|
|
||||||
| "provider.opencode.event.terminal"
|
|
||||||
| "provider.opencode.finish_foreground_turn"
|
|
||||||
| "provider.opencode.event_emit";
|
|
||||||
|
|
||||||
type OpenCodeToolPartEventPart = Extract<
|
type OpenCodeToolPartEventPart = Extract<
|
||||||
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||||
{ type: "tool" }
|
{ type: "tool" }
|
||||||
@@ -2166,24 +2122,6 @@ function createDeferred<T>(): Deferred<T> {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -2196,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;
|
||||||
@@ -2220,22 +2157,19 @@ 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,
|
||||||
private readonly agentId?: string,
|
|
||||||
) {
|
) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
this.logger = logger.child({ agentId: this.agentId });
|
this.logger = logger;
|
||||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||||
this.releaseServer = releaseServer ?? null;
|
this.releaseServer = releaseServer ?? null;
|
||||||
@@ -2289,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(
|
||||||
@@ -2310,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,
|
||||||
@@ -2357,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();
|
||||||
@@ -2497,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 () => {
|
||||||
this.traceOpenCode("provider.opencode.prompt_async.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,
|
||||||
@@ -2523,12 +2398,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||||
});
|
});
|
||||||
this.traceOpenCode("provider.opencode.prompt_async.response", {
|
|
||||||
turnId,
|
|
||||||
hasError: promptResponse.error !== undefined,
|
|
||||||
error: promptResponse.error,
|
|
||||||
data: promptResponse.data,
|
|
||||||
});
|
|
||||||
if (promptResponse.error) {
|
if (promptResponse.error) {
|
||||||
this.finishForegroundTurn(
|
this.finishForegroundTurn(
|
||||||
{
|
{
|
||||||
@@ -2540,13 +2409,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.traceOpenCode("provider.opencode.prompt_async.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",
|
||||||
@@ -2574,51 +2436,36 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
turnAbortController: AbortController,
|
turnAbortController: AbortController,
|
||||||
subscriptionReady: Deferred<void>,
|
subscriptionReady: Deferred<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.traceOpenCode("provider.opencode.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;
|
|
||||||
this.traceOpenCode("provider.opencode.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.traceOpenCode("provider.opencode.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) {
|
||||||
this.traceOpenCode("provider.opencode.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",
|
||||||
@@ -2629,11 +2476,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.traceOpenCode("provider.opencode.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(
|
||||||
@@ -2662,80 +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);
|
|
||||||
this.traceOpenCode("provider.opencode.raw_event", {
|
|
||||||
turnId,
|
|
||||||
n: eventCount,
|
|
||||||
type: event?.type,
|
|
||||||
rawType: readOpenCodeRecord(rawEvent)?.type,
|
|
||||||
directory: readOpenCodeRecord(rawEvent)?.directory,
|
|
||||||
rawEvent,
|
|
||||||
properties: event?.properties,
|
|
||||||
});
|
|
||||||
if (!event) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
|
|
||||||
this.traceOpenCode("provider.opencode.event.skip", {
|
|
||||||
turnId,
|
|
||||||
n: eventCount,
|
|
||||||
aborted: turnAbortController.signal.aborted,
|
|
||||||
activeTurnId: this.activeForegroundTurnId,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.armRetryFailureTimerForStatus(event, turnId);
|
|
||||||
const translated = await this.translateEvent(event);
|
|
||||||
this.traceOpenCode("provider.opencode.parsed_event", {
|
|
||||||
turnId,
|
|
||||||
n: eventCount,
|
|
||||||
count: translated.length,
|
|
||||||
types: translated.map((t) => t.type),
|
|
||||||
events: translated,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const e of translated) {
|
|
||||||
if (this.activeForegroundTurnId !== turnId) {
|
|
||||||
this.traceOpenCode("provider.opencode.parsed_event.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) {
|
|
||||||
this.traceOpenCode("provider.opencode.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 {
|
||||||
this.traceOpenCode("provider.opencode.finish_foreground_turn", {
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -2744,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();
|
||||||
@@ -2760,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" };
|
||||||
@@ -2829,10 +2562,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
|
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
|
||||||
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
|
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
|
||||||
const tagged = turnId ? { ...event, turnId } : event;
|
const tagged = turnId ? { ...event, turnId } : event;
|
||||||
this.traceOpenCode("provider.opencode.event_emit", {
|
|
||||||
turnId: getAgentStreamEventTurnId(tagged),
|
|
||||||
event: tagged,
|
|
||||||
});
|
|
||||||
for (const callback of this.subscribers) {
|
for (const callback of this.subscribers) {
|
||||||
try {
|
try {
|
||||||
callback(tagged);
|
callback(tagged);
|
||||||
@@ -2846,19 +2575,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return `opencode-turn-${this.nextTurnOrdinal++}`;
|
return `opencode-turn-${this.nextTurnOrdinal++}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
|
|
||||||
this.logger.trace(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: "opencode",
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
turnId: data.turnId ?? this.activeForegroundTurnId ?? undefined,
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
msg,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||||
const response = await this.client.session.messages({
|
const response = await this.client.session.messages({
|
||||||
sessionID: this.sessionId,
|
sessionID: this.sessionId,
|
||||||
|
|||||||
-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 () => ({}),
|
||||||
|
|||||||
@@ -30,30 +30,29 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|||||||
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentCapabilityFlags,
|
||||||
type AgentCapabilityFlags,
|
AgentClient,
|
||||||
type AgentClient,
|
AgentLaunchContext,
|
||||||
type AgentLaunchContext,
|
AgentMetadata,
|
||||||
type AgentMetadata,
|
AgentMode,
|
||||||
type AgentMode,
|
AgentModelDefinition,
|
||||||
type AgentModelDefinition,
|
AgentPermissionRequest,
|
||||||
type AgentPermissionRequest,
|
AgentPermissionResponse,
|
||||||
type AgentPermissionResponse,
|
AgentPersistenceHandle,
|
||||||
type AgentPersistenceHandle,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentRunResult,
|
||||||
type AgentRunResult,
|
AgentRuntimeInfo,
|
||||||
type AgentRuntimeInfo,
|
AgentSession,
|
||||||
type AgentSession,
|
AgentSessionConfig,
|
||||||
type AgentSessionConfig,
|
AgentSlashCommand,
|
||||||
type AgentSlashCommand,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
AgentTimelineItem,
|
||||||
type AgentTimelineItem,
|
AgentUsage,
|
||||||
type AgentUsage,
|
ListModesOptions,
|
||||||
type ListModesOptions,
|
ListModelsOptions,
|
||||||
type ListModelsOptions,
|
ToolCallDetail,
|
||||||
type ToolCallDetail,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
@@ -739,6 +738,21 @@ function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersis
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStreamEventTurnId(event: AgentStreamEvent): string | undefined {
|
||||||
|
switch (event.type) {
|
||||||
|
case "turn_started":
|
||||||
|
case "turn_completed":
|
||||||
|
case "turn_failed":
|
||||||
|
case "turn_canceled":
|
||||||
|
case "timeline":
|
||||||
|
case "permission_requested":
|
||||||
|
case "permission_resolved":
|
||||||
|
return event.turnId;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isPiRequestAbortError(error: unknown): boolean {
|
function isPiRequestAbortError(error: unknown): boolean {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
return true;
|
return true;
|
||||||
@@ -1031,7 +1045,7 @@ export class PiDirectAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
const eventTurnId = getStreamEventTurnId(event);
|
||||||
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentRunResult,
|
||||||
type AgentRunResult,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
AgentTimelineItem,
|
||||||
type AgentTimelineItem,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
|
|
||||||
export type ProviderFinalTextReducer = (params: {
|
export type ProviderFinalTextReducer = (params: {
|
||||||
@@ -45,7 +44,7 @@ export async function runProviderTurn({
|
|||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
const eventTurnId = "turnId" in event ? event.turnId : undefined;
|
||||||
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import {
|
import type {
|
||||||
getAgentStreamEventTurnId,
|
AgentPromptInput,
|
||||||
type AgentPromptInput,
|
AgentRunOptions,
|
||||||
type AgentRunOptions,
|
AgentSession,
|
||||||
type AgentSession,
|
AgentStreamEvent,
|
||||||
type AgentStreamEvent,
|
|
||||||
} from "../../agent-sdk-types.js";
|
} from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||||
@@ -30,7 +29,7 @@ export async function* streamSession(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const matchesTurn = (event: AgentStreamEvent): boolean => {
|
const matchesTurn = (event: AgentStreamEvent): boolean => {
|
||||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
const eventTurnId = (event as { turnId?: string }).turnId;
|
||||||
return turnId == null || eventTurnId == null || eventTurnId === turnId;
|
return turnId == null || eventTurnId == null || eventTurnId === turnId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user