mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
60 Commits
refactor-t
...
v0.1.75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d9563352 | ||
|
|
045b373168 | ||
|
|
985ad52cce | ||
|
|
d198c68b9e | ||
|
|
751a07124f | ||
|
|
defb4f82f7 | ||
|
|
4570e65ce8 | ||
|
|
db0d63dd90 | ||
|
|
1a8fdcd388 | ||
|
|
417abed6a5 | ||
|
|
6afdeef84a | ||
|
|
77c82dfdbd | ||
|
|
29277c900d | ||
|
|
ed1943058a | ||
|
|
e0361ddd22 | ||
|
|
ce9474055e | ||
|
|
8f9b4c8828 | ||
|
|
af4e0de9ab | ||
|
|
3acc71b8ad | ||
|
|
0759932dad | ||
|
|
95f45e4e2b | ||
|
|
a5c2b97e1d | ||
|
|
d32462e9ee | ||
|
|
33262843a5 | ||
|
|
1cd02a0e1a | ||
|
|
40ab9e3f20 | ||
|
|
4141c76258 | ||
|
|
7f44323686 | ||
|
|
152b07b599 | ||
|
|
84f36d2e20 | ||
|
|
25d4c5023a | ||
|
|
3f5acfff31 | ||
|
|
b9940e285c | ||
|
|
9993c6c6c3 | ||
|
|
3b7971a463 | ||
|
|
d75d2d857d | ||
|
|
cab42985a5 | ||
|
|
ef892bd27d | ||
|
|
3014576c4c | ||
|
|
b8c77bf0e3 | ||
|
|
17073fe8ff | ||
|
|
6220b47073 | ||
|
|
bf7f8f686b | ||
|
|
93cd4734ce | ||
|
|
e4acd6cb7a | ||
|
|
ca11fc667b | ||
|
|
36e54a097e | ||
|
|
ecd3137d34 | ||
|
|
f881f9ae32 | ||
|
|
3f6b84899a | ||
|
|
5e64a1340c | ||
|
|
2d0ed004e2 | ||
|
|
183cda2b66 | ||
|
|
ed2a97fda8 | ||
|
|
2fed0f09bb | ||
|
|
478aa4b70e | ||
|
|
fd74abcdca | ||
|
|
444e265275 | ||
|
|
2ee9329663 | ||
|
|
b30aafc2bd |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -5,8 +5,13 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
72
.github/workflows/nix-build.yml
vendored
72
.github/workflows/nix-build.yml
vendored
@@ -1,72 +0,0 @@
|
||||
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
|
||||
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
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
|
||||
99
.github/workflows/nix.yml
vendored
Normal file
99
.github/workflows/nix.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,5 +1,55 @@
|
||||
# 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
|
||||
|
||||
### Added
|
||||
@@ -23,6 +73,7 @@
|
||||
- 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))
|
||||
- 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
|
||||
|
||||
|
||||
@@ -59,6 +59,30 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `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
|
||||
|
||||
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
providers: Record<providerId, ProviderOverride>
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
|
||||
@@ -39,7 +39,14 @@ In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
`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
|
||||
|
||||
|
||||
49
docs/opencode-global-event-baseline.md
Normal file
49
docs/opencode-global-event-baseline.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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,11 +26,18 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
isLinux = nixpkgs.lib.elem system [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
|
||||
157
nix/desktop-package.nix
Normal file
157
nix/desktop-package.nix
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,48 @@ in
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
description = ''
|
||||
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\"`.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,9 +152,50 @@ in
|
||||
'';
|
||||
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") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
@@ -131,6 +213,10 @@ in
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
@@ -149,6 +235,9 @@ in
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && 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;
|
||||
|
||||
serviceConfig = {
|
||||
@@ -172,5 +261,6 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
1
nix/npm-deps.hash
Normal file
1
nix/npm-deps.hash
Normal file
@@ -0,0 +1 @@
|
||||
sha256-LczD9EmK6LuaJuZQu1v/q8zBE92LVynRR85dp6IdfCo=
|
||||
@@ -7,6 +7,15 @@
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
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 {
|
||||
@@ -40,9 +49,9 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
|
||||
# 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).
|
||||
|
||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -9397,12 +9397,6 @@
|
||||
"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": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -21775,6 +21769,18 @@
|
||||
"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": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
||||
@@ -38854,7 +38860,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38892,6 +38898,7 @@
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
@@ -38980,10 +38987,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.71",
|
||||
"@getpaseo/server": "0.1.75",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39026,7 +39033,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -39075,7 +39082,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -39111,7 +39118,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -39137,7 +39144,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39152,18 +39159,18 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.71",
|
||||
"@getpaseo/relay": "0.1.71",
|
||||
"@getpaseo/highlight": "0.1.75",
|
||||
"@getpaseo/relay": "0.1.75",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.2",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
@@ -39344,6 +39351,15 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -39691,7 +39707,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click();
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click({ force: true });
|
||||
await page.getByRole("button", { name: "Close menu" }).click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -63,6 +63,7 @@
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
|
||||
type EscHandler = () => void;
|
||||
const escStack: EscHandler[] = [];
|
||||
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||
function AdaptiveTextInput(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile && isNative) {
|
||||
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
@@ -443,7 +443,7 @@ function ProviderSearchInput({
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
|
||||
|
||||
@@ -791,10 +791,13 @@ function DesktopSidebar({
|
||||
|
||||
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
|
||||
[resizeAnimatedStyle, insetsTop],
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
||||
[resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
|
||||
const resizeHandleStyle = useMemo(
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
shouldShowCustomComboboxOption,
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
@@ -148,7 +148,7 @@ export function SearchInput({
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
|
||||
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
85
packages/app/src/components/worktree-setup-callout-policy.ts
Normal file
85
packages/app/src/components/worktree-setup-callout-policy.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* @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,48 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
interface ActiveGitWorkspaceProject {
|
||||
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;
|
||||
}
|
||||
import {
|
||||
buildWorktreeSetupCalloutPolicy,
|
||||
selectActiveGitWorkspaceProject,
|
||||
shouldShowWorktreeSetupCallout,
|
||||
} from "./worktree-setup-callout-policy";
|
||||
|
||||
export function WorktreeSetupCalloutSource() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
@@ -58,7 +26,7 @@ export function WorktreeSetupCalloutSource() {
|
||||
if (!activeProject) {
|
||||
return;
|
||||
}
|
||||
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
|
||||
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute);
|
||||
});
|
||||
|
||||
const readQuery = useQuery({
|
||||
@@ -73,29 +41,31 @@ export function WorktreeSetupCalloutSource() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const shouldShow =
|
||||
activeProject !== null &&
|
||||
readQuery.data?.ok === true &&
|
||||
!hasSetupCommands(readQuery.data.config ?? {});
|
||||
const calloutPolicy = useMemo(
|
||||
() =>
|
||||
activeProject && shouldShowWorktreeSetupCallout(readQuery.data)
|
||||
? buildWorktreeSetupCalloutPolicy(activeProject)
|
||||
: null,
|
||||
[activeProject, readQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow || !activeProject) {
|
||||
if (!calloutPolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
return callouts.show({
|
||||
id: `worktree-setup-missing:${activeProject.projectKey}`,
|
||||
dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
id: calloutPolicy.id,
|
||||
dismissalKey: calloutPolicy.dismissalKey,
|
||||
priority: calloutPolicy.priority,
|
||||
title: calloutPolicy.title,
|
||||
description: calloutPolicy.description,
|
||||
actions: [
|
||||
{ label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
|
||||
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" },
|
||||
],
|
||||
testID: `worktree-setup-callout-${activeProject.projectKey}`,
|
||||
testID: calloutPolicy.testID,
|
||||
});
|
||||
}, [activeProject, callouts, openProjectSettings, shouldShow]);
|
||||
}, [calloutPolicy, callouts, openProjectSettings]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* @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,27 +8,24 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
SidebarCallout,
|
||||
type SidebarCalloutAction,
|
||||
type SidebarCalloutProps,
|
||||
type SidebarCalloutVariant,
|
||||
} from "@/components/sidebar-callout";
|
||||
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
clearSidebarCallouts,
|
||||
createSidebarCalloutState,
|
||||
dismissSidebarCallout,
|
||||
loadDismissedCalloutKeys,
|
||||
parseDismissedCalloutKeys,
|
||||
selectActiveSidebarCallout,
|
||||
serializeDismissedCalloutKeys,
|
||||
showSidebarCallout,
|
||||
type SidebarCalloutEntry,
|
||||
type SidebarCalloutOptions,
|
||||
type SidebarCalloutState,
|
||||
unregisterSidebarCallout,
|
||||
} 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 type { SidebarCalloutOptions } from "./sidebar-callout-state";
|
||||
|
||||
export interface SidebarCalloutsApi {
|
||||
show: (callout: SidebarCalloutOptions) => () => void;
|
||||
@@ -36,96 +33,48 @@ export interface SidebarCalloutsApi {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
type SidebarCalloutEntry = SidebarCalloutOptions & {
|
||||
order: number;
|
||||
priority: number;
|
||||
token: number;
|
||||
};
|
||||
|
||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
||||
|
||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | 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 {
|
||||
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).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);
|
||||
void AsyncStorage.setItem(
|
||||
DISMISSED_CALLOUTS_STORAGE_KEY,
|
||||
serializeDismissedCalloutKeys(keys),
|
||||
).catch((error) => {
|
||||
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
||||
});
|
||||
|
||||
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 }) {
|
||||
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
|
||||
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
|
||||
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
|
||||
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
|
||||
const dismissedKeysRef = useRef<Set<string>>(new Set());
|
||||
const orderRef = useRef(0);
|
||||
const tokenRef = useRef(0);
|
||||
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
|
||||
const stateRef = useRef<SidebarCalloutState>(state);
|
||||
|
||||
function commitState(next: SidebarCalloutState): void {
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
|
||||
.then((value) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const nextKeys = parseDismissedCalloutKeys(value);
|
||||
dismissedKeysRef.current = nextKeys;
|
||||
setDismissedKeys(nextKeys);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
async function loadDismissedKeys(): Promise<void> {
|
||||
let dismissedKeys: ReadonlySet<string>;
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
|
||||
dismissedKeys = parseDismissedCalloutKeys(value);
|
||||
} catch (error) {
|
||||
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setDismissalStorageLoaded(true);
|
||||
}
|
||||
});
|
||||
dismissedKeys = stateRef.current.dismissedKeys;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
|
||||
}
|
||||
}
|
||||
|
||||
void loadDismissedKeys();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
@@ -133,60 +82,33 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const show = useStableEvent((callout: SidebarCalloutOptions) => {
|
||||
tokenRef.current += 1;
|
||||
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);
|
||||
const result = showSidebarCallout(stateRef.current, callout);
|
||||
commitState(result.state);
|
||||
|
||||
return () => {
|
||||
const updated = calloutsRef.current.filter(
|
||||
(entry) => entry.id !== callout.id || entry.token !== token,
|
||||
commitState(
|
||||
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
|
||||
);
|
||||
calloutsRef.current = updated;
|
||||
setCallouts(updated);
|
||||
};
|
||||
});
|
||||
|
||||
const dismiss = useStableEvent((id: string) => {
|
||||
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
|
||||
const next = calloutsRef.current.filter((entry) => entry.id !== id);
|
||||
calloutsRef.current = next;
|
||||
setCallouts(next);
|
||||
const result = dismissSidebarCallout(stateRef.current, id);
|
||||
commitState(result.state);
|
||||
|
||||
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
|
||||
if (dismissalKey) {
|
||||
const nextKeys = new Set(dismissedKeysRef.current);
|
||||
nextKeys.add(dismissalKey);
|
||||
dismissedKeysRef.current = nextKeys;
|
||||
setDismissedKeys(nextKeys);
|
||||
persistDismissedCalloutKeys(nextKeys);
|
||||
if (result.dismissalKey) {
|
||||
persistDismissedCalloutKeys(result.state.dismissedKeys);
|
||||
}
|
||||
|
||||
dismissed?.onDismiss?.();
|
||||
result.dismissedCallout?.onDismiss?.();
|
||||
});
|
||||
|
||||
const clear = useStableEvent(() => {
|
||||
calloutsRef.current = [];
|
||||
setCallouts([]);
|
||||
commitState(clearSidebarCallouts(stateRef.current));
|
||||
});
|
||||
|
||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||
const activeCallout = useMemo(
|
||||
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
|
||||
[callouts, dismissedKeys, dismissalStorageLoaded],
|
||||
);
|
||||
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
||||
|
||||
return (
|
||||
<SidebarCalloutApiContext.Provider value={api}>
|
||||
|
||||
136
packages/app/src/contexts/sidebar-callout-state.test.ts
Normal file
136
packages/app/src/contexts/sidebar-callout-state.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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"]));
|
||||
});
|
||||
});
|
||||
162
packages/app/src/contexts/sidebar-callout-state.ts
Normal file
162
packages/app/src/contexts/sidebar-callout-state.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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,9 +148,16 @@ describe("keyboard-shortcuts", () => {
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on desktop via Alt+digit",
|
||||
name: "matches tab index jump on mac desktop via Cmd+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 },
|
||||
context: { isDesktop: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
@@ -333,6 +340,11 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "t", code: "KeyT", ctrlKey: 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",
|
||||
event: { key: "k", code: "KeyK", ctrlKey: true },
|
||||
@@ -477,16 +489,17 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
|
||||
"workspace-tab-close-current": ["meta", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shows Ctrl+W close tab for non-mac desktop",
|
||||
name: "uses non-mac desktop defaults for tab jump and close tab",
|
||||
context: { isMac: false, isDesktop: true },
|
||||
expectedKeys: {
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-close-current": ["ctrl", "W"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -292,11 +292,24 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
|
||||
// --- 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",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Alt+Digit",
|
||||
when: { desktop: true, commandCenter: false },
|
||||
when: { mac: false, desktop: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
|
||||
40
packages/app/src/polyfills/crypto.test.ts
Normal file
40
packages/app/src/polyfills/crypto.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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,8 +13,26 @@ interface MutableGlobal {
|
||||
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 {
|
||||
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).
|
||||
// Hermes may not provide them in all configurations.
|
||||
@@ -47,17 +65,21 @@ export function polyfillCrypto(): void {
|
||||
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") {
|
||||
g.crypto.randomUUID = () =>
|
||||
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
|
||||
g.crypto.randomUUID = () => createUuidV4(fillRandomValues);
|
||||
}
|
||||
|
||||
if (typeof g.crypto.getRandomValues !== "function") {
|
||||
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;
|
||||
};
|
||||
g.crypto.getRandomValues = fillRandomValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,15 @@ 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(
|
||||
latencyByConnectionId: Record<string, number | Error>,
|
||||
createdClients: FakeDaemonClient[],
|
||||
@@ -1734,6 +1743,41 @@ describe("HostRuntimeStore", () => {
|
||||
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 () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
|
||||
@@ -1506,10 +1506,12 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
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({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls: offer.relay.useTls,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
label,
|
||||
});
|
||||
|
||||
@@ -720,9 +720,16 @@ function SettingsSidebar({
|
||||
}, [hosts, localServerId]);
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||
const insets = useSafeAreaInsets();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const isDesktop = layout === "desktop";
|
||||
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
|
||||
isDesktop ? { paddingTop: insets.top } : null,
|
||||
],
|
||||
[insets.top, isDesktop],
|
||||
);
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
|
||||
99
packages/app/src/screens/workspace/terminals/state.test.ts
Normal file
99
packages/app/src/screens/workspace/terminals/state.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/app/src/screens/workspace/terminals/state.ts
Normal file
106
packages/app/src/screens/workspace/terminals/state.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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 { useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -94,8 +94,6 @@ import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
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 { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -158,10 +156,12 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
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 EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
@@ -1028,8 +1028,6 @@ function WorkspaceHeaderTitleBar({
|
||||
);
|
||||
}
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
type PaneDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
function parsePaneDirection(actionId: string): PaneDirection | null {
|
||||
@@ -1180,39 +1178,6 @@ 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 {
|
||||
const trimmed = host?.label?.trim();
|
||||
return trimmed ? trimmed : fallback;
|
||||
@@ -1359,17 +1324,6 @@ function shouldShowWorkspaceExplorerSidebar(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 {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
@@ -1377,6 +1331,108 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
|
||||
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({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -1405,7 +1461,6 @@ function WorkspaceScreenContent({
|
||||
scopeKey: workspaceTerminalScopeKey,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const workspaceAuthority = useMemo(
|
||||
@@ -1430,12 +1485,19 @@ function WorkspaceScreenContent({
|
||||
useProvidersSnapshot(normalizedServerId, {
|
||||
enabled: isRouteFocused,
|
||||
});
|
||||
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
||||
paneId?: string;
|
||||
} | null>(null);
|
||||
const canCreateTerminalNow = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
|
||||
const persistenceKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[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(
|
||||
@@ -1449,167 +1511,56 @@ function WorkspaceScreenContent({
|
||||
workspaceAgentVisibilityEqual,
|
||||
);
|
||||
|
||||
const terminalsQueryKey = useMemo(
|
||||
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
);
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: terminalsQueryKey,
|
||||
enabled: canCreateTerminalNow,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
const {
|
||||
handleTerminalCreated,
|
||||
handleScriptTerminalSelected,
|
||||
handleWorkspacePathUnavailable,
|
||||
handleTerminalCreateQueued,
|
||||
} = useWorkspaceTerminalTabActions({
|
||||
persistenceKey,
|
||||
focusWorkspacePane,
|
||||
openWorkspaceTabFocused,
|
||||
toast,
|
||||
});
|
||||
const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.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 terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(
|
||||
reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
|
||||
);
|
||||
}, [liveTerminalIds, terminalsDataUpdatedAt]);
|
||||
const knownTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(liveTerminalIds);
|
||||
for (const terminalId of pendingScriptTerminalIds.keys()) {
|
||||
terminalIds.add(terminalId);
|
||||
}
|
||||
return Array.from(terminalIds);
|
||||
}, [liveTerminalIds, pendingScriptTerminalIds]);
|
||||
const scriptTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(pendingScriptTerminalIds.keys());
|
||||
for (const script of workspaceDescriptor?.scripts ?? []) {
|
||||
if (script.terminalId) {
|
||||
terminalIds.add(script.terminalId);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
createMutation: createTerminalMutation,
|
||||
createTerminal,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
invalidateTerminals,
|
||||
killMutation: killTerminalMutation,
|
||||
knownTerminalIds,
|
||||
liveTerminalIds,
|
||||
pendingCreateInput: pendingTerminalCreateInput,
|
||||
query: terminalsQuery,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
} = useWorkspaceTerminals({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreated: handleTerminalCreated,
|
||||
onScriptTerminalSelected: handleScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
|
||||
onTerminalCreateQueued: handleTerminalCreateQueued,
|
||||
});
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
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 { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
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(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
|
||||
);
|
||||
@@ -1618,30 +1569,6 @@ function WorkspaceScreenContent({
|
||||
workspace: workspaceDescriptor,
|
||||
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({
|
||||
isCheckoutStatusLoading,
|
||||
isError: checkoutQuery.isError,
|
||||
@@ -1738,15 +1665,6 @@ function WorkspaceScreenContent({
|
||||
return () => handler.remove();
|
||||
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
|
||||
|
||||
const persistenceKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
|
||||
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
||||
);
|
||||
@@ -1761,7 +1679,6 @@ function WorkspaceScreenContent({
|
||||
[workspaceLayout],
|
||||
);
|
||||
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
||||
(state) => state.openTabInBackground,
|
||||
);
|
||||
@@ -1777,39 +1694,6 @@ function WorkspaceScreenContent({
|
||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
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 resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
@@ -2200,24 +2084,7 @@ function WorkspaceScreenContent({
|
||||
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
|
||||
);
|
||||
|
||||
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 handleCreateTerminal = useStableEvent(createTerminal);
|
||||
|
||||
const handleCreateBrowserTab = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
@@ -2284,10 +2151,7 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(
|
||||
terminalsQueryKey,
|
||||
removeTerminalFromPayload(terminalId),
|
||||
);
|
||||
removeTerminalFromCache(terminalId);
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
@@ -2297,18 +2161,16 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
}
|
||||
|
||||
void killTerminalAsync(terminalId).catch(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
});
|
||||
void killTerminalAsync(terminalId).catch(invalidateTerminals);
|
||||
});
|
||||
},
|
||||
[
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
invalidateTerminals,
|
||||
killTerminalAsync,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQueryKey,
|
||||
removeTerminalFromCache,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import invariant from "tiny-invariant";
|
||||
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 {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
@@ -86,7 +88,7 @@ interface InsertSplitInternalInput {
|
||||
targetPaneId: string;
|
||||
tabId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
}
|
||||
|
||||
interface InsertSplitInternalResult {
|
||||
@@ -142,7 +144,7 @@ interface SplitPaneInLayoutInput {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
maxTreeDepth: number;
|
||||
}
|
||||
|
||||
@@ -155,7 +157,7 @@ interface SplitPaneEmptyInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
maxTreeDepth: number;
|
||||
}
|
||||
|
||||
@@ -232,14 +234,6 @@ function normalizeTabIds(list: unknown): string[] {
|
||||
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: {
|
||||
id: string;
|
||||
tabs?: WorkspaceTab[];
|
||||
@@ -996,13 +990,16 @@ export function insertSplit(
|
||||
targetPaneId: string,
|
||||
tabId: string,
|
||||
position: "left" | "right" | "top" | "bottom",
|
||||
createNodeId: (
|
||||
prefix: WorkspaceLayoutNodeIdPrefix,
|
||||
) => string = defaultWorkspaceLayoutIds.createNodeId,
|
||||
): SplitNode {
|
||||
return insertSplitInternal({
|
||||
root: asInternalNode(root),
|
||||
targetPaneId,
|
||||
tabId,
|
||||
position,
|
||||
createNodeId: generateNodeId,
|
||||
createNodeId,
|
||||
}).root;
|
||||
}
|
||||
|
||||
|
||||
17
packages/app/src/stores/workspace-layout-ids.ts
Normal file
17
packages/app/src/stores/workspace-layout-ids.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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,6 +20,7 @@ import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createWorkspaceLayoutStore,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
insertSplit,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -36,6 +36,40 @@ import {
|
||||
const SERVER_ID = "server-1";
|
||||
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 {
|
||||
return {
|
||||
tabId,
|
||||
@@ -168,13 +202,14 @@ describe("workspace-layout-store helpers", () => {
|
||||
|
||||
describe("workspace-layout-store tree transforms", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
workspaceLayoutIds.reset();
|
||||
});
|
||||
|
||||
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
useWorkspaceLayoutIds(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
);
|
||||
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
@@ -189,7 +224,7 @@ describe("workspace-layout-store tree transforms", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId);
|
||||
const nextGroup = expectGroup(nextRoot);
|
||||
const nestedGroup = expectGroup(nextGroup.group.children[1]);
|
||||
|
||||
@@ -270,22 +305,20 @@ describe("workspace-layout-store tree transforms", () => {
|
||||
|
||||
describe("workspace-layout-store actions", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({
|
||||
workspaceLayoutIds.reset();
|
||||
workspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
);
|
||||
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -308,7 +341,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/b.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
||||
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
|
||||
@@ -322,14 +355,14 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabInBackground inserts a tab without stealing focus", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const setupTabId = store.openTabInBackground(workspaceKey, {
|
||||
kind: "setup",
|
||||
workspaceId: "ws-main",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layout.root, "main")!;
|
||||
|
||||
expect(agentTabId).toBe("agent_agent-1");
|
||||
@@ -341,7 +374,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabInBackground on an existing target is a no-op", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -355,7 +388,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
});
|
||||
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layoutAfter.root, "main")!;
|
||||
|
||||
expect(duplicateTabId).toBe(firstTabId);
|
||||
@@ -365,27 +398,25 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("unfocuses and restores the previous focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const token = store.unfocusPane(workspaceKey);
|
||||
expect(token).toBeTruthy();
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore stale focus after another pane is focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.splitPane(workspaceKey, {
|
||||
@@ -399,14 +430,14 @@ describe("workspace-layout-store actions", () => {
|
||||
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for nested focus restorations before restoring", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const outerToken = store.unfocusPane(workspaceKey);
|
||||
@@ -414,22 +445,22 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.restorePaneFocus(workspaceKey, outerToken!);
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, innerToken!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstTabId).toBe("draft-1");
|
||||
expect(secondTabId).toBe("draft-2");
|
||||
@@ -450,11 +481,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
);
|
||||
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const newPaneId = store.splitPaneEmpty(workspaceKey, {
|
||||
@@ -465,7 +494,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "draft",
|
||||
draftId: "draft-split",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
||||
expect(draftTabId).toBe("draft-split");
|
||||
@@ -476,11 +505,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const fileTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -497,22 +524,20 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusTab(workspaceKey, fileTabId!);
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusTab(workspaceKey, terminalTabId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"12121212-1212-1212-1212-121212121212",
|
||||
);
|
||||
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
@@ -523,7 +548,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const splitPane = findPaneById(layout.root, splitPaneId);
|
||||
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
||||
|
||||
@@ -540,7 +565,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("retargetTab keeps a draft tab in place while updating its target", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -550,7 +575,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/retargeted.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(draftTabId).toBe("draft-retarget");
|
||||
expect(nextTabId).toBe(draftTabId);
|
||||
@@ -565,11 +590,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"55555555-5555-5555-5555-555555555555",
|
||||
);
|
||||
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const existingFileTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -590,7 +613,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/existing.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
||||
expect(draftTabId).toBe("draft-dup");
|
||||
@@ -606,7 +629,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("retargetTab closes a draft tab and focuses an existing matching target tab", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstDraftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -625,7 +648,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstAgentTabId).toBe(firstDraftTabId);
|
||||
expect(nextTabId).toBe(firstDraftTabId);
|
||||
@@ -641,7 +664,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reorderTabs reorders tabs within the focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -657,7 +680,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(findPaneById(layout.root, "main")).toEqual({
|
||||
id: "main",
|
||||
@@ -684,11 +707,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"34343434-3434-3434-3434-343434343434",
|
||||
);
|
||||
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
store.openTabFocused(workspaceKey, {
|
||||
@@ -712,7 +733,7 @@ describe("workspace-layout-store actions", () => {
|
||||
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
||||
store.focusPane(workspaceKey, "main");
|
||||
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
@@ -736,11 +757,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("focusPane switches workspace focus to a different pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"56565656-5656-5656-5656-565656565656",
|
||||
);
|
||||
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -754,22 +773,20 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusPane(workspaceKey, "main");
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusPane(workspaceKey, splitPaneId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
});
|
||||
|
||||
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
);
|
||||
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -783,7 +800,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
@@ -791,18 +808,19 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("splitPane enforces the maximum depth of four", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
|
||||
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
|
||||
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
|
||||
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
|
||||
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
|
||||
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
|
||||
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
|
||||
useWorkspaceLayoutIds(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
"33333333-3333-3333-3333-333333333333",
|
||||
"44444444-4444-4444-4444-444444444444",
|
||||
"55555555-5555-5555-5555-555555555555",
|
||||
"66666666-6666-6666-6666-666666666666",
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
"88888888-8888-8888-8888-888888888888",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
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 c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
@@ -831,7 +849,7 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "bottom",
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
|
||||
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||
@@ -840,11 +858,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||
);
|
||||
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const leftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -861,7 +877,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
|
||||
@@ -872,13 +888,14 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
|
||||
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
|
||||
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
|
||||
useWorkspaceLayoutIds(
|
||||
"78787878-7878-7878-7878-787878787878",
|
||||
"89898989-8989-8989-8989-898989898989",
|
||||
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -901,7 +918,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const rootGroup = expectGroup(layout.root);
|
||||
|
||||
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
||||
@@ -937,11 +954,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"abababab-abab-abab-abab-abababababab",
|
||||
);
|
||||
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -959,7 +974,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/b.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
|
||||
expect(duplicateTabId).toBe(secondTabId);
|
||||
@@ -971,13 +986,14 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
|
||||
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
|
||||
useWorkspaceLayoutIds(
|
||||
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
@@ -995,12 +1011,12 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const splitGroup = expectGroup(splitRoot);
|
||||
const nestedGroup = expectGroup(splitGroup.group.children[1]);
|
||||
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
||||
|
||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const resizedGroup = expectGroup(resizedRoot);
|
||||
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
|
||||
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
||||
@@ -1014,11 +1030,11 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("closing the last tab keeps a single empty pane in the layout", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.closeTab(workspaceKey, tabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout).toEqual(createDefaultLayout());
|
||||
});
|
||||
@@ -1032,12 +1048,12 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
expect(otherWorkspaceKey).toBeTruthy();
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(otherWorkspaceKey as string, "agent-2");
|
||||
|
||||
let state = useWorkspaceLayoutStore.getState();
|
||||
let state = workspaceLayoutStore.getState();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
@@ -1045,13 +1061,13 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.unpinAgent(workspaceKey, "agent-1");
|
||||
|
||||
state = useWorkspaceLayoutStore.getState();
|
||||
state = workspaceLayoutStore.getState();
|
||||
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
]);
|
||||
|
||||
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
@@ -1068,12 +1084,12 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
expect(otherWorkspaceKey).toBeTruthy();
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.hideAgent(otherWorkspaceKey as string, "agent-2");
|
||||
|
||||
let state = useWorkspaceLayoutStore.getState();
|
||||
let state = workspaceLayoutStore.getState();
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
@@ -1081,13 +1097,13 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.unhideAgent(workspaceKey, "agent-1");
|
||||
|
||||
state = useWorkspaceLayoutStore.getState();
|
||||
state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
]);
|
||||
|
||||
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
@@ -1096,11 +1112,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"67676767-6767-6767-6767-676767676767",
|
||||
);
|
||||
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -1114,7 +1128,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
||||
expect(nextTabId).toBe("agent_agent-1");
|
||||
@@ -1126,7 +1140,7 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
@@ -1169,7 +1183,7 @@ describe("workspace-layout-store actions", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
@@ -1179,7 +1193,7 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
||||
@@ -1200,14 +1214,14 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
hiddenAgentIdsByWorkspace: {
|
||||
[workspaceKey]: new Set<string>(["agent-1"]),
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
@@ -1217,13 +1231,13 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["parent-agent", "child-agent"],
|
||||
@@ -1234,7 +1248,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1243,7 +1257,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
@@ -1258,7 +1272,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1267,7 +1281,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
@@ -1282,7 +1296,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1291,7 +1305,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "child-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
@@ -1304,11 +1318,11 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
|
||||
"agent_child-agent",
|
||||
@@ -1317,7 +1331,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const scriptTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "terminal",
|
||||
@@ -1335,8 +1349,8 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
|
||||
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
||||
});
|
||||
@@ -1344,7 +1358,7 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
@@ -1355,46 +1369,44 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("explicitly opening an agent tab clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
|
||||
});
|
||||
|
||||
it("pinning an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
|
||||
).toBeDefined();
|
||||
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
|
||||
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("retargeting a tab to an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +1,142 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClientConfig } from "@server/client/daemon-client";
|
||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||
|
||||
const daemonClientMock = vi.hoisted(() => {
|
||||
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
|
||||
let nextConnectError: Error | null = null;
|
||||
let nextLastError: string | null = null;
|
||||
class FakeDaemonClient implements DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
|
||||
class MockDaemonClient {
|
||||
public lastError: string | null = nextLastError;
|
||||
private lastServerInfo = {
|
||||
status: "server_info" as const,
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host" as string | null,
|
||||
version: "0.0.0",
|
||||
};
|
||||
constructor(
|
||||
private readonly probe: FakeDaemonProbe,
|
||||
readonly config: DaemonClientConfig,
|
||||
) {
|
||||
this.lastError = probe.nextLastError;
|
||||
}
|
||||
|
||||
constructor(config: { clientId?: string; url?: string; password?: string }) {
|
||||
createdConfigs.push(config);
|
||||
}
|
||||
|
||||
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;
|
||||
async connect(): Promise<void> {
|
||||
if (this.probe.nextConnectError) {
|
||||
throw this.probe.nextConnectError;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockDaemonClient,
|
||||
createdConfigs,
|
||||
setNextConnectFailure: (error: Error, lastError: string | null) => {
|
||||
nextConnectError = error;
|
||||
nextLastError = lastError;
|
||||
getLastServerInfoMessage() {
|
||||
return {
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host",
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
},
|
||||
reset: () => {
|
||||
createdConfigs.length = 0;
|
||||
nextConnectError = null;
|
||||
nextLastError = null;
|
||||
resolveAppVersion: () => null,
|
||||
createLocalTransportFactory: () => null,
|
||||
buildLocalTransportUrl: ({ transportType, transportPath }) =>
|
||||
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
|
||||
createClient: (config) => {
|
||||
const client = new FakeDaemonClient(this, config);
|
||||
this.createdClients.push(client);
|
||||
return client;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const clientIdMock = vi.hoisted(() => ({
|
||||
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
|
||||
}));
|
||||
failNextConnection(error: Error, lastError: string | null): void {
|
||||
this.nextConnectError = error;
|
||||
this.nextLastError = lastError;
|
||||
}
|
||||
|
||||
vi.mock("@server/client/daemon-client", () => ({
|
||||
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)}`,
|
||||
),
|
||||
}));
|
||||
createdConfigs(): DaemonClientConfig[] {
|
||||
return this.createdClients.map((client) => client.config);
|
||||
}
|
||||
}
|
||||
|
||||
describe("test-daemon-connection connectToDaemon", () => {
|
||||
let probe: FakeDaemonProbe;
|
||||
|
||||
beforeEach(() => {
|
||||
daemonClientMock.reset();
|
||||
clientIdMock.getOrCreateClientId.mockClear();
|
||||
vi.stubGlobal("__DEV__", false);
|
||||
probe = new FakeDaemonProbe();
|
||||
});
|
||||
|
||||
it("reuses the app clientId for direct connections", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const first = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const first = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await first.client.close();
|
||||
|
||||
const second = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const second = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await second.client.close();
|
||||
|
||||
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
|
||||
const [firstConfig, secondConfig] = probe.createdConfigs();
|
||||
expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
|
||||
expect(probe.clientIdsRequested).toBe(2);
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
|
||||
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||
);
|
||||
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock");
|
||||
});
|
||||
|
||||
it("passes direct TCP connection passwords into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
|
||||
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
|
||||
});
|
||||
|
||||
it("uses relay TLS from the stored connection", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const tlsResult = await mod.connectToDaemon(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const tlsResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:wss:[::1]:443",
|
||||
type: "relay",
|
||||
@@ -157,10 +145,11 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await tlsResult.client.close();
|
||||
|
||||
const plainResult = await mod.connectToDaemon(
|
||||
const plainResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
@@ -169,43 +158,52 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await plainResult.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
});
|
||||
|
||||
it("surfaces auth rejection as an incorrect password", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(
|
||||
new Error("Transport closed (code 4001)"),
|
||||
"Transport closed (code 4001)",
|
||||
);
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Incorrect password",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps generic transport failures generic when a password was supplied", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(new Error("Transport error"), "Transport error");
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Transport error",
|
||||
});
|
||||
|
||||
@@ -13,6 +13,34 @@ import {
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} 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 {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -69,13 +97,17 @@ export class DaemonConnectionTestError extends Error {
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const clientId = await deps.getClientId();
|
||||
const localTransportFactory = deps.createLocalTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
appVersion: deps.resolveAppVersion() ?? undefined,
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
@@ -87,7 +119,7 @@ export async function buildClientConfig(
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return {
|
||||
...base,
|
||||
url: buildLocalDaemonTransportUrl({
|
||||
url: deps.buildLocalTransportUrl({
|
||||
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
||||
transportPath: connection.path,
|
||||
}),
|
||||
@@ -120,10 +152,23 @@ export async function buildClientConfig(
|
||||
export function connectAndProbe(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const client = new DaemonClient(config);
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
||||
export function connectAndProbe<TClient extends DaemonProbeClient>(
|
||||
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: DaemonClient; serverId: string; hostname: string | null }>(
|
||||
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
|
||||
(resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
void client.close().catch(() => undefined);
|
||||
@@ -183,10 +228,20 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
|
||||
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(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options));
|
||||
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId, deps);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.71",
|
||||
"@getpaseo/server": "0.1.75",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,32 +1,67 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawnSync: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
import {
|
||||
type DaemonLaunchRuntime,
|
||||
type DetachedDaemonProcess,
|
||||
startLocalDaemonDetached,
|
||||
startLocalDaemonForeground,
|
||||
} from "./local-daemon.js";
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: mocks.spawnSync,
|
||||
};
|
||||
});
|
||||
type RecordedDaemonLaunch =
|
||||
| {
|
||||
mode: "detached";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
|
||||
}
|
||||
| {
|
||||
mode: "foreground";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
|
||||
};
|
||||
|
||||
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 {
|
||||
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
|
||||
pid = 4242;
|
||||
unref = vi.fn();
|
||||
wasUnreferenced = false;
|
||||
|
||||
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 {
|
||||
@@ -41,59 +76,59 @@ function expectSupervisorLaunch(argv: string[]): void {
|
||||
describe("local daemon launch supervision", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mocks.spawnSync.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
});
|
||||
|
||||
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(mocks.spawnSync).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-relay");
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-relay");
|
||||
});
|
||||
|
||||
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChildProcess();
|
||||
mocks.spawnProcess.mockReturnValue(child);
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonDetached } = await import("./local-daemon.js");
|
||||
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
|
||||
const resultPromise = startLocalDaemonDetached(
|
||||
{ home: "/tmp/paseo-test", mcp: false },
|
||||
runtime,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
||||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-mcp");
|
||||
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("detached");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-mcp");
|
||||
});
|
||||
|
||||
test("relay TLS flag is passed to the supervised daemon", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({
|
||||
home: "/tmp/paseo-test",
|
||||
relayUseTls: true,
|
||||
});
|
||||
const status = startLocalDaemonForeground(
|
||||
{
|
||||
home: "/tmp/paseo-test",
|
||||
relayUseTls: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(status).toBe(0);
|
||||
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
|
||||
string,
|
||||
string[],
|
||||
{ env?: NodeJS.ProcessEnv },
|
||||
];
|
||||
expect(argv).toContain("--relay-use-tls");
|
||||
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.args).toContain("--relay-use-tls");
|
||||
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
@@ -68,6 +68,28 @@ interface 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 PID_POLL_INTERVAL_MS = 100;
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
@@ -78,6 +100,13 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
|
||||
resolveRunnerEntry: resolveDaemonRunnerEntry,
|
||||
resolveHome: resolvePaseoHome,
|
||||
spawnDetached: spawnProcess,
|
||||
spawnForeground: spawnSync,
|
||||
};
|
||||
|
||||
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
|
||||
|
||||
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
||||
@@ -395,17 +424,18 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
|
||||
|
||||
export async function startLocalDaemonDetached(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): Promise<DetachedStartResult> {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv);
|
||||
const paseoHome = runtime.resolveHome(childEnv);
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
||||
const child = spawnProcess(
|
||||
const child = runtime.spawnDetached(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
@@ -461,14 +491,17 @@ export async function startLocalDaemonDetached(
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
|
||||
export function startLocalDaemonForeground(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): number {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
const result = spawnSync(
|
||||
const result = runtime.spawnForeground(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCreateWorktreeInput,
|
||||
toDaemonCreateInput,
|
||||
type WorktreeCreateOptions,
|
||||
} from "./create.js";
|
||||
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
|
||||
|
||||
const REPO = "/tmp/repo";
|
||||
|
||||
function build(options: WorktreeCreateOptions): unknown {
|
||||
try {
|
||||
return buildCreateWorktreeInput(options, REPO);
|
||||
return buildCreateWorktreeRequest(options, REPO);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildCreateWorktreeInput", () => {
|
||||
describe("buildCreateWorktreeRequest", () => {
|
||||
it("requires --mode", () => {
|
||||
expect(build({})).toMatchObject({ code: "MISSING_MODE" });
|
||||
});
|
||||
@@ -28,17 +24,20 @@ describe("buildCreateWorktreeInput", () => {
|
||||
expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" });
|
||||
});
|
||||
|
||||
it("branch-off parses with new branch only", () => {
|
||||
it("branch-off builds a daemon request with a new branch", () => {
|
||||
expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x" },
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
});
|
||||
});
|
||||
|
||||
it("branch-off parses with base ref", () => {
|
||||
it("branch-off includes the base ref when provided", () => {
|
||||
expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
refName: "main",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,10 +45,11 @@ describe("buildCreateWorktreeInput", () => {
|
||||
expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" });
|
||||
});
|
||||
|
||||
it("checkout-branch parses with branch", () => {
|
||||
it("checkout-branch builds a checkout request for the branch", () => {
|
||||
expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-branch", branch: "feat/x" },
|
||||
action: "checkout",
|
||||
refName: "feat/x",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,62 +69,8 @@ describe("buildCreateWorktreeInput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("checkout-pr parses positive integers", () => {
|
||||
it("checkout-pr builds a checkout request for the pull request", () => {
|
||||
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,
|
||||
action: "checkout",
|
||||
githubPrNumber: 42,
|
||||
104
packages/cli/src/commands/worktree/create-input.ts
Normal file
104
packages/cli/src/commands/worktree/create-input.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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 };
|
||||
}
|
||||
@@ -2,12 +2,8 @@ import path from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
|
||||
|
||||
export interface WorktreeCreateResult {
|
||||
name: string;
|
||||
@@ -24,110 +20,6 @@ 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 {
|
||||
return details ? { code, message, details } : { code, message };
|
||||
}
|
||||
@@ -137,7 +29,7 @@ export async function runCreateCommand(
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorktreeCreateResult>> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const parsed = buildCreateWorktreeInput(options, cwd);
|
||||
const request = buildCreateWorktreeRequest(options, cwd);
|
||||
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client: DaemonClient;
|
||||
@@ -153,7 +45,7 @@ export async function runCreateCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
|
||||
const response = await client.createPaseoWorktree(request);
|
||||
|
||||
const workspace = response.workspace;
|
||||
if (!workspace || response.error) {
|
||||
|
||||
@@ -8,6 +8,26 @@ function sleep(ms: number): Promise<void> {
|
||||
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");
|
||||
|
||||
const ctx = await createE2ETestContext({ timeout: 30000 });
|
||||
@@ -143,13 +163,10 @@ try {
|
||||
const runJson = JSON.parse(run.stdout);
|
||||
assert.strictEqual(runJson.name, "smoke-loop");
|
||||
|
||||
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);
|
||||
const listedJson = await waitForLoopInList(ctx, runJson.id);
|
||||
assert(
|
||||
listedJson.some((item: { id: string }) => item.id === runJson.id),
|
||||
listed.stdout,
|
||||
JSON.stringify(listedJson),
|
||||
);
|
||||
|
||||
async function pollStatus(attempt: number): Promise<string> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||
@@ -70,6 +71,34 @@ 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", () => {
|
||||
beforeEach(() => {
|
||||
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
|
||||
@@ -165,4 +194,61 @@ describe("daemon-manager commands", () => {
|
||||
"--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,6 +43,7 @@ const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
|
||||
|
||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
|
||||
@@ -69,6 +70,11 @@ interface DesktopPairingOffer {
|
||||
qr: string | null;
|
||||
}
|
||||
|
||||
interface StartupOutputCapture {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function parseReleaseChannel(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): AppReleaseChannel | undefined {
|
||||
@@ -145,6 +151,30 @@ 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 {
|
||||
log.info("[desktop daemon]", message, {
|
||||
pid: process.pid,
|
||||
@@ -197,17 +227,28 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
unknown
|
||||
>;
|
||||
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
||||
const running = localDaemon === "running";
|
||||
const connectedDaemon =
|
||||
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 {
|
||||
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
|
||||
status: running ? "running" : "stopped",
|
||||
status,
|
||||
listen: typeof payload.listen === "string" ? payload.listen : null,
|
||||
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||
pid: running && typeof payload.pid === "number" ? payload.pid : null,
|
||||
hostname:
|
||||
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
|
||||
home,
|
||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||
desktopManaged: payload.desktopManaged === true,
|
||||
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -248,15 +289,17 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
|
||||
|
||||
function buildStartupFailureError(
|
||||
result: { code: number | null; signal: string | null; error?: Error },
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
stdout: StartupOutputCapture,
|
||||
stderr: StartupOutputCapture,
|
||||
): Error {
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
|
||||
const parts = [`Daemon failed to start: ${reason}`];
|
||||
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
|
||||
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
|
||||
const formattedStderr = formatStartupOutput(stderr).trim();
|
||||
const formattedStdout = formatStartupOutput(stdout).trim();
|
||||
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
|
||||
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
|
||||
const logs = tailFile(logFilePath(), 15);
|
||||
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
|
||||
return new Error(parts.join("\n\n"));
|
||||
@@ -337,13 +380,13 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdout = createStartupOutputCapture();
|
||||
let stderr = createStartupOutputCapture();
|
||||
child.stdout!.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
stdout = appendStartupOutput(stdout, data);
|
||||
});
|
||||
child.stderr!.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
stderr = appendStartupOutput(stderr, data);
|
||||
});
|
||||
|
||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||
@@ -381,8 +424,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
||||
childPid: child.pid ?? null,
|
||||
exitedEarly: result.exitedEarly,
|
||||
stdout: stdout.slice(0, 2000),
|
||||
stderr: stderr.slice(0, 2000),
|
||||
stdout: formatStartupOutput(stdout).slice(0, 2000),
|
||||
stderr: formatStartupOutput(stderr).slice(0, 2000),
|
||||
...(result.exitedEarly
|
||||
? {
|
||||
exitCode: result.code,
|
||||
|
||||
53
packages/desktop/src/features/opener.test.ts
Normal file
53
packages/desktop/src/features/opener.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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,7 +1,25 @@
|
||||
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 {
|
||||
ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
|
||||
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => {
|
||||
if (!isAllowedExternalUrl(url)) {
|
||||
throw new Error("Unsupported external URL");
|
||||
}
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ function resolveShellEnv(): Record<string, string> | undefined {
|
||||
const result = spawnSync(shell, [...shellArgs, command], {
|
||||
encoding: "utf8",
|
||||
timeout: RESOLVE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...shellEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
|
||||
@@ -191,6 +191,7 @@ if (forcedUserDataDir) {
|
||||
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
devWorktreeName = path.basename(topLevel);
|
||||
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
|
||||
@@ -200,6 +201,7 @@ if (forcedUserDataDir) {
|
||||
cwd: topLevel,
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
}).trim(),
|
||||
);
|
||||
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -58,14 +58,14 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.71",
|
||||
"@getpaseo/relay": "0.1.71",
|
||||
"@getpaseo/highlight": "0.1.75",
|
||||
"@getpaseo/relay": "0.1.75",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.2",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
|
||||
@@ -10,12 +10,9 @@ import {
|
||||
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
||||
import { loadPersistedConfig } from "../src/server/persisted-config.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";
|
||||
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DEFAULT_LOG_ROTATE_SIZE = "10m";
|
||||
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
|
||||
|
||||
interface DaemonRunnerConfig {
|
||||
devMode: boolean;
|
||||
workerArgs: string[];
|
||||
@@ -77,28 +74,6 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
|
||||
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> {
|
||||
const config = parseConfig(process.argv.slice(2));
|
||||
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
||||
@@ -113,7 +88,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const paseoHome = resolvePaseoHome(workerEnv);
|
||||
const persistedConfig = loadPersistedConfig(paseoHome);
|
||||
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
|
||||
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);
|
||||
|
||||
try {
|
||||
await acquirePidLock(paseoHome, null, {
|
||||
|
||||
42
packages/server/scripts/supervisor-log-config.ts
Normal file
42
packages/server/scripts/supervisor-log-config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
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 supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||
@@ -87,6 +88,57 @@ async function runSupervisorFixture(options: {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
|
||||
70
packages/server/src/server/agent/agent-archive.test.ts
Normal file
70
packages/server/src/server/agent/agent-archive.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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");
|
||||
});
|
||||
30
packages/server/src/server/agent/agent-archive.ts
Normal file
30
packages/server/src/server/agent/agent-archive.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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,6 +716,7 @@ test("createAgent passes daemon launch env through the provider launch context",
|
||||
modeId: "auto",
|
||||
});
|
||||
expect(client.lastLaunchContext).toEqual({
|
||||
agentId: snapshot.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: snapshot.id,
|
||||
},
|
||||
@@ -1179,6 +1180,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
||||
},
|
||||
});
|
||||
expect(client.lastResumeLaunchContext).toEqual({
|
||||
agentId: resumed.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: resumed.id,
|
||||
},
|
||||
@@ -1283,6 +1285,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
|
||||
});
|
||||
|
||||
expect(client.lastCreateLaunchContext).toEqual({
|
||||
agentId: snapshot.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: snapshot.id,
|
||||
},
|
||||
@@ -1293,6 +1296,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
|
||||
});
|
||||
|
||||
expect(client.lastResumeLaunchContext).toEqual({
|
||||
agentId: snapshot.id,
|
||||
env: {
|
||||
PASEO_AGENT_ID: snapshot.id,
|
||||
},
|
||||
|
||||
@@ -10,31 +10,33 @@ import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentSlashCommand,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionResult,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
AgentRuntimeInfo,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentSlashCommand,
|
||||
type AgentMode,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionResult,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ListPersistedAgentsOptions,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
InMemoryAgentTimelineStore,
|
||||
@@ -66,7 +68,6 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
};
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
|
||||
|
||||
interface TimeoutOptions {
|
||||
operation: Promise<void>;
|
||||
@@ -1005,18 +1006,28 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: agent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
pendingPermissions: agent.pendingPermissions.size,
|
||||
},
|
||||
"closeAgent: start",
|
||||
"agent.manager.close.start",
|
||||
);
|
||||
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
||||
await agent.session.close();
|
||||
this.timelineStore.delete(agentId);
|
||||
await this.persistSnapshot(closedAgent);
|
||||
this.emitClosedAgent(closedAgent, { persist: false });
|
||||
this.logger.trace({ agentId }, "closeAgent: completed");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: closedAgent.provider,
|
||||
sessionId: closedAgent.persistence?.sessionId ?? undefined,
|
||||
},
|
||||
"agent.manager.close.complete",
|
||||
);
|
||||
}
|
||||
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
@@ -1071,19 +1082,7 @@ export class AgentManager {
|
||||
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
|
||||
const registry = this.requireRegistry();
|
||||
const archivedAt = new Date().toISOString();
|
||||
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,
|
||||
};
|
||||
const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt });
|
||||
|
||||
await registry.upsert(archivedRecord);
|
||||
|
||||
@@ -1259,19 +1258,7 @@ export class AgentManager {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const normalizedStatus =
|
||||
record.lastStatus === "running" || record.lastStatus === "initializing"
|
||||
? "idle"
|
||||
: record.lastStatus;
|
||||
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...record,
|
||||
archivedAt,
|
||||
lastStatus: normalizedStatus,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
const nextRecord = buildArchivedAgentRecord(record, { archivedAt });
|
||||
await registry.upsert(nextRecord);
|
||||
|
||||
await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
|
||||
@@ -1494,22 +1481,28 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: existingAgent.provider,
|
||||
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
||||
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: existingAgent.lifecycle,
|
||||
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
|
||||
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
||||
hasRunOptions: Boolean(options),
|
||||
},
|
||||
"streamAgent: requested",
|
||||
"agent.manager.stream.request",
|
||||
);
|
||||
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: existingAgent.provider,
|
||||
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
||||
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
||||
lifecycle: existingAgent.lifecycle,
|
||||
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
|
||||
},
|
||||
"streamAgent: rejected because a foreground run is already in flight",
|
||||
"agent.manager.stream.reject",
|
||||
);
|
||||
throw new Error(`Agent ${agentId} already has an active run`);
|
||||
}
|
||||
@@ -1546,10 +1539,13 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
},
|
||||
"streamAgent: started",
|
||||
"agent.manager.stream.start",
|
||||
);
|
||||
|
||||
turnStream = this.foregroundRuns.createTurnStream(turnId);
|
||||
@@ -1601,11 +1597,14 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
|
||||
turnId,
|
||||
lifecycle: mutableAgent.lifecycle,
|
||||
terminalError,
|
||||
pendingReplacement: mutableAgent.pendingReplacement,
|
||||
},
|
||||
"finalizeForegroundTurn: applying terminal state",
|
||||
"agent.manager.finalize",
|
||||
);
|
||||
if (!shouldHoldBusyForReplacement) {
|
||||
this.touchUpdatedAt(mutableAgent);
|
||||
@@ -2395,6 +2394,16 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
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 next = previous
|
||||
.catch(() => undefined)
|
||||
@@ -2406,6 +2415,16 @@ export class AgentManager {
|
||||
if (current.session == null) {
|
||||
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);
|
||||
return;
|
||||
})
|
||||
@@ -2429,8 +2448,19 @@ export class AgentManager {
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
): Promise<void> {
|
||||
const turnId = (event as { turnId?: string }).turnId;
|
||||
const turnId = getAgentStreamEventTurnId(event);
|
||||
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);
|
||||
|
||||
@@ -2441,6 +2471,18 @@ export class AgentManager {
|
||||
this.foregroundRuns.notifyWaiters(matchingWaiters, 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(
|
||||
@@ -2553,7 +2595,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
|
||||
const turnId = (event as { turnId?: string }).turnId;
|
||||
const turnId = getAgentStreamEventTurnId(event);
|
||||
if (turnId == null) {
|
||||
return;
|
||||
}
|
||||
@@ -2564,6 +2606,16 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -2571,8 +2623,9 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
options?: HandleStreamEventOptions,
|
||||
): Promise<boolean> {
|
||||
const eventTurnId = (event as { turnId?: string }).turnId;
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
|
||||
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
|
||||
if (
|
||||
eventTurnId &&
|
||||
isTurnTerminalEvent(event) &&
|
||||
@@ -2585,6 +2638,7 @@ export class AgentManager {
|
||||
if (!options?.fromHistory) {
|
||||
this.touchUpdatedAt(agent);
|
||||
if (this.agentStreamCoalescer.handle(agent.id, event)) {
|
||||
this.traceCoalescerBuffered(agent, event, eventTurnId);
|
||||
return false;
|
||||
}
|
||||
this.agentStreamCoalescer.flushFor(agent.id);
|
||||
@@ -2612,9 +2666,71 @@ export class AgentManager {
|
||||
this.dispatchStream(agent.id, event);
|
||||
}
|
||||
|
||||
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
|
||||
|
||||
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: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: AgentStreamEvent;
|
||||
@@ -2771,11 +2887,13 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
},
|
||||
"handleStreamEvent: turn_completed",
|
||||
"agent.manager.turn.completed",
|
||||
);
|
||||
agent.lastUsage = event.usage;
|
||||
agent.lastError = undefined;
|
||||
@@ -2802,6 +2920,9 @@ export class AgentManager {
|
||||
this.logger.warn(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
@@ -2842,11 +2963,14 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
},
|
||||
"handleStreamEvent: turn_canceled",
|
||||
"agent.manager.turn.canceled",
|
||||
);
|
||||
if (!isForegroundEvent && !agent.pendingReplacement) {
|
||||
agent.lifecycle = "idle";
|
||||
@@ -2867,11 +2991,13 @@ export class AgentManager {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? undefined,
|
||||
turnId: eventTurnId,
|
||||
lifecycle: agent.lifecycle,
|
||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||
eventTurnId,
|
||||
},
|
||||
"handleStreamEvent: turn_started",
|
||||
"agent.manager.turn.started",
|
||||
);
|
||||
if (!isForegroundEvent) {
|
||||
agent.lifecycle = "running";
|
||||
@@ -3017,6 +3143,20 @@ export class AgentManager {
|
||||
|
||||
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({
|
||||
type: "agent_state",
|
||||
agent: { ...agent },
|
||||
@@ -3144,6 +3284,18 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
metadata?: { seq?: number; epoch?: string },
|
||||
): 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 });
|
||||
}
|
||||
|
||||
@@ -3239,6 +3391,7 @@ export class AgentManager {
|
||||
|
||||
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
||||
return {
|
||||
agentId,
|
||||
env: {
|
||||
PASEO_AGENT_ID: agentId,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,19 @@ export function startAgentRun(
|
||||
logger: Logger,
|
||||
options?: StartAgentRunOptions,
|
||||
): { 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
|
||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||
@@ -28,12 +41,38 @@ export function startAgentRun(
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(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 () => {
|
||||
try {
|
||||
for await (const _ of iterator) {
|
||||
// Events are broadcast via AgentManager subscribers.
|
||||
}
|
||||
logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: snapshot?.provider,
|
||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
||||
},
|
||||
"agent.session.iterator.drained",
|
||||
);
|
||||
} 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");
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -358,6 +358,10 @@ export type AgentStreamEvent =
|
||||
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 AgentPermissionUpdate = AgentMetadata;
|
||||
@@ -476,6 +480,7 @@ export interface AgentSessionConfig {
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
agentId?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import { getAgentStreamEventTurnId, type AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
|
||||
export interface ForegroundTurnWaiter {
|
||||
turnId: string;
|
||||
@@ -105,7 +105,7 @@ export class ForegroundRunState {
|
||||
event: AgentStreamEvent,
|
||||
options?: { terminal?: boolean },
|
||||
): void {
|
||||
const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
|
||||
const waiters = this.getMatchingWaiters(agent, getAgentStreamEventTurnId(event));
|
||||
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
|
||||
}
|
||||
|
||||
@@ -226,7 +226,3 @@ function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
|
||||
pendingRun.settled = true;
|
||||
pendingRun.resolveSettled();
|
||||
}
|
||||
|
||||
function eventTurnId(event: AgentStreamEvent): string | undefined {
|
||||
return (event as { turnId?: string }).turnId;
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ export function findExecutable(name: string): string | null {
|
||||
const result = execFileSync(cmd, [trimmed], {
|
||||
encoding: "utf8",
|
||||
env: createProviderEnv({ baseEnv: process.env }),
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
|
||||
const candidate = lines.at(-1)?.trim() ?? null;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AgentModeVisuals {
|
||||
|
||||
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -96,9 +97,9 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
|
||||
colorTier: "planning",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
label: "Autopilot",
|
||||
description: "Autonomous mode that runs until task completion without user interaction",
|
||||
id: "allow-all",
|
||||
label: "Allow All",
|
||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
isUnattended: true,
|
||||
|
||||
@@ -67,10 +67,21 @@ export interface BuildProviderRegistryOptions {
|
||||
isDev?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderClientFactoryOptions extends Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService"
|
||||
> {
|
||||
customProvider?: {
|
||||
id: string;
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderClientFactory = (
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||
options?: ProviderClientFactoryOptions,
|
||||
) => AgentClient;
|
||||
|
||||
interface ResolvedProvider {
|
||||
@@ -92,6 +103,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
codex: (logger, runtimeSettings, options) =>
|
||||
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
||||
workspaceGitService: options?.workspaceGitService,
|
||||
customProvider: options?.customProvider,
|
||||
}),
|
||||
copilot: (logger, runtimeSettings) =>
|
||||
new CopilotACPAgentClient({
|
||||
@@ -510,10 +522,11 @@ function addDerivedProviders(
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseProvider = resolvedProviders.get(override.extends);
|
||||
const baseProviderId = override.extends;
|
||||
const baseProvider = resolvedProviders.get(baseProviderId);
|
||||
if (!baseProvider) {
|
||||
throw new Error(
|
||||
`Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
|
||||
`Custom provider '${providerId}' extends unknown provider '${baseProviderId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -522,7 +535,7 @@ function addDerivedProviders(
|
||||
toRuntimeSettings(override),
|
||||
);
|
||||
const baseDefinition = baseProvider.definition;
|
||||
const baseFactory = getProviderClientFactory(override.extends);
|
||||
const baseFactory = getProviderClientFactory(baseProviderId);
|
||||
|
||||
resolvedProviders.set(providerId, {
|
||||
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
||||
@@ -530,8 +543,15 @@ function addDerivedProviders(
|
||||
profileModels: override.models ?? [],
|
||||
additionalModels: override.additionalModels ?? [],
|
||||
enabled: override.enabled !== false,
|
||||
derivedFromProviderId: override.extends,
|
||||
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
|
||||
derivedFromProviderId: baseProviderId,
|
||||
createBaseClient: (logger) =>
|
||||
baseFactory(logger, mergedRuntimeSettings, {
|
||||
customProvider: {
|
||||
id: providerId,
|
||||
label: override.label ?? providerId,
|
||||
extends: baseProviderId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ import {
|
||||
resolveACPModeSelection,
|
||||
resolveACPModelSelection,
|
||||
} 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 type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
@@ -140,6 +149,73 @@ 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 {
|
||||
if (category === "mode") {
|
||||
return "Mode";
|
||||
@@ -190,7 +266,7 @@ function prepareConfiguredOverrideSession(
|
||||
|
||||
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
|
||||
const logger = createTestLogger();
|
||||
const childLogger = { warn: vi.fn() };
|
||||
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||
const session = createSessionWithConfig({}, logger);
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
@@ -577,7 +653,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
expect(await validSession.getCurrentMode()).toBe("default");
|
||||
|
||||
const logger = createTestLogger();
|
||||
const childLogger = { warn: vi.fn() };
|
||||
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||
const invalidSession = createSessionWithConfig(
|
||||
{ modeId: "acceptEdits", model: "opus" },
|
||||
@@ -796,12 +872,10 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
test("passes generic ACP permission requests through to the user", async () => {
|
||||
const session = createSessionWithConfig({
|
||||
provider: "copilot",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
provider: "cursor-acp",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
});
|
||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
@@ -835,6 +909,126 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -54,35 +54,36 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionRequestKind,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionRequestKind,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type ListModesOptions,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type McpServerConfig,
|
||||
type PersistedAgentDescriptor,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
@@ -226,7 +227,13 @@ interface ACPAgentClientOptions {
|
||||
defaultModes?: AgentMode[];
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
|
||||
thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
@@ -245,7 +252,13 @@ interface ACPAgentSessionOptions {
|
||||
defaultModes: AgentMode[];
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
|
||||
thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
@@ -253,6 +266,7 @@ interface ACPAgentSessionOptions {
|
||||
) => Promise<void>;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
handle?: AgentPersistenceHandle;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
@@ -337,6 +351,26 @@ interface ACPModelSelection {
|
||||
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 {
|
||||
if (!usage) {
|
||||
return undefined;
|
||||
@@ -465,7 +499,17 @@ export class ACPAgentClient implements AgentClient {
|
||||
private readonly sessionResponseTransformer?: (
|
||||
response: SessionStateResponse,
|
||||
) => SessionStateResponse;
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
private readonly beforeModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPBeforeModeWriteResult>;
|
||||
private readonly thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
@@ -477,13 +521,20 @@ export class ACPAgentClient implements AgentClient {
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
this.provider = options.provider;
|
||||
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
|
||||
this.logger = options.logger.child({ module: "agent", provider: options.provider });
|
||||
this.logger = options.logger.child({
|
||||
module: "agent",
|
||||
provider: options.provider,
|
||||
});
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.defaultCommand = options.defaultCommand;
|
||||
this.defaultModes = options.defaultModes ?? [];
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
this.beforeModeWriter = options.beforeModeWriter;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
@@ -504,9 +555,14 @@ export class ACPAgentClient implements AgentClient {
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
@@ -545,10 +601,15 @@ export class ACPAgentClient implements AgentClient {
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
handle,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
@@ -747,7 +808,16 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
|
||||
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
|
||||
const transformed = this.sessionResponseTransformer
|
||||
? this.sessionResponseTransformer(response)
|
||||
: response;
|
||||
if (!this.configOptionsTransformer || !transformed.configOptions) {
|
||||
return transformed;
|
||||
}
|
||||
return {
|
||||
...transformed,
|
||||
configOptions: this.configOptionsTransformer(transformed.configOptions),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,12 +833,23 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly sessionResponseTransformer?: (
|
||||
response: SessionStateResponse,
|
||||
) => SessionStateResponse;
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPProviderModeWriteResult>;
|
||||
private readonly beforeModeWriter?: (
|
||||
context: ACPProviderModeWriterContext,
|
||||
) => Promise<ACPBeforeModeWriteResult>;
|
||||
private readonly thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
private readonly agentId?: string;
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
||||
@@ -814,9 +895,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.defaultModes = options.defaultModes;
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
this.beforeModeWriter = options.beforeModeWriter;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.availableModes = options.defaultModes;
|
||||
this.agentId = options.agentId;
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.initialHandle = options.handle;
|
||||
this.config = { ...config, provider: options.provider };
|
||||
@@ -1068,6 +1154,25 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
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.availableMode) {
|
||||
this.warnInvalidSelection(
|
||||
@@ -1078,7 +1183,32 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
);
|
||||
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 });
|
||||
this.currentMode = modeId;
|
||||
this.pushEvent({
|
||||
@@ -1094,17 +1224,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
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;
|
||||
}
|
||||
|
||||
const response = await this.connection.setSessionConfigOption({
|
||||
sessionId: this.sessionId,
|
||||
@@ -1127,6 +1246,24 @@ 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> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
@@ -1281,9 +1418,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: string;
|
||||
label: string;
|
||||
}): string {
|
||||
this.configOptions = response.configOptions;
|
||||
this.configOptions = this.transformConfigOptions(response.configOptions);
|
||||
const responseOption = findSelectConfigOption({
|
||||
configOptions: response.configOptions,
|
||||
configOptions: this.configOptions,
|
||||
category,
|
||||
id: configId,
|
||||
});
|
||||
@@ -1409,7 +1546,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Match Zed acp.rs:3189-3220 — pure pass-through. Accepted UX regression: Copilot Autopilot will now prompt the user for every tool request.
|
||||
// Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through.
|
||||
const requestId = randomUUID();
|
||||
let toolSnapshot =
|
||||
this.toolCalls.get(params.toolCall.toolCallId) ??
|
||||
@@ -1439,11 +1576,31 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const event of events) {
|
||||
if (event.type === "timeline") {
|
||||
@@ -1622,7 +1779,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
? this.sessionResponseTransformer(response)
|
||||
: response;
|
||||
|
||||
this.configOptions = transformed.configOptions ?? [];
|
||||
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
|
||||
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
|
||||
this.availableModes = modeInfo.modes;
|
||||
@@ -1635,6 +1792,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
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> {
|
||||
const configuredModeId = this.config.modeId;
|
||||
if (configuredModeId && configuredModeId !== this.currentMode) {
|
||||
@@ -1772,11 +1939,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
|
||||
this.currentMode = update.currentModeId;
|
||||
this.currentMode = this.transformModeId(update.currentModeId);
|
||||
}
|
||||
|
||||
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
|
||||
this.configOptions = update.configOptions;
|
||||
this.configOptions = this.transformConfigOptions(update.configOptions);
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
||||
const nextMode = modeInfo.currentModeId;
|
||||
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
|
||||
@@ -1864,6 +2031,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
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) {
|
||||
subscriber(event);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentSession,
|
||||
type AgentStreamEvent,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
@@ -37,15 +41,14 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
|
||||
type EventWithTurnId = AgentStreamEvent & { turnId: string };
|
||||
|
||||
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
|
||||
return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
|
||||
return getAgentStreamEventTurnId(event) !== undefined;
|
||||
}
|
||||
|
||||
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
|
||||
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
|
||||
return events.filter((e) => getAgentStreamEventTurnId(e) === turnId);
|
||||
}
|
||||
|
||||
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
|
||||
@@ -157,7 +160,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
|
||||
// Invariant 2: Every turn_started has exactly one matching terminal
|
||||
const turnStartedIds = events
|
||||
.filter((e) => e.type === "turn_started" && hasTurnId(e))
|
||||
.map((e) => (e as EventWithTurnId).turnId);
|
||||
.map((e) => e.turnId);
|
||||
|
||||
for (const turnId of turnStartedIds) {
|
||||
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
|
||||
@@ -277,7 +280,7 @@ test("Test 3: Lifecycle doesn't get stuck in running", async () => {
|
||||
|
||||
// Any turn_started after terminal must have a different turnId
|
||||
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
|
||||
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
|
||||
expect(ts.turnId).not.toBe(turnId);
|
||||
}
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
@@ -313,8 +316,9 @@ test("Test 4: Autonomous run", async () => {
|
||||
|
||||
// Autonomous turn_started with a different turnId
|
||||
const autoStarts = afterForeground.filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||
) as EventWithTurnId[];
|
||||
(e): e is EventWithTurnId =>
|
||||
e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||
);
|
||||
if (autoStarts.length === 0) {
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
return;
|
||||
|
||||
@@ -42,34 +42,35 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../prov
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionRequestKind,
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionUpdate,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
AgentRuntimeInfo,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPermissionAction,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionRequestKind,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionUpdate,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type McpServerConfig,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import {
|
||||
createProviderEnv,
|
||||
@@ -250,6 +251,7 @@ interface ClaudeAgentSessionOptions {
|
||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
handle?: AgentPersistenceHandle;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
logger: Logger;
|
||||
@@ -1152,8 +1154,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||
};
|
||||
}
|
||||
|
||||
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
|
||||
|
||||
export class ClaudeAgentClient implements AgentClient {
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
@@ -1181,6 +1181,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
persistSession: options?.persistSession,
|
||||
logger: this.logger,
|
||||
@@ -1209,6 +1210,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
handle,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
@@ -1477,6 +1479,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private readonly config: ClaudeAgentConfig;
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly agentId?: string;
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly persistSession?: boolean;
|
||||
@@ -1526,10 +1529,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
||||
this.config = config;
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.agentId = options.agentId;
|
||||
this.defaults = options.defaults;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.persistSession = options.persistSession;
|
||||
this.logger = options.logger;
|
||||
this.logger = options.logger.child({ agentId: this.agentId });
|
||||
this.queryFactory = options.queryFactory;
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
const handle = options.handle;
|
||||
@@ -1869,13 +1873,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
turnState: this.turnState,
|
||||
hasQuery: Boolean(this.query),
|
||||
hasInput: Boolean(this.input),
|
||||
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
|
||||
},
|
||||
"Claude session close: start",
|
||||
"provider.claude.session_close.start",
|
||||
);
|
||||
this.closed = true;
|
||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||
@@ -1910,8 +1917,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
this.logger.trace(
|
||||
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
|
||||
"Claude session close: completed",
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnState: this.turnState,
|
||||
},
|
||||
"provider.claude.session_close.complete",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2188,16 +2200,41 @@ class ClaudeAgentSession implements AgentSession {
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
if (!promise) {
|
||||
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
label,
|
||||
},
|
||||
"provider.claude.query_operation.skip",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
this.logger.trace({ label }, "Claude query operation wait start");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
label,
|
||||
},
|
||||
"provider.claude.query_operation.start",
|
||||
);
|
||||
try {
|
||||
await withTimeout(promise, 3_000, "timeout");
|
||||
this.logger.trace(
|
||||
{ label, durationMs: Date.now() - startedAt },
|
||||
"Claude query operation settled",
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
label,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
"provider.claude.query_operation.settled",
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
|
||||
@@ -2593,7 +2630,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const pump = this.runQueryPump().catch((error) => {
|
||||
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
|
||||
this.logger.trace(
|
||||
{
|
||||
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;
|
||||
@@ -2609,24 +2655,34 @@ class ClaudeAgentSession implements AgentSession {
|
||||
try {
|
||||
activeQuery = await this.ensureQuery();
|
||||
} catch (error) {
|
||||
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
|
||||
this.logger.trace(
|
||||
{
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
let consecutiveInterruptAbortRecoveries = 0;
|
||||
const logRawMessage = (message: SDKMessage): void => {
|
||||
if (!claudeDebug) {
|
||||
return;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
messageType: message.type,
|
||||
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
||||
messageUuid: "uuid" in message ? message.uuid : undefined,
|
||||
rawEvent: message,
|
||||
},
|
||||
"Claude query pump: raw SDK message",
|
||||
"provider.claude.raw_event",
|
||||
);
|
||||
};
|
||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||
@@ -2739,16 +2795,18 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||
const identifiers = readEventIdentifiers(message);
|
||||
|
||||
if (claudeDebug) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: message.type,
|
||||
turnId,
|
||||
},
|
||||
"Claude query pump: SDK message",
|
||||
);
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: turnId ?? undefined,
|
||||
messageType: message.type,
|
||||
identifiers,
|
||||
rawEvent: message,
|
||||
},
|
||||
"provider.claude.parsed_event",
|
||||
);
|
||||
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
@@ -2816,7 +2874,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
this.logger.warn(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
error: staleResumeError,
|
||||
},
|
||||
"Claude resumed session no longer exists; invalidating persisted session",
|
||||
@@ -2846,7 +2903,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async interruptActiveTurn(): Promise<void> {
|
||||
const queryToInterrupt = this.query;
|
||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||
this.logger.trace("interruptActiveTurn: no query to interrupt");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
},
|
||||
"provider.claude.interrupt.no_query",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.pendingInterruptAbort = true;
|
||||
@@ -3455,6 +3520,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
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) {
|
||||
try {
|
||||
callback(tagged);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.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";
|
||||
|
||||
const CODEX_PROVIDER = "codex";
|
||||
@@ -27,39 +31,7 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
},
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
type CodexFeaturesTestSession = AgentSession;
|
||||
|
||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||
return {
|
||||
@@ -71,28 +43,36 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
): CodexFeaturesTestSession {
|
||||
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): {
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
} {
|
||||
const config = createConfig(configOverrides);
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||
});
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
) as unknown as CodexFeaturesTestSession;
|
||||
session.connected = true;
|
||||
session.currentThreadId = "test-thread";
|
||||
session.collaborationModes = TEST_COLLABORATION_MODES;
|
||||
session.refreshResolvedCollaborationMode();
|
||||
return session;
|
||||
async () => appServer.child,
|
||||
) as CodexFeaturesTestSession;
|
||||
return { session, appServer };
|
||||
}
|
||||
|
||||
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
}> {
|
||||
const harness = createSessionHarness(configOverrides);
|
||||
await harness.session.connect();
|
||||
harness.appServer.assertNoErrors();
|
||||
return harness;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider features", () => {
|
||||
test("features returns fast and plan toggles when supported", async () => {
|
||||
const session = createSession();
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
@@ -140,8 +120,8 @@ describe("Codex app-server provider features", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("features returns only plan toggle when model does not support fast mode", () => {
|
||||
const session = createSession({ model: "gpt-3.5-turbo" });
|
||||
test("features returns only plan toggle when model does not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
@@ -157,49 +137,56 @@ describe("Codex app-server provider features", () => {
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
|
||||
const session = createSession();
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
|
||||
const session = createSession({
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
await session.setFeature?.("fast_mode", false);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature invalidates cachedRuntimeInfo", async () => {
|
||||
const session = createSession();
|
||||
test("setFeature invalidates runtime info", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
await session.getRuntimeInfo();
|
||||
expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
|
||||
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
expect(asInternals(session).cachedRuntimeInfo).toBeNull();
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature throws for unknown feature ids", async () => {
|
||||
const session = createSession();
|
||||
const { session } = createSessionHarness();
|
||||
|
||||
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
||||
"Unknown Codex feature: unknown_feature",
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor restores feature flags from config.featureValues", () => {
|
||||
const session = createSession({
|
||||
test("constructor restores feature flags from config.featureValues", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true, plan_mode: true },
|
||||
});
|
||||
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).planModeEnabled).toBe(true);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
@@ -220,41 +207,29 @@ describe("Codex app-server provider features", () => {
|
||||
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 () => {
|
||||
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);
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/start",
|
||||
expect.objectContaining({
|
||||
serviceTier: "fast",
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
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);
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setModel("gpt-3.5-turbo");
|
||||
@@ -270,41 +245,23 @@ describe("Codex app-server provider features", () => {
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/start",
|
||||
expect.not.objectContaining({
|
||||
serviceTier: expect.anything(),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||
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);
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/start",
|
||||
expect.objectContaining({
|
||||
collaborationMode: expect.objectContaining({
|
||||
mode: "plan",
|
||||
}),
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
collaborationMode: expect.objectContaining({
|
||||
mode: "plan",
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type {
|
||||
AgentLaunchContext,
|
||||
@@ -17,11 +19,12 @@ import {
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
} from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createCodexAppServerChildProcessStub,
|
||||
TestCodexAppServerPeer,
|
||||
} from "./codex/test-utils/test-app-server-peer.js";
|
||||
createFakeCodexAppServer,
|
||||
waitForNextPermission,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||
import { buildProviderRegistry } from "../provider-registry.js";
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
name: string;
|
||||
@@ -32,6 +35,7 @@ interface CollaborationModeRecord {
|
||||
}
|
||||
|
||||
interface CodexSessionTestAccess {
|
||||
ensureThreadLoaded(): Promise<void>;
|
||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||
handleNotification(method: string, params: unknown): void;
|
||||
loadPersistedHistory(): Promise<void>;
|
||||
@@ -100,32 +104,98 @@ function markdownImageSource(markdown: string): string {
|
||||
return match[1].replace(/\\\)/g, ")");
|
||||
}
|
||||
|
||||
function waitForNextPermission(
|
||||
session: AgentSession,
|
||||
events: AgentStreamEvent[],
|
||||
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
||||
const existing = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested",
|
||||
);
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
type CapturedFakeCodexRecord = Record<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
async function runCustomCodexProviderTurn(
|
||||
providerId: string,
|
||||
baseUrl: string,
|
||||
): Promise<CapturedFakeCodexRecord[]> {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-"));
|
||||
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");
|
||||
}
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const registry = buildProviderRegistry(createTestLogger(), {
|
||||
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 {
|
||||
const threadStart = records.find((record) => record.method === "thread/start");
|
||||
const params = threadStart?.params as Record<string, unknown> | undefined;
|
||||
return params?.config;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
@@ -191,15 +261,21 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("disposes an unresponsive app-server child with SIGKILL", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createCodexAppServerChildProcessStub({ exitOnKill: false });
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
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());
|
||||
|
||||
try {
|
||||
const disposePromise = client.dispose();
|
||||
expect(child.killSignals).toEqual(["SIGTERM"]);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(child.killSignals).toEqual(["SIGTERM", "SIGKILL"]);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(disposePromise).resolves.toBeUndefined();
|
||||
@@ -209,7 +285,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
test("round-trips server-initiated command approvals through the real app-server transport", async () => {
|
||||
const peer = new TestCodexAppServerPeer({
|
||||
const appServer = createFakeCodexAppServer({
|
||||
initialize: () => ({}),
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"skills/list": () => ({ data: [] }),
|
||||
@@ -218,28 +294,21 @@ describe("Codex app-server provider", () => {
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => peer.child,
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.connect();
|
||||
peer.assertNoErrors();
|
||||
appServer.assertNoErrors();
|
||||
|
||||
const permissionRequested = waitForNextPermission(session, events);
|
||||
|
||||
peer.writeRequest(
|
||||
"item/commandExecution/requestApproval",
|
||||
{
|
||||
itemId: "exec-approval-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
command: "git restore README.md",
|
||||
cwd: "/workspace/project",
|
||||
reason: "requires escalated permissions",
|
||||
},
|
||||
41,
|
||||
);
|
||||
const permissionRequested = waitForNextPermission(session);
|
||||
appServer.requestCommandApproval({
|
||||
itemId: "exec-approval-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
command: "git restore README.md",
|
||||
cwd: "/workspace/project",
|
||||
reason: "requires escalated permissions",
|
||||
});
|
||||
|
||||
const permissionEvent = await permissionRequested;
|
||||
expect(permissionEvent.request).toMatchObject({
|
||||
@@ -262,14 +331,128 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
|
||||
|
||||
await expect(peer.waitForResponse(41, { decision: "accept" })).resolves.toMatchObject({
|
||||
id: 41,
|
||||
result: { decision: "accept" },
|
||||
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({
|
||||
decision: "accept",
|
||||
});
|
||||
peer.assertNoErrors();
|
||||
appServer.assertNoErrors();
|
||||
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 () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
||||
const cwd = path.join(tempDir, "repo", "packages", "app");
|
||||
@@ -280,12 +463,8 @@ describe("Codex app-server provider", () => {
|
||||
path.join(repoSkillDir, "SKILL.md"),
|
||||
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
|
||||
);
|
||||
const resolvedRepoRoots: string[] = [];
|
||||
const workspaceGitService = {
|
||||
resolveRepoRoot: async (pathToResolve: string) => {
|
||||
resolvedRepoRoots.push(pathToResolve);
|
||||
return path.join(tempDir, "repo");
|
||||
},
|
||||
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -296,7 +475,7 @@ describe("Codex app-server provider", () => {
|
||||
description: "Ship changes carefully.",
|
||||
argumentHint: "",
|
||||
});
|
||||
expect(resolvedRepoRoots).toEqual([cwd]);
|
||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -935,6 +1114,52 @@ 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 () => {
|
||||
const session = createSession();
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
@@ -984,6 +1209,37 @@ 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 () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const session = createSession({}, { goalsEnabled: true });
|
||||
@@ -1790,7 +2046,14 @@ describe("Codex persisted sessions", () => {
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => {
|
||||
return createCodexAppServerChildProcessStub();
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
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" });
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
McpServerConfig,
|
||||
AgentPersistenceHandle,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionResult,
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ToolCallTimelineItem,
|
||||
AgentUsage,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPermissionAction,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type McpServerConfig,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionResult,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type ToolCallTimelineItem,
|
||||
type AgentUsage,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
@@ -55,7 +56,10 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import {
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
@@ -174,9 +178,16 @@ interface CodexAppServerClientLike {
|
||||
|
||||
interface CodexAppServerAgentDeps {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
customProvider?: {
|
||||
id: string;
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
customCodexConfig?: Record<string, unknown> | null;
|
||||
_createCodexClient?: (
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
logger: Logger,
|
||||
getTraceContext: () => CodexAppServerTraceContext,
|
||||
) => CodexAppServerClientLike;
|
||||
}
|
||||
|
||||
@@ -2525,6 +2536,50 @@ 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 {
|
||||
callId: string;
|
||||
toolCall: ToolCallTimelineItem;
|
||||
@@ -2603,8 +2658,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
private readonly ephemeral: boolean = false,
|
||||
private readonly goalsEnabled: boolean = false,
|
||||
private readonly agentId?: string,
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
||||
this.logger = logger.child({
|
||||
module: "agent",
|
||||
provider: CODEX_PROVIDER,
|
||||
agentId: this.agentId,
|
||||
});
|
||||
if (config.modeId === undefined) {
|
||||
throw new Error("Codex agent requires modeId to be specified");
|
||||
}
|
||||
@@ -2641,7 +2701,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
const child = await this.spawnAppServer();
|
||||
this.client = new CodexAppServerClient(child, this.logger);
|
||||
this.client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
|
||||
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
||||
this.registerRequestHandlers();
|
||||
|
||||
@@ -2659,6 +2719,14 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
private traceContext(): CodexAppServerTraceContext {
|
||||
return {
|
||||
agentId: this.agentId,
|
||||
sessionId: this.currentThreadId ?? undefined,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadCollaborationModes(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
try {
|
||||
@@ -2679,7 +2747,16 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.trace({ error }, "Failed to load collaboration modes");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
error,
|
||||
},
|
||||
"provider.codex.metadata.collaboration_modes_failed",
|
||||
);
|
||||
this.collaborationModes = [];
|
||||
}
|
||||
this.refreshResolvedCollaborationMode();
|
||||
@@ -2711,7 +2788,16 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.cachedSkills = skills;
|
||||
} catch (error) {
|
||||
this.logger.trace({ error }, "Failed to load skills list");
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
error,
|
||||
},
|
||||
"provider.codex.metadata.skills_failed",
|
||||
);
|
||||
this.cachedSkills = [];
|
||||
}
|
||||
}
|
||||
@@ -2901,9 +2987,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
await this.client.request("thread/resume", params);
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
|
||||
this.currentThreadId = null;
|
||||
await this.ensureThread();
|
||||
const threadId = this.currentThreadId;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
|
||||
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3587,6 +3674,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (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;
|
||||
}
|
||||
|
||||
@@ -3604,6 +3694,16 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId;
|
||||
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) {
|
||||
try {
|
||||
callback(tagged);
|
||||
@@ -3619,6 +3719,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||
this.traceParsedNotification(method, params, parsed);
|
||||
switch (parsed.kind) {
|
||||
case "thread_started":
|
||||
this.handleThreadStartedNotification(parsed);
|
||||
@@ -3675,6 +3776,25 @@ 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 {
|
||||
if (!threadId || threadId === this.currentThreadId) {
|
||||
return null;
|
||||
@@ -3786,10 +3906,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(
|
||||
callId,
|
||||
timelineItem.type === "tool_call" && timelineItem.status === "failed" ? "failed" : "running",
|
||||
);
|
||||
this.emitSubAgentActivityUpdate(callId, "running");
|
||||
}
|
||||
|
||||
private shouldSkipCompletedThreadItem(
|
||||
@@ -4270,7 +4387,17 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.warnedUnknownNotificationMethods.add(method);
|
||||
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
|
||||
this.logger.trace(
|
||||
{
|
||||
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 {
|
||||
@@ -4531,6 +4658,16 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {}
|
||||
|
||||
private sessionDeps(): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
customCodexConfig: buildCodexCustomProviderConfig(
|
||||
this.runtimeSettings,
|
||||
this.deps.customProvider,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveGoalsEnabled(): Promise<boolean> {
|
||||
if (!this.goalsEnabledPromise) {
|
||||
this.goalsEnabledPromise = (async () => {
|
||||
@@ -4538,7 +4675,14 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
|
||||
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
|
||||
this.logger.trace({ versionOutput, enabled }, "Resolved codex goals feature gate");
|
||||
this.logger.trace(
|
||||
{
|
||||
provider: CODEX_PROVIDER,
|
||||
versionOutput,
|
||||
enabled,
|
||||
},
|
||||
"provider.codex.config.goals_resolved",
|
||||
);
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
|
||||
@@ -4551,7 +4695,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
|
||||
private async spawnAppServer(
|
||||
launchEnv?: Record<string, string>,
|
||||
options?: { goalsEnabled?: boolean },
|
||||
options?: { goalsEnabled?: boolean; agentId?: string },
|
||||
): Promise<ChildProcessWithoutNullStreams> {
|
||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const args = [...launchPrefix.args, "app-server"];
|
||||
@@ -4560,10 +4704,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: options?.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
launchPrefix,
|
||||
goalsEnabled: options?.goalsEnabled === true,
|
||||
},
|
||||
"Spawning Codex app server",
|
||||
"provider.codex.spawn",
|
||||
);
|
||||
const child = spawnProcess(launchPrefix.command, args, {
|
||||
detached: process.platform !== "win32",
|
||||
@@ -4595,10 +4741,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
sessionConfig,
|
||||
null,
|
||||
this.logger,
|
||||
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||
this.deps,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(),
|
||||
options?.persistSession === false,
|
||||
goalsEnabled,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -4621,10 +4769,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
merged,
|
||||
handle,
|
||||
this.logger,
|
||||
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||
this.deps,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(),
|
||||
false,
|
||||
goalsEnabled,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -4635,7 +4785,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
const child = await this.spawnAppServer();
|
||||
const client =
|
||||
this.deps._createCodexClient?.(child, this.logger) ??
|
||||
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
|
||||
new CodexAppServerClient(child, this.logger);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
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 { TestCodexAppServerPeer } from "./test-utils/test-app-server-peer.js";
|
||||
|
||||
describe("Codex app-server transport", () => {
|
||||
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
|
||||
const peer = new TestCodexAppServerPeer();
|
||||
const client = new CodexAppServerClient(peer.child, createTestLogger());
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
const request = client.request("model/list", {});
|
||||
peer.writeNonJsonStdout("Codex ha iniciado en modo localizado");
|
||||
peer.writeResponse(1, { data: [] });
|
||||
child.stdout.write("Codex ha iniciado en modo localizado\n");
|
||||
child.stdout.write('{"id":1,"result":{"data":[]}}\n');
|
||||
|
||||
await expect(request).resolves.toEqual({ data: [] });
|
||||
peer.close();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -23,19 +25,23 @@ describe("Codex app-server transport", () => {
|
||||
"item/tool/requestUserInput",
|
||||
"tool/requestUserInput",
|
||||
])("answers server-initiated %s requests through registered handlers", async (method) => {
|
||||
const peer = new TestCodexAppServerPeer();
|
||||
const client = new CodexAppServerClient(peer.child, createTestLogger());
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const handlerCalls: unknown[] = [];
|
||||
client.setRequestHandler(method, async (params) => {
|
||||
handlerCalls.push(params);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const response = peer.nextPaseoOutputLine();
|
||||
peer.writeRequest(method);
|
||||
const response = new Promise<string>((resolve) => {
|
||||
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
|
||||
});
|
||||
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
|
||||
|
||||
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
|
||||
expect(handlerCalls).toEqual([{}]);
|
||||
peer.close();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,12 @@ interface PendingRequest {
|
||||
type RequestHandler = (params: unknown) => unknown;
|
||||
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> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -54,6 +60,24 @@ function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
|
||||
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 {
|
||||
private readonly rl: readline.Interface;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
@@ -66,6 +90,7 @@ export class CodexAppServerClient {
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger,
|
||||
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
|
||||
) {
|
||||
this.rl = readline.createInterface({ input: child.stdout });
|
||||
this.rl.on("line", (line) => {
|
||||
@@ -224,6 +249,19 @@ export class CodexAppServerClient {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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,6 +64,39 @@ 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", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
|
||||
@@ -200,21 +200,48 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
||||
|
||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||
if (!wrapperMatch) {
|
||||
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||
if (unixWrapperMatch) {
|
||||
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;
|
||||
}
|
||||
const candidate = wrapperMatch[1]?.trim() ?? "";
|
||||
const wrappedCommand = windowsWrapperMatch[2]?.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) {
|
||||
return trimmed;
|
||||
}
|
||||
return stripMatchingEdgeQuotes(candidate);
|
||||
}
|
||||
|
||||
function stripMatchingEdgeQuotes(value: string): string {
|
||||
if (
|
||||
(candidate.startsWith('"') && candidate.endsWith('"')) ||
|
||||
(candidate.startsWith("'") && candidate.endsWith("'"))
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return candidate.slice(1, -1);
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return candidate;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isWindowsShellCommand(command: string): boolean {
|
||||
const normalized = command.replace(/^["']|["']$/g, "");
|
||||
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
|
||||
}
|
||||
|
||||
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
||||
@@ -236,6 +263,14 @@ function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
||||
const unwrapped = parts[2]?.trim();
|
||||
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(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
type ACPBeforeModeWriteResult,
|
||||
type ACPProviderModeWriteResult,
|
||||
type ACPProviderModeWriterContext,
|
||||
type SessionStateResponse,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
@@ -22,21 +29,31 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const COPILOT_MODES: AgentMode[] = [
|
||||
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
||||
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: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
id: COPILOT_AGENT_MODE_ID,
|
||||
label: "Agent",
|
||||
description: "Default agent mode for conversational interactions",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||
id: COPILOT_PLAN_MODE_ID,
|
||||
label: "Plan",
|
||||
description: "Plan mode for creating and executing multi-step plans",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
label: "Autopilot",
|
||||
description: "Autonomous mode that runs until task completion without user interaction",
|
||||
id: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
label: "Allow All",
|
||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -53,6 +70,11 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
modeIdTransformer: transformCopilotModeId,
|
||||
providerModeWriter: writeCopilotProviderMode,
|
||||
beforeModeWriter: beforeCopilotModeWriter,
|
||||
capabilities: COPILOT_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
@@ -113,3 +135,126 @@ 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,6 +78,14 @@ async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Pr
|
||||
return result;
|
||||
}
|
||||
|
||||
function createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
|
||||
return (async function* () {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function isBinaryInstalled(binary: string): boolean {
|
||||
try {
|
||||
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
|
||||
@@ -566,6 +574,160 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
@@ -580,6 +742,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
"/tmp/opencode-storage",
|
||||
new Map(),
|
||||
undefined,
|
||||
false,
|
||||
@@ -607,6 +770,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
"/tmp/opencode-storage",
|
||||
);
|
||||
|
||||
await session.close();
|
||||
@@ -615,19 +779,29 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
|
||||
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
|
||||
// Async iterable that never yields and never resolves. The IIFE in
|
||||
// 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.
|
||||
// Yield the server-connected event, then park forever. The adapter waits
|
||||
// for that first event before sending the prompt.
|
||||
const neverYieldingStream: AsyncIterable<OpenCodeEvent> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => new Promise(() => {}),
|
||||
}),
|
||||
[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(() => {});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const fakeClient = {
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
|
||||
global: {
|
||||
event: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
|
||||
},
|
||||
session: {
|
||||
promptAsync: vi.fn(() => {
|
||||
@@ -641,6 +815,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
"/tmp/opencode-storage",
|
||||
);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -655,6 +830,58 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
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", () => {
|
||||
@@ -740,3 +967,45 @@ function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unk
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
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,33 +13,34 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListModelsOptions,
|
||||
ListModesOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type ListModelsOptions,
|
||||
type ListModesOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type McpServerConfig,
|
||||
type PersistedAgentDescriptor,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
@@ -74,6 +75,8 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
const OPENCODE_BUILD_MODE_ID = "build";
|
||||
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
|
||||
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[] = [
|
||||
{
|
||||
@@ -818,18 +821,32 @@ async function readOpenCodeSessionTimeline(
|
||||
}
|
||||
|
||||
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 partFiles = await findJsonFiles(partRoot);
|
||||
const parts: OpenCodeStoredPart[] = [];
|
||||
for (const file of partFiles) {
|
||||
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
|
||||
if (parsed?.type === "text" && typeof parsed.text === "string") {
|
||||
if (parsed) {
|
||||
parts.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.sort(
|
||||
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
|
||||
);
|
||||
}
|
||||
|
||||
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
|
||||
return parts
|
||||
.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@@ -950,7 +967,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
@@ -984,9 +1001,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
client,
|
||||
session.id,
|
||||
this.logger,
|
||||
this.storageRoot,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.release,
|
||||
options?.persistSession,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -997,7 +1016,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
||||
if (!cwd) {
|
||||
@@ -1025,8 +1044,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
client,
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
this.storageRoot,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.release,
|
||||
undefined,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -1258,6 +1280,28 @@ export interface OpenCodeEventTranslationState {
|
||||
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<
|
||||
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||
{ type: "tool" }
|
||||
@@ -2122,6 +2166,24 @@ function createDeferred<T>(): Deferred<T> {
|
||||
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 {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
@@ -2134,6 +2196,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private currentMode: string = "default";
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private abortController: AbortController | null = null;
|
||||
private pendingAbortPromise: Promise<void> | null = null;
|
||||
private accumulatedUsage: AgentUsage = {};
|
||||
private mcpConfigured = false;
|
||||
private mcpSetupPromise: Promise<void> | null = null;
|
||||
@@ -2157,19 +2220,22 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private releaseServer: (() => void) | null;
|
||||
private readonly persistSession: boolean;
|
||||
private deletedFromProvider = false;
|
||||
private retryFailureTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
_storageRoot: string,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
releaseServer?: () => void,
|
||||
persistSession = true,
|
||||
private readonly agentId?: string,
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger;
|
||||
this.logger = logger.child({ agentId: this.agentId });
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.releaseServer = releaseServer ?? null;
|
||||
@@ -2223,9 +2289,18 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const turnId = this.activeForegroundTurnId;
|
||||
const turnAbortController = this.abortController;
|
||||
turnAbortController?.abort();
|
||||
await this.client.session.abort({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
|
||||
// the running tool actually stops, which can be tens of seconds for
|
||||
// long-running tools. Cap the wait so the user-visible cancel lands
|
||||
// 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) {
|
||||
this.finishForegroundTurn(
|
||||
@@ -2235,6 +2310,46 @@ 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(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
@@ -2242,11 +2357,13 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
if (this.activeForegroundTurnId) {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
await this.awaitPendingAbortBeforeStartingTurn();
|
||||
|
||||
this.runningToolCalls.clear();
|
||||
this.subAgentsByCallId.clear();
|
||||
this.subAgentCallIdByChildSessionId.clear();
|
||||
this.pendingChildToolPartsBySessionId.clear();
|
||||
this.clearRetryFailureTimer();
|
||||
const turnAbortController = new AbortController();
|
||||
this.abortController = turnAbortController;
|
||||
await this.ensureMcpServersConfigured();
|
||||
@@ -2380,6 +2497,14 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
// SDK input validation) is caught alongside async rejections. A plain
|
||||
// `.then().catch()` chain would let a sync throw escape unhandled.
|
||||
void (async () => {
|
||||
this.traceOpenCode("provider.opencode.prompt_async.start", {
|
||||
turnId,
|
||||
sessionId: this.sessionId,
|
||||
model,
|
||||
effectiveMode,
|
||||
effectiveVariant,
|
||||
partTypes: parts.map((p) => p.type),
|
||||
});
|
||||
try {
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
@@ -2398,6 +2523,12 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
});
|
||||
this.traceOpenCode("provider.opencode.prompt_async.response", {
|
||||
turnId,
|
||||
hasError: promptResponse.error !== undefined,
|
||||
error: promptResponse.error,
|
||||
data: promptResponse.data,
|
||||
});
|
||||
if (promptResponse.error) {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
@@ -2409,6 +2540,13 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
} 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(
|
||||
{
|
||||
type: "turn_failed",
|
||||
@@ -2436,36 +2574,51 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
turnAbortController: AbortController,
|
||||
subscriptionReady: Deferred<void>,
|
||||
): Promise<void> {
|
||||
this.traceOpenCode("provider.opencode.subscribe.start", {
|
||||
turnId,
|
||||
sessionId: this.sessionId,
|
||||
cwd: this.config.cwd,
|
||||
});
|
||||
try {
|
||||
const result = await this.client.event.subscribe(
|
||||
{ directory: this.config.cwd },
|
||||
{ signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
|
||||
);
|
||||
subscriptionReady.resolve();
|
||||
|
||||
for await (const event of result.stream) {
|
||||
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
|
||||
break;
|
||||
const result = await this.client.global.event({
|
||||
signal: turnAbortController.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
});
|
||||
let eventCount = 0;
|
||||
let subscriptionReadyResolved = false;
|
||||
for await (const rawEvent of result.stream) {
|
||||
eventCount += 1;
|
||||
if (!subscriptionReadyResolved) {
|
||||
subscriptionReadyResolved = true;
|
||||
this.traceOpenCode("provider.opencode.subscribe.ready", {
|
||||
turnId,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
subscriptionReady.resolve();
|
||||
}
|
||||
|
||||
const translated = await this.translateEvent(event);
|
||||
for (const e of translated) {
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (e.type === "timeline" && e.item.type === "tool_call") {
|
||||
this.trackToolCall(e.item);
|
||||
}
|
||||
const terminalEvent = toTerminalTurnEvent(e);
|
||||
if (terminalEvent) {
|
||||
this.finishForegroundTurn(terminalEvent, turnId);
|
||||
return;
|
||||
}
|
||||
this.notifySubscribers(e, turnId);
|
||||
const shouldContinue = await this.consumeOpenCodeStreamEvent({
|
||||
rawEvent,
|
||||
eventCount,
|
||||
turnId,
|
||||
turnAbortController,
|
||||
});
|
||||
if (!shouldContinue) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.traceOpenCode("provider.opencode.stream.eof", {
|
||||
turnId,
|
||||
eventCount,
|
||||
aborted: turnAbortController.signal.aborted,
|
||||
stillActive: 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(
|
||||
{
|
||||
type: "turn_failed",
|
||||
@@ -2476,6 +2629,11 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.traceOpenCode("provider.opencode.subscribe.error", {
|
||||
turnId,
|
||||
error:
|
||||
error instanceof Error ? { name: error.name, message: error.message } : String(error),
|
||||
});
|
||||
subscriptionReady.reject(error);
|
||||
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
|
||||
this.finishForegroundTurn(
|
||||
@@ -2504,10 +2662,80 @@ 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(
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
turnId: string,
|
||||
): 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) {
|
||||
return;
|
||||
}
|
||||
@@ -2516,6 +2744,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
} else {
|
||||
this.runningToolCalls.clear();
|
||||
}
|
||||
this.clearRetryFailureTimer();
|
||||
this.activeForegroundTurnId = null;
|
||||
// Abort the SSE connection so the SDK tears down the underlying fetch.
|
||||
this.abortController?.abort();
|
||||
@@ -2531,6 +2760,44 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
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 {
|
||||
for (const item of this.runningToolCalls.values()) {
|
||||
const error = { message: "Tool execution aborted" };
|
||||
@@ -2562,6 +2829,10 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
|
||||
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
this.traceOpenCode("provider.opencode.event_emit", {
|
||||
turnId: getAgentStreamEventTurnId(tagged),
|
||||
event: tagged,
|
||||
});
|
||||
for (const callback of this.subscribers) {
|
||||
try {
|
||||
callback(tagged);
|
||||
@@ -2575,6 +2846,19 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
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> {
|
||||
const response = await this.client.session.messages({
|
||||
sessionID: this.sessionId,
|
||||
|
||||
@@ -47,6 +47,7 @@ export class TestOpenCodeClient {
|
||||
appAgents: [] as unknown[],
|
||||
commandList: [] as unknown[],
|
||||
eventSubscribe: [] as unknown[],
|
||||
globalEvent: [] as unknown[],
|
||||
permissionReply: [] as unknown[],
|
||||
providerList: [] as unknown[],
|
||||
questionReject: [] as unknown[],
|
||||
@@ -99,6 +100,12 @@ export class TestOpenCodeClient {
|
||||
return { stream: this.eventStream };
|
||||
},
|
||||
},
|
||||
global: {
|
||||
event: async (options: unknown) => {
|
||||
this.calls.globalEvent.push(options);
|
||||
return { stream: this.eventStream };
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
add: async () => ({}),
|
||||
connect: async () => ({}),
|
||||
|
||||
@@ -30,29 +30,30 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ToolCallDetail,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type ListModesOptions,
|
||||
type ListModelsOptions,
|
||||
type ToolCallDetail,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
@@ -738,21 +739,6 @@ function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersis
|
||||
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 {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
@@ -1045,7 +1031,7 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTurnId = getStreamEventTurnId(event);
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentRunResult,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
export type ProviderFinalTextReducer = (params: {
|
||||
@@ -44,7 +45,7 @@ export async function runProviderTurn({
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const eventTurnId = "turnId" in event ? event.turnId : undefined;
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentSession,
|
||||
type AgentStreamEvent,
|
||||
} from "../../agent-sdk-types.js";
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
@@ -29,7 +30,7 @@ export async function* streamSession(
|
||||
};
|
||||
|
||||
const matchesTurn = (event: AgentStreamEvent): boolean => {
|
||||
const eventTurnId = (event as { turnId?: string }).turnId;
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
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