Compare commits

..
150 changed files with 4207 additions and 8515 deletions
-5
View File
@@ -5,13 +5,8 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
workflow_dispatch: workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+72
View File
@@ -0,0 +1,72 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates (main push only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push
-60
View File
@@ -1,60 +0,0 @@
name: Nix Update Hash
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-update-hash.yml"
permissions:
contents: read
jobs:
update-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates
run: |
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
git config user.name "paseo-ai[bot]"
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
git add package-lock.json nix/npm-deps.hash
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
git push
-99
View File
@@ -1,99 +0,0 @@
name: Nix
on:
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Smoke Nix daemon
run: |
set -euo pipefail
export PASEO_HOME
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
kill "$DAEMON_PID"
wait "$DAEMON_PID" || true
fi
rm -rf "$PASEO_HOME"
}
trap cleanup EXIT
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
echo "$STATUS_JSON"
exit 0
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "Nix daemon exited before becoming reachable."
break
fi
sleep 1
done
echo "::group::daemon.log"
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo daemon status"
./result/bin/paseo daemon status || true
echo "::endgroup::"
exit 1
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop
-51
View File
@@ -1,55 +1,5 @@
# Changelog # Changelog
## 0.1.75 - 2026-05-12
### Added
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
- NixOS: `services.paseo.settings` renders declarative daemon config to disk, and typed `services.paseo.relay` options auto-wire the relay endpoint. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
- NixOS: Paseo desktop is now packaged in the flake — `nix run github:getpaseo/paseo#desktop` launches the Electron app. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
### Fixed
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
- Copilot's **Allow All** mode (previously misnamed Autopilot) now actually suppresses tool, path, and URL permission prompts. ([#935](https://github.com/getpaseo/paseo/pull/935))
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
- iPhone HEIC photos now attach correctly from the image picker. ([#934](https://github.com/getpaseo/paseo/pull/934))
- Scheduled agents now archive automatically after each run instead of piling up in the active list. ([#945](https://github.com/getpaseo/paseo/pull/945))
- Windows: Codex command summaries show the underlying command instead of `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
## 0.1.74 - 2026-05-11
### Fixed
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
## 0.1.73 - 2026-05-10
### Fixed
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
## 0.1.72 - 2026-05-10
### Fixed
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
## 0.1.71 - 2026-05-09 ## 0.1.71 - 2026-05-09
### Added ### Added
@@ -73,7 +23,6 @@
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831)) - iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830)) - System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link. - Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
### Security ### Security
-24
View File
@@ -59,30 +59,6 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`) - `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI - `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
--- ---
## Z.AI (Zhipu) coding plan ## Z.AI (Zhipu) coding plan
+2 -2
View File
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
providers: Record<providerId, ProviderOverride> providers: Record<providerId, ProviderOverride>
}, },
features: { features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
}, },
log: { log: {
level, format, level, format,
+1 -8
View File
@@ -39,14 +39,7 @@ In any worktree-style or portless setup, never assume default ports.
### Daemon logs ### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set Check `$PASEO_HOME/daemon.log` for trace-level logs.
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
## paseo.json service scripts ## paseo.json service scripts
-49
View File
@@ -1,49 +0,0 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
-7
View File
@@ -26,18 +26,11 @@
let let
pkgs = pkgsFor system; pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { }; paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
];
in in
{ {
default = paseo; default = paseo;
paseo = paseo; paseo = paseo;
} }
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
}
); );
nixosModules.default = self.nixosModules.paseo; nixosModules.default = self.nixosModules.paseo;
-157
View File
@@ -1,157 +0,0 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
# different value.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
pname = "paseo-desktop";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store"
&& baseName != "release";
};
nodejs = nodejs_22;
inherit npmDepsHash;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
EXPO_NO_TELEMETRY = "1";
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
};
buildPhase = ''
runHook preBuild
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# App workspace deps not covered by build:daemon
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only — NOT electron-builder)
npm run build:main --workspace=@getpaseo/desktop
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
copyDesktopItems
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
})
];
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
};
}
+3 -93
View File
@@ -81,48 +81,7 @@ in
enable = lib.mkOption { enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = true; default = true;
description = '' description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
Whether to enable relay-based remote access. When false, the daemon
runs with `--no-relay` and only accepts direct (LAN/loopback)
connections.
'';
};
mode = lib.mkOption {
type = lib.types.enum [ "hosted" "remote" ];
default = "hosted";
description = ''
How the daemon reaches the relay when `relay.enable = true`:
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
Preserves the current behavior; no extra options needed.
- `"remote"`: connect to a self-hosted relay at
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
`PASEO_RELAY_USE_TLS` for the daemon.
A `"local"` mode (running a relay on the same host as a systemd
unit) is not yet implemented — the relay package currently only
ships a Cloudflare Workers adapter. Tracked separately.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "";
example = "relay.example.com";
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Relay port. Used when `relay.mode = \"remote\"`.";
};
useTls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
}; };
}; };
@@ -152,50 +111,9 @@ in
''; '';
description = "Extra environment variables for the Paseo daemon."; description = "Extra environment variables for the Paseo daemon.";
}; };
settings = lib.mkOption {
type = (pkgs.formats.json { }).type;
default = { };
example = lib.literalExpression ''
{
daemon.mcp = { enabled = true; injectIntoAgents = false; };
agents.providers.myAcp = {
extends = "acp";
label = "My Agent";
command = { path = "/run/current-system/sw/bin/my-acp"; };
};
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
}
'';
description = ''
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
and installed on every service start.
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
or the mobile app toggling MCP injection / provider overrides) are
overwritten on the next restart. Pick one: manage via this option, or
manage via the CLI — not both.
The full schema is defined by `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`.
'';
};
}; };
config = lib.mkIf cfg.enable ( config = lib.mkIf cfg.enable {
let
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
in
{
assertions = [
{
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
message = ''
services.paseo.relay.host must be set when relay.mode = "remote".
'';
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") { users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true; isSystemUser = true;
group = cfg.group; group = cfg.group;
@@ -213,10 +131,6 @@ in
after = [ "network.target" ]; after = [ "network.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
preStart = lib.mkIf (cfg.settings != { }) ''
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
'';
environment = { environment = {
NODE_ENV = "production"; NODE_ENV = "production";
PASEO_HOME = cfg.dataDir; PASEO_HOME = cfg.dataDir;
@@ -235,9 +149,6 @@ in
PASEO_HOSTNAMES = "true"; PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) { } // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames; PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // cfg.environment; } // cfg.environment;
serviceConfig = { serviceConfig = {
@@ -261,6 +172,5 @@ in
environment.systemPackages = [ cfg.package ]; environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
} };
);
} }
-1
View File
@@ -1 +0,0 @@
sha256-LczD9EmK6LuaJuZQu1v/q8zBE92LVynRR85dp6IdfCo=
+3 -12
View File
@@ -7,15 +7,6 @@
makeWrapper, makeWrapper,
# node-pty needs libuv headers on Linux # node-pty needs libuv headers on Linux
libuv, libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
# can override via `.override { npmDepsHash = "sha256-..."; }` without
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
#
# The default is read from a sidecar file so the CI auto-updater can replace
# the hash with a single file write instead of a sed against this source.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}: }:
buildNpmPackage rec { buildNpmPackage rec {
@@ -49,9 +40,9 @@ buildNpmPackage rec {
nodejs = nodejs_22; nodejs = nodejs_22;
# Default hash lives in nix/npm-deps.hash (see arg default above). # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates that file when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash; npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+20 -36
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.75", "version": "0.1.71",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.75", "version": "0.1.71",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -9397,6 +9397,12 @@
"node": ">=20.0" "node": ">=20.0"
} }
}, },
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": { "node_modules/@opentelemetry/api": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -21769,18 +21775,6 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": { "node_modules/expo-image-picker": {
"version": "17.0.10", "version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
@@ -38860,7 +38854,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.75", "version": "0.1.71",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -38898,7 +38892,6 @@
"expo-file-system": "~19.0.17", "expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8", "expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7", "expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
@@ -38987,10 +38980,10 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.75", "version": "0.1.71",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.75", "@getpaseo/server": "0.1.71",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -39033,7 +39026,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.75", "version": "0.1.71",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -39082,7 +39075,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.75", "version": "0.1.71",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
@@ -39118,7 +39111,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.75", "version": "0.1.71",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -39144,7 +39137,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.75", "version": "0.1.71",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -39159,18 +39152,18 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.75", "version": "0.1.71",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133", "@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.75", "@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.75", "@getpaseo/relay": "0.1.71",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2", "@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0", "@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
@@ -39351,15 +39344,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"packages/server/node_modules/@opencode-ai/sdk": {
"version": "1.14.46",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
"integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/accepts": { "packages/server/node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -39707,7 +39691,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.75", "version": "0.1.71",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.75", "version": "0.1.71",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API", "description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [ "keywords": [
+2 -2
View File
@@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
} }
export async function openMobileAgentSidebar(page: Page): Promise<void> { export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click(); await page.getByTestId("menu-button").click();
} }
// force=true: the overlay covers the button when the mobile sidebar is open. // force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> { export async function closeMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Close menu" }).click({ force: true }); await page.getByTestId("menu-button").click({ force: true });
} }
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. // The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.75", "version": "0.1.71",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -63,7 +63,6 @@
"expo-file-system": "~19.0.17", "expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8", "expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7", "expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
@@ -19,7 +19,7 @@ import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { isNative, isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
type EscHandler = () => void; type EscHandler = () => void;
const escStack: EscHandler[] = []; const escStack: EscHandler[] = [];
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) { function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
if (isMobile && isNative) { if (isMobile) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />; return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
} }
@@ -12,7 +12,7 @@ import {
import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
@@ -443,7 +443,7 @@ function ProviderSearchInput({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput; const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {}; if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
+3 -6
View File
@@ -791,13 +791,10 @@ function DesktopSidebar({
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
const desktopSidebarStyle = useMemo( const desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle], () => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
[resizeAnimatedStyle], [resizeAnimatedStyle, insetsTop],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
); );
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
const resizeHandleStyle = useMemo( const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[], [],
+2 -2
View File
@@ -38,7 +38,7 @@ import {
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isNative, isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
@@ -148,7 +148,7 @@ export function SearchInput({
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput; const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
});
});
});
@@ -1,85 +0,0 @@
import type { PaseoConfigRaw } from "@server/shared/messages";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
interface ReadProjectConfigResult {
ok: boolean;
config?: PaseoConfigRaw | null;
}
export interface WorktreeSetupCalloutPolicy {
id: string;
dismissalKey: string;
priority: number;
title: string;
description: string;
actionLabel: string;
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
testID: string;
}
export function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorktreeSetupWorkspaceInput,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
}
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
@@ -0,0 +1,279 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
const activeSelection = vi.hoisted(() => ({
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
serverId: string;
workspaceId: string;
} | null,
}));
const activeWorkspace = vi.hoisted(() => ({
value: {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
} as Record<string, unknown> | null,
}));
const client = vi.hoisted(() => ({
readProjectConfig: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceFields: (
serverId: string | null,
workspaceId: string | null,
project: (workspace: Record<string, unknown>) => unknown,
) => {
if (
!activeWorkspace.value ||
serverId !== activeSelection.value?.serverId ||
workspaceId !== activeWorkspace.value.id
) {
return null;
}
return project(activeWorkspace.value);
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
function readOk(config: Record<string, unknown>) {
return {
ok: true,
config,
revision: { exists: true, mtimeMs: 1, size: 2 },
};
}
function readError() {
return {
ok: false,
error: { code: "project_not_found", message: "Project not found" },
};
}
function Harness({ queryClient }: { queryClient: QueryClient }) {
return (
<QueryClientProvider client={queryClient}>
<SidebarCalloutProvider>
<WorktreeSetupCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
</QueryClientProvider>
);
}
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
await act(async () => {
root.render(<Harness queryClient={queryClient} />);
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
async function findByTestId(testID: string): Promise<HTMLElement | null> {
let element: HTMLElement | null = null;
for (let index = 0; index < 10 && !element; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
}
return element;
}
describe("WorktreeSetupCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
};
client.readProjectConfig.mockReset();
client.readProjectConfig.mockResolvedValue(readOk({}));
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
}
queryClient?.clear();
queryClient = null;
root = null;
container?.remove();
container = null;
});
it("registers a callout for an active git workspace with missing setup", async () => {
await renderHarness(root!, queryClient!);
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
expect(container?.textContent).toContain("Set up worktree scripts");
expect(container?.textContent).toContain("Open project settings");
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
});
it("does not register a callout for a non-git workspace", async () => {
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "local",
projectRootPath: "/repo/project-1",
};
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when setup is present", async () => {
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("does not register a callout without an active workspace", async () => {
activeSelection.value = null;
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when reading paseo.json fails", async () => {
client.readProjectConfig.mockResolvedValue(readError());
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("opens project settings from the callout action", async () => {
await renderHarness(root!, queryClient!);
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
});
it("persists dismissal for the project", async () => {
await renderHarness(root!, queryClient!);
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["worktree-setup-missing:project-1"]),
);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
});
@@ -1,16 +1,48 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { import type { WorkspaceDescriptor } from "@/stores/session-store";
buildWorktreeSetupCalloutPolicy, import { buildProjectSettingsRoute } from "@/utils/host-routes";
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout, interface ActiveGitWorkspaceProject {
} from "./worktree-setup-callout-policy"; serverId: string;
projectKey: string;
repoRoot: string;
}
function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorkspaceDescriptor,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
export function WorktreeSetupCalloutSource() { export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
@@ -26,7 +58,7 @@ export function WorktreeSetupCalloutSource() {
if (!activeProject) { if (!activeProject) {
return; return;
} }
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute); router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
}); });
const readQuery = useQuery({ const readQuery = useQuery({
@@ -41,31 +73,29 @@ export function WorktreeSetupCalloutSource() {
retry: false, retry: false,
}); });
const calloutPolicy = useMemo( const shouldShow =
() => activeProject !== null &&
activeProject && shouldShowWorktreeSetupCallout(readQuery.data) readQuery.data?.ok === true &&
? buildWorktreeSetupCalloutPolicy(activeProject) !hasSetupCommands(readQuery.data.config ?? {});
: null,
[activeProject, readQuery.data],
);
useEffect(() => { useEffect(() => {
if (!calloutPolicy) { if (!shouldShow || !activeProject) {
return; return;
} }
return callouts.show({ return callouts.show({
id: calloutPolicy.id, id: `worktree-setup-missing:${activeProject.projectKey}`,
dismissalKey: calloutPolicy.dismissalKey, dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
priority: calloutPolicy.priority, priority: 100,
title: calloutPolicy.title, title: "Set up worktree scripts",
description: calloutPolicy.description, description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actions: [ actions: [
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" }, { label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
], ],
testID: calloutPolicy.testID, testID: `worktree-setup-callout-${activeProject.projectKey}`,
}); });
}, [calloutPolicy, callouts, openProjectSettings]); }, [activeProject, callouts, openProjectSettings, shouldShow]);
return null; return null;
} }
@@ -0,0 +1,224 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import {
SidebarCalloutProvider,
type SidebarCalloutsApi,
SidebarCalloutViewport,
useSidebarCallouts,
} from "./sidebar-callout-context";
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
function handleApi(nextApi: SidebarCalloutsApi): void {
apiSink.current = nextApi;
}
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
const api = useSidebarCallouts();
onApi(api);
return null;
}
describe("SidebarCalloutProvider", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let api: SidebarCalloutsApi | null = null;
beforeEach(async () => {
api = null;
apiSink.current = null;
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
api = apiSink.current;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
api = null;
});
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
act(() => {
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
api?.show({ id: "update", priority: 200, title: "Update available" });
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).not.toContain("Set up scripts");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).toContain("Set up scripts");
expect(container?.textContent).not.toContain("Update available");
});
it("replaces a callout by id without duplicating the queue item", () => {
act(() => {
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
});
expect(container?.textContent).toContain("New daemon");
expect(container?.textContent).toContain("v2");
expect(container?.textContent).not.toContain("Old daemon");
});
it("keeps API consumers from rerendering when callout state changes", () => {
const renders = vi.fn();
function Producer() {
const callouts = useSidebarCallouts();
renders(callouts);
useEffect(() => {
callouts.show({ id: "initial", title: "Initial" });
}, [callouts]);
return null;
}
act(() => {
root?.render(
<SidebarCalloutProvider>
<Producer />
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
});
api = apiSink.current;
const firstApi = renders.mock.calls[0]?.[0];
act(() => {
api?.show({ id: "later", priority: 10, title: "Later" });
});
expect(renders).toHaveBeenCalledTimes(1);
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
});
it("unregisters only the registration returned by show", () => {
let unregisterOld: (() => void) | null = null;
act(() => {
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
api?.show({ id: "update", title: "New" });
});
act(() => {
unregisterOld?.();
});
expect(container?.textContent).toContain("New");
});
it("persists dismissals by dismissal key", () => {
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
});
});
expect(container?.textContent).toContain("Update available");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).not.toContain("Update available");
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["desktop-update:available:1.2.3"]),
);
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
});
});
expect(container?.textContent).not.toContain("Dismissed update");
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
});
});
expect(container?.textContent).toContain("New update");
});
});
@@ -8,24 +8,27 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
import { useStableEvent } from "@/hooks/use-stable-event";
import { import {
clearSidebarCallouts, SidebarCallout,
createSidebarCalloutState, type SidebarCalloutAction,
dismissSidebarCallout, type SidebarCalloutProps,
loadDismissedCalloutKeys, type SidebarCalloutVariant,
parseDismissedCalloutKeys, } from "@/components/sidebar-callout";
selectActiveSidebarCallout, import { useStableEvent } from "@/hooks/use-stable-event";
serializeDismissedCalloutKeys,
showSidebarCallout,
type SidebarCalloutEntry,
type SidebarCalloutOptions,
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
export type { SidebarCalloutOptions } from "./sidebar-callout-state"; export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutsApi { export interface SidebarCalloutsApi {
show: (callout: SidebarCalloutOptions) => () => void; show: (callout: SidebarCalloutOptions) => () => void;
@@ -33,48 +36,96 @@ export interface SidebarCalloutsApi {
clear: () => void; clear: () => void;
} }
type SidebarCalloutEntry = SidebarCalloutOptions & {
order: number;
priority: number;
token: number;
};
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals"; const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null); const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null); const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void { function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem( void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
DISMISSED_CALLOUTS_STORAGE_KEY, (error) => {
serializeDismissedCalloutKeys(keys), console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
).catch((error) => { },
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error); );
}
function selectActiveCallout(input: {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
}): SidebarCalloutEntry | null {
const visibleCallouts = input.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
}); });
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
} }
export function SidebarCalloutProvider({ children }: { children: ReactNode }) { export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState); const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
const stateRef = useRef<SidebarCalloutState>(state); const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
function commitState(next: SidebarCalloutState): void { const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
stateRef.current = next; const dismissedKeysRef = useRef<Set<string>>(new Set());
setState(next); const orderRef = useRef(0);
} const tokenRef = useRef(0);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
async function loadDismissedKeys(): Promise<void> { .then((value) => {
let dismissedKeys: ReadonlySet<string>; if (!mounted) {
try { return;
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY); }
dismissedKeys = parseDismissedCalloutKeys(value); const nextKeys = parseDismissedCalloutKeys(value);
} catch (error) { dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
return;
})
.catch((error) => {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error); console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
dismissedKeys = stateRef.current.dismissedKeys; })
} .finally(() => {
if (mounted) {
if (mounted) { setDismissalStorageLoaded(true);
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys)); }
} });
}
void loadDismissedKeys();
return () => { return () => {
mounted = false; mounted = false;
@@ -82,33 +133,60 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const show = useStableEvent((callout: SidebarCalloutOptions) => { const show = useStableEvent((callout: SidebarCalloutOptions) => {
const result = showSidebarCallout(stateRef.current, callout); tokenRef.current += 1;
commitState(result.state); const token = tokenRef.current;
const current = calloutsRef.current;
const existing = current.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? ++orderRef.current,
token,
};
const next = existing
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...current, nextEntry];
calloutsRef.current = next;
setCallouts(next);
return () => { return () => {
commitState( const updated = calloutsRef.current.filter(
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }), (entry) => entry.id !== callout.id || entry.token !== token,
); );
calloutsRef.current = updated;
setCallouts(updated);
}; };
}); });
const dismiss = useStableEvent((id: string) => { const dismiss = useStableEvent((id: string) => {
const result = dismissSidebarCallout(stateRef.current, id); const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
commitState(result.state); const next = calloutsRef.current.filter((entry) => entry.id !== id);
calloutsRef.current = next;
setCallouts(next);
if (result.dismissalKey) { const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
persistDismissedCalloutKeys(result.state.dismissedKeys); if (dismissalKey) {
const nextKeys = new Set(dismissedKeysRef.current);
nextKeys.add(dismissalKey);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
persistDismissedCalloutKeys(nextKeys);
} }
result.dismissedCallout?.onDismiss?.(); dismissed?.onDismiss?.();
}); });
const clear = useStableEvent(() => { const clear = useStableEvent(() => {
commitState(clearSidebarCallouts(stateRef.current)); calloutsRef.current = [];
setCallouts([]);
}); });
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]); const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]); const activeCallout = useMemo(
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
[callouts, dismissedKeys, dismissalStorageLoaded],
);
return ( return (
<SidebarCalloutApiContext.Provider value={api}> <SidebarCalloutApiContext.Provider value={api}>
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
clearSidebarCallouts,
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
describe("sidebar callout state", () => {
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "onboarding",
priority: 10,
title: "Set up scripts",
}).state;
state = showSidebarCallout(state, {
id: "update",
priority: 200,
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
state = dismissSidebarCallout(state, "update").state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
});
it("replaces a callout by id without duplicating the queue item", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "daemon",
title: "Old daemon",
description: "v1",
}).state;
state = showSidebarCallout(state, {
id: "daemon",
title: "New daemon",
description: "v2",
}).state;
expect(state.callouts).toMatchObject([
{
id: "daemon",
title: "New daemon",
description: "v2",
},
]);
});
it("unregisters only the registration returned by show", () => {
let state = createSidebarCalloutState();
const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" });
state = oldRegistration.state;
state = showSidebarCallout(state, { id: "update", title: "New" }).state;
state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token });
expect(selectActiveSidebarCallout(state)?.title).toBe("New");
});
it("persists dismissals by dismissal key and hides matching future callouts", () => {
const onDismiss = vi.fn();
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set());
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
onDismiss,
}).state;
const result = dismissSidebarCallout(state, "update");
state = result.state;
expect(result.dismissalKey).toBe("desktop-update:available:1.2.3");
expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe(
JSON.stringify(["desktop-update:available:1.2.3"]),
);
expect(onDismiss).not.toHaveBeenCalled();
result.dismissedCallout?.onDismiss?.();
expect(onDismiss).toHaveBeenCalledOnce();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("New update");
});
it("waits for dismissal storage before showing dismissible callouts", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = loadDismissedCalloutKeys(state, new Set());
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
});
it("parses stored dismissal keys defensively", () => {
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
});
it("clears visible callouts without dropping dismissal state", () => {
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
state = clearSidebarCallouts(state);
expect(state.callouts).toEqual([]);
expect(state.dismissedKeys).toEqual(new Set(["dismissed"]));
});
});
@@ -1,162 +0,0 @@
import type { ReactNode } from "react";
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
order: number;
priority: number;
token: number;
}
export interface SidebarCalloutState {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
nextOrder: number;
nextToken: number;
}
export function createSidebarCalloutState(): SidebarCalloutState {
return {
callouts: [],
dismissedKeys: new Set(),
dismissalStorageLoaded: false,
nextOrder: 0,
nextToken: 0,
};
}
export function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
return JSON.stringify([...keys]);
}
export function loadDismissedCalloutKeys(
state: SidebarCalloutState,
dismissedKeys: ReadonlySet<string>,
): SidebarCalloutState {
return {
...state,
dismissedKeys: new Set(dismissedKeys),
dismissalStorageLoaded: true,
};
}
export function showSidebarCallout(
state: SidebarCalloutState,
callout: SidebarCalloutOptions,
): { state: SidebarCalloutState; token: number } {
const token = state.nextToken + 1;
const existing = state.callouts.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? state.nextOrder + 1,
token,
};
const callouts = existing
? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...state.callouts, nextEntry];
return {
state: {
...state,
callouts,
nextOrder: existing ? state.nextOrder : state.nextOrder + 1,
nextToken: token,
},
token,
};
}
export function unregisterSidebarCallout(
state: SidebarCalloutState,
input: { id: string; token: number },
): SidebarCalloutState {
const callouts = state.callouts.filter(
(entry) => entry.id !== input.id || entry.token !== input.token,
);
return callouts.length === state.callouts.length ? state : { ...state, callouts };
}
export function dismissSidebarCallout(
state: SidebarCalloutState,
id: string,
): {
state: SidebarCalloutState;
dismissedCallout: SidebarCalloutEntry | null;
dismissalKey: string | null;
} {
const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null;
const callouts = state.callouts.filter((entry) => entry.id !== id);
const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey);
const dismissedKeys = dismissalKey
? new Set([...state.dismissedKeys, dismissalKey])
: state.dismissedKeys;
return {
state: {
...state,
callouts,
dismissedKeys,
},
dismissedCallout,
dismissalKey,
};
}
export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState {
return { ...state, callouts: [] };
}
export function selectActiveSidebarCallout(
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}
@@ -1,75 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("expo-image-manipulator", () => ({
SaveFormat: { PNG: "png" },
ImageManipulator: {
manipulate: (_source: string) => ({
async renderAsync() {
return {
release() {},
async saveAsync(options: { format?: string }) {
return {
uri:
options.format === "png"
? "file:///cache/ImageManipulator/safe-picked.png"
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
width: 100,
height: 100,
};
},
};
},
release() {},
}),
},
}));
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
describe("native image attachment picker", () => {
it("preserves native picked JPEG and PNG attachment inputs", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.JPG",
mimeType: "image/jpeg",
fileName: "picked.jpeg",
},
{
uri: "file:///photos/screenshot.png",
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
mimeType: "image/jpeg",
fileName: "picked.jpg",
},
{
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
});
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.HEIC",
mimeType: "image/png",
fileName: "picked.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
});
@@ -1,129 +0,0 @@
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
mimeType?: string | null;
fileName?: string | null;
}
export interface ExpoImagePickerAssetLike {
uri: string;
mimeType?: string | null;
fileName?: string | null;
file?: File | null;
}
interface SupportedPickedImageFormat {
mimeType: "image/jpeg" | "image/png";
extension: "jpg" | "png";
}
const JPEG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/jpeg",
extension: "jpg",
};
const PNG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/png",
extension: "png",
};
function extensionFromPath(path: string | null | undefined): string | null {
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
return match?.[1]?.toLowerCase() ?? null;
}
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
if (extension === "jpg" || extension === "jpeg") {
return JPEG_FORMAT;
}
if (extension === "png") {
return PNG_FORMAT;
}
return null;
}
function supportedFormatForMimeType(
mimeType: string | null | undefined,
): SupportedPickedImageFormat | null {
const normalizedMimeType = mimeType?.toLowerCase();
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
return JPEG_FORMAT;
}
if (normalizedMimeType === "image/png") {
return PNG_FORMAT;
}
return null;
}
function pickedAssetSupportedFormat(
asset: ExpoImagePickerAssetLike,
): SupportedPickedImageFormat | null {
const uriExtension = extensionFromPath(asset.uri);
if (uriExtension) {
return supportedFormatForExtension(uriExtension);
}
return (
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
supportedFormatForMimeType(asset.mimeType)
);
}
function replaceFileExtension(
fileName: string | null | undefined,
extension: SupportedPickedImageFormat["extension"],
): string | null {
if (!fileName) {
return null;
}
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
}
async function exportPickedImageAsPng(uri: string): Promise<string> {
const context = ImageManipulator.manipulate(uri);
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
try {
image = await context.renderAsync();
const result = await image.saveAsync({
format: SaveFormat.PNG,
});
return result.uri;
} finally {
image?.release();
context.release();
}
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
const supportedFormat = pickedAssetSupportedFormat(asset);
if (supportedFormat) {
return {
source: { kind: "file_uri", uri: asset.uri },
mimeType: supportedFormat.mimeType,
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
};
}
const convertedUri = await exportPickedImageAsPng(asset.uri);
return {
source: { kind: "file_uri", uri: convertedUri },
mimeType: PNG_FORMAT.mimeType,
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
};
}),
);
}
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
throw new Error("Desktop dialog API is not available on native.");
}
@@ -148,16 +148,9 @@ describe("keyboard-shortcuts", () => {
payload: { index: 2 }, payload: { index: 2 },
}, },
{ {
name: "matches tab index jump on mac desktop via Cmd+Alt+digit", name: "matches tab index jump on desktop via Alt+digit",
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
context: { isMac: true, isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on non-mac desktop via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true }, event: { key: "2", code: "Digit2", altKey: true },
context: { isMac: false, isDesktop: true }, context: { isDesktop: true },
action: "workspace.tab.navigate.index", action: "workspace.tab.navigate.index",
payload: { index: 2 }, payload: { index: 2 },
}, },
@@ -340,11 +333,6 @@ describe("keyboard-shortcuts", () => {
event: { key: "t", code: "KeyT", ctrlKey: true }, event: { key: "t", code: "KeyT", ctrlKey: true },
context: { isMac: true }, context: { isMac: true },
}, },
{
name: "keeps mac Option+digit available for international text input",
event: { key: "@", code: "Digit2", altKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
},
{ {
name: "does not match Ctrl+K for command center on non-mac in terminal", name: "does not match Ctrl+K for command center on non-mac in terminal",
event: { key: "k", code: "KeyK", ctrlKey: true }, event: { key: "k", code: "KeyK", ctrlKey: true },
@@ -489,17 +477,16 @@ describe("keyboard-shortcut help sections", () => {
"new-agent": ["mod", "shift", "O"], "new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"], "workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"], "workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"], "workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"], "workspace-tab-close-current": ["meta", "W"],
"workspace-pane-split-right": ["mod", "\\"], "workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"], "workspace-pane-close": ["mod", "shift", "W"],
}, },
}, },
{ {
name: "uses non-mac desktop defaults for tab jump and close tab", name: "shows Ctrl+W close tab for non-mac desktop",
context: { isMac: false, isDesktop: true }, context: { isMac: false, isDesktop: true },
expectedKeys: { expectedKeys: {
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["ctrl", "W"], "workspace-tab-close-current": ["ctrl", "W"],
}, },
}, },
@@ -292,24 +292,11 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
}, },
// --- Tab index jump --- // --- Tab index jump ---
{
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
action: "workspace.tab.navigate.index",
combo: "Cmd+Alt+Digit",
when: { mac: true, desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
label: "Jump to tab",
keys: ["mod", "alt", "1-9"],
},
},
{ {
id: "workspace-tab-navigate-index-alt-digit-desktop", id: "workspace-tab-navigate-index-alt-digit-desktop",
action: "workspace.tab.navigate.index", action: "workspace.tab.navigate.index",
combo: "Alt+Digit", combo: "Alt+Digit",
when: { mac: false, desktop: true, commandCenter: false }, when: { desktop: true, commandCenter: false },
payload: { type: "index" }, payload: { type: "index" },
help: { help: {
id: "workspace-tab-jump-index", id: "workspace-tab-jump-index",
-40
View File
@@ -1,40 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const expoCryptoMock = vi.hoisted(() => ({
getRandomValues: vi.fn(<T extends ArrayBufferView>(array: T): T => array),
randomUUID: vi.fn(() => {
throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback");
}),
}));
vi.mock("expo-crypto", () => expoCryptoMock);
describe("polyfillCrypto", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
expoCryptoMock.getRandomValues.mockClear();
expoCryptoMock.randomUUID.mockClear();
});
it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => {
const sourceBytes = Uint8Array.from([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
0xff,
]);
const getRandomValues = vi.fn(<T extends ArrayBufferView | null>(array: T): T => {
if (array && ArrayBuffer.isView(array)) {
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes);
}
return array;
});
vi.stubGlobal("crypto", { getRandomValues });
const { polyfillCrypto } = await import("./crypto");
polyfillCrypto();
expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff");
expect(getRandomValues).toHaveBeenCalledTimes(1);
expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled();
});
});
+8 -30
View File
@@ -13,26 +13,8 @@ interface MutableGlobal {
crypto?: Crypto; crypto?: Crypto;
} }
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
const bytes = fillRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
}
export function polyfillCrypto(): void { export function polyfillCrypto(): void {
const g = globalThis as unknown as MutableGlobal; const g = globalThis as unknown as MutableGlobal;
const nativeGetRandomValues =
typeof g.crypto?.getRandomValues === "function"
? g.crypto.getRandomValues.bind(g.crypto)
: null;
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport). // Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
// Hermes may not provide them in all configurations. // Hermes may not provide them in all configurations.
@@ -65,21 +47,17 @@ export function polyfillCrypto(): void {
g.crypto = {} as Crypto; g.crypto = {} as Crypto;
} }
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
if (nativeGetRandomValues) {
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
}
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
if (typeof g.crypto.randomUUID !== "function") { if (typeof g.crypto.randomUUID !== "function") {
g.crypto.randomUUID = () => createUuidV4(fillRandomValues); g.crypto.randomUUID = () =>
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
} }
if (typeof g.crypto.getRandomValues !== "function") { if (typeof g.crypto.getRandomValues !== "function") {
g.crypto.getRandomValues = fillRandomValues; g.crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
} }
} }
@@ -203,15 +203,6 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
}; };
} }
function encodeOfferUrl(payload: unknown): string {
const encoded = Buffer.from(JSON.stringify(payload), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
return `https://app.paseo.sh/#offer=${encoded}`;
}
function makeDeps( function makeDeps(
latencyByConnectionId: Record<string, number | Error>, latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[], createdClients: FakeDaemonClient[],
@@ -1743,41 +1734,6 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]); store.syncHosts([]);
}); });
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const oldPairingUrl = encodeOfferUrl({
v: 2,
serverId: "srv_offer",
daemonPublicKeyB64: "pk_test_offer",
relay: { endpoint: "relay.paseo.sh:443" },
});
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
{
id: "relay:wss:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_test_offer",
},
]);
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => { it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({ const store = new HostRuntimeStore({
deps: { deps: {
+1 -3
View File
@@ -1506,12 +1506,10 @@ export class HostRuntimeStore {
} }
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> { async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
return this.upsertRelayConnection({ return this.upsertRelayConnection({
serverId: offer.serverId, serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint, relayEndpoint: offer.relay.endpoint,
useTls, useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
label, label,
}); });
+1 -8
View File
@@ -720,16 +720,9 @@ function SettingsSidebar({
}, [hosts, localServerId]); }, [hosts, localServerId]);
const isDesktopApp = isElectronRuntime(); const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp); const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
const padding = useWindowControlsPadding("sidebar"); const padding = useWindowControlsPadding("sidebar");
const isDesktop = layout === "desktop"; const isDesktop = layout === "desktop";
const containerStyle = useMemo( const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
() => [
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
isDesktop ? { paddingTop: insets.top } : null,
],
[insets.top, isDesktop],
);
const selectedSectionId = view.kind === "section" ? view.section : null; const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null; const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project"; const isProjectsSelected = view.kind === "projects" || view.kind === "project";
@@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import {
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
upsertCreatedTerminalPayload,
type ListTerminalsPayload,
} from "@/screens/workspace/terminals/state";
import type { CreateTerminalResponse } from "@server/shared/messages";
function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] {
return { id, name: id, title: id };
}
function createdTerminal(id: string): NonNullable<CreateTerminalResponse["payload"]["terminal"]> {
return { id, name: id, cwd: "/repo", title: id };
}
describe("workspace terminal state", () => {
it("keeps pending script terminals until they appear or a fresher list arrives", () => {
const pending = new Map([
["older-than-list", 10],
["now-live", 20],
["still-pending", 30],
]);
const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending);
expect(reconciled).toEqual(new Map([["still-pending", 30]]));
});
it("returns the same pending map when reconciliation changes nothing", () => {
const pending = new Map([["still-pending", 30]]);
const reconciled = reconcilePendingScriptTerminals([], 20)(pending);
expect(reconciled).toBe(pending);
});
it("combines live and pending terminal ids without duplicating script terminals", () => {
const pendingScriptTerminalIds = new Map([
["script-pending", 10],
["terminal-1", 10],
]);
expect(
collectKnownTerminalIds({
liveTerminalIds: ["terminal-1", "terminal-2"],
pendingScriptTerminalIds,
}),
).toEqual(["terminal-1", "terminal-2", "script-pending"]);
expect(
collectScriptTerminalIds({
pendingScriptTerminalIds,
scripts: [{ terminalId: "script-live" }, { terminalId: null }],
}),
).toEqual(new Set(["script-pending", "terminal-1", "script-live"]));
expect(
collectStandaloneTerminalIds({
terminals: [
listedTerminal("terminal-1"),
listedTerminal("terminal-2"),
listedTerminal("script-live"),
],
scriptTerminalIds: new Set(["terminal-1", "script-live"]),
}),
).toEqual(["terminal-2"]);
});
it("updates terminal cache entries for created and closed terminals", () => {
const current: ListTerminalsPayload = {
cwd: "/repo",
requestId: "existing",
terminals: [listedTerminal("terminal-1")],
};
expect(
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal("terminal-2"),
workspaceDirectory: "/repo",
}),
).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [
listedTerminal("terminal-1"),
{ id: "terminal-2", name: "terminal-2", title: "terminal-2" },
],
});
expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [],
});
});
});
@@ -1,106 +0,0 @@
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
export const TERMINALS_QUERY_STALE_TIME = 5_000;
export type ListTerminalsPayload = ListTerminalsResponse["payload"];
type TerminalEntry = ListTerminalsPayload["terminals"][number];
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
return ["terminals", serverId, workspaceDirectory] as const;
}
export function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
export function collectKnownTerminalIds(input: {
liveTerminalIds: string[];
pendingScriptTerminalIds: Map<string, number>;
}): string[] {
const terminalIds = new Set(input.liveTerminalIds);
for (const terminalId of input.pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}
export function collectScriptTerminalIds(input: {
pendingScriptTerminalIds: Map<string, number>;
scripts: Array<{ terminalId?: string | null }>;
}): Set<string> {
const terminalIds = new Set(input.pendingScriptTerminalIds.keys());
for (const script of input.scripts) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}
export function collectStandaloneTerminalIds(input: {
terminals: TerminalEntry[];
scriptTerminalIds: Set<string>;
}): string[] {
return input.terminals
.filter((terminal) => !input.scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id);
}
export function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
export function upsertCreatedTerminalPayload(input: {
current: ListTerminalsPayload | undefined;
terminal: CreatedTerminal;
workspaceDirectory: string | null;
}): ListTerminalsPayload {
const nextTerminals = upsertTerminalListEntry({
terminals: input.current?.terminals ?? [],
terminal: input.terminal,
});
const cwd = input.current?.cwd ?? input.workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`,
};
}
@@ -1,281 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildTerminalsQueryKey,
canCreateWorkspaceTerminal,
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
TERMINALS_QUERY_STALE_TIME,
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state";
interface PendingTerminalCreateInput {
paneId?: string;
}
interface UseWorkspaceTerminalsInput {
client: DaemonClient | null;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
workspaceScripts: WorkspaceDescriptor["scripts"];
hasHydratedWorkspaces: boolean;
isMissingWorkspaceExecutionAuthority: boolean;
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
onScriptTerminalSelected: (terminalId: string) => void;
onWorkspacePathUnavailable: () => void;
onTerminalCreateQueued: () => void;
}
export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
const {
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreated,
onScriptTerminalSelected,
onWorkspacePathUnavailable,
onTerminalCreateQueued,
} = input;
const queryClient = useQueryClient();
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
null,
);
const canCreateNow = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const queryKey = useMemo(
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
[normalizedServerId, workspaceDirectory],
);
const query = useQuery({
queryKey,
enabled: canCreateNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
});
const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]);
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
const dataUpdatedAt = query.dataUpdatedAt;
useEffect(() => {
setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt));
}, [liveTerminalIds, dataUpdatedAt]);
const knownTerminalIds = useMemo(
() => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }),
[liveTerminalIds, pendingScriptTerminalIds],
);
const scriptTerminalIds = useMemo(
() => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }),
[pendingScriptTerminalIds, workspaceScripts],
);
const standaloneTerminalIds = useMemo(
() => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }),
[scriptTerminalIds, terminals],
);
const createMutation = useMutation({
mutationFn: async (_input?: PendingTerminalCreateInput) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, createInput) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) =>
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal,
workspaceDirectory,
}),
);
}
void queryClient.invalidateQueries({ queryKey });
if (createdTerminal) {
onTerminalCreated({
terminalId: createdTerminal.id,
paneId: createInput?.paneId,
});
}
},
});
const killMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
});
useEffect(() => {
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
return;
}
const unsubscribeChanged = client.on("terminals_changed", (message) => {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
useEffect(() => {
if (!pendingCreateInput) {
return;
}
if (canCreateNow && !createMutation.isPending) {
const pendingInput = pendingCreateInput;
setPendingCreateInput(null);
createMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingCreateInput(null);
onWorkspacePathUnavailable();
}
}, [
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onWorkspacePathUnavailable,
pendingCreateInput,
]);
const createTerminal = useCallback(
(createInput?: PendingTerminalCreateInput) => {
if (createMutation.isPending || pendingCreateInput) {
return;
}
if (canCreateNow) {
createMutation.mutate(createInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
onWorkspacePathUnavailable();
return;
}
setPendingCreateInput(createInput ?? {});
onTerminalCreateQueued();
},
[
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreateQueued,
onWorkspacePathUnavailable,
pendingCreateInput,
],
);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, query.dataUpdatedAt);
return nextTerminalIds;
});
onScriptTerminalSelected(terminalId);
void queryClient.invalidateQueries({ queryKey });
},
[onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
onScriptTerminalSelected(terminalId);
},
[onScriptTerminalSelected],
);
const removeTerminalFromCache = useCallback(
(terminalId: string) => {
queryClient.setQueryData<ListTerminalsPayload>(
queryKey,
removeTerminalFromPayload(terminalId),
);
},
[queryClient, queryKey],
);
const invalidateTerminals = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return {
canCreateNow,
createMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput,
query,
queryKey,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
};
}
@@ -11,7 +11,7 @@ import {
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router"; import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
@@ -94,6 +94,8 @@ import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query"; import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys"; import { checkoutStatusQueryKey } from "@/git/query-keys";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
@@ -156,12 +158,10 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>(); const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>(); const EMPTY_SET = new Set<string>();
@@ -1028,6 +1028,8 @@ function WorkspaceHeaderTitleBar({
); );
} }
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type PaneDirection = "left" | "right" | "up" | "down"; type PaneDirection = "left" | "right" | "up" | "down";
function parsePaneDirection(actionId: string): PaneDirection | null { function parsePaneDirection(actionId: string): PaneDirection | null {
@@ -1178,6 +1180,39 @@ function resolveWorkspaceAuthorityState(
}; };
} }
function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string { function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
const trimmed = host?.label?.trim(); const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback; return trimmed ? trimmed : fallback;
@@ -1324,6 +1359,17 @@ function shouldShowWorkspaceExplorerSidebar(input: {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
} }
function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return null; return null;
@@ -1331,108 +1377,6 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`; return `${serverId}:${workspaceId}`;
} }
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
toast: {
error: (message: string) => void;
show: (message: string) => void;
};
}
interface WorkspaceTerminalTabActions {
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
handleScriptTerminalSelected: (terminalId: string) => void;
handleWorkspacePathUnavailable: () => void;
handleTerminalCreateQueued: () => void;
}
function useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
if (!persistenceKey) {
return;
}
if (paneId) {
focusWorkspacePane(persistenceKey, paneId);
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error("Workspace path is not available yet");
}, [toast]);
const handleTerminalCreateQueued = useCallback(() => {
toast.show("Preparing workspace, opening terminal when ready...");
}, [toast]);
return {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
};
}
function useWorkspaceCheckoutStatus(input: {
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}) {
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
isRouteFocused: input.isRouteFocused,
client: input.client,
isConnected: input.isConnected,
workspaceDirectory: input.workspaceDirectory,
}),
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
input.normalizedServerId,
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error("Host is not connected");
}
return await input.client.getCheckoutStatus(input.workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
return { checkoutQuery, isCheckoutStatusLoading };
}
function WorkspaceScreenContent({ function WorkspaceScreenContent({
serverId, serverId,
workspaceId, workspaceId,
@@ -1461,6 +1405,7 @@ function WorkspaceScreenContent({
scopeKey: workspaceTerminalScopeKey, scopeKey: workspaceTerminalScopeKey,
}); });
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId); const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceAuthority = useMemo( const workspaceAuthority = useMemo(
@@ -1485,19 +1430,12 @@ function WorkspaceScreenContent({
useProvidersSnapshot(normalizedServerId, { useProvidersSnapshot(normalizedServerId, {
enabled: isRouteFocused, enabled: isRouteFocused,
}); });
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
const persistenceKey = useMemo( paneId?: string;
() => } | null>(null);
buildWorkspaceTabPersistenceKey({ const canCreateTerminalNow = useMemo(
serverId: normalizedServerId, () => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
workspaceId: normalizedWorkspaceId, [isRouteFocused, client, isConnected, workspaceDirectory],
}),
[normalizedServerId, normalizedWorkspaceId],
);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
); );
const workspaceAgentVisibility = useStoreWithEqualityFn( const workspaceAgentVisibility = useStoreWithEqualityFn(
@@ -1511,56 +1449,167 @@ function WorkspaceScreenContent({
workspaceAgentVisibilityEqual, workspaceAgentVisibilityEqual,
); );
const { const terminalsQueryKey = useMemo(
handleTerminalCreated, () => ["terminals", normalizedServerId, workspaceDirectory] as const,
handleScriptTerminalSelected, [normalizedServerId, workspaceDirectory],
handleWorkspacePathUnavailable, );
handleTerminalCreateQueued, const terminalsQuery = useQuery({
} = useWorkspaceTerminalTabActions({ queryKey: terminalsQueryKey,
persistenceKey, enabled: canCreateTerminalNow,
focusWorkspacePane, queryFn: async () => {
openWorkspaceTabFocused, if (!client || !workspaceDirectory) {
toast, throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
}); });
const { const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
createMutation: createTerminalMutation, const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
createTerminal, const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
handleScriptTerminalStarted, () => new Map(),
handleViewScriptTerminal, );
invalidateTerminals, useEffect(() => {
killMutation: killTerminalMutation, setPendingScriptTerminalIds(new Map());
knownTerminalIds, }, [normalizedServerId, normalizedWorkspaceId]);
liveTerminalIds, const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
pendingCreateInput: pendingTerminalCreateInput, useEffect(() => {
query: terminalsQuery, setPendingScriptTerminalIds(
removeTerminalFromCache, reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
standaloneTerminalIds, );
terminals, }, [liveTerminalIds, terminalsDataUpdatedAt]);
} = useWorkspaceTerminals({ const knownTerminalIds = useMemo(() => {
client, const terminalIds = new Set(liveTerminalIds);
isConnected, for (const terminalId of pendingScriptTerminalIds.keys()) {
isRouteFocused, terminalIds.add(terminalId);
normalizedServerId, }
normalizedWorkspaceId, return Array.from(terminalIds);
workspaceDirectory, }, [liveTerminalIds, pendingScriptTerminalIds]);
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS, const scriptTerminalIds = useMemo(() => {
hasHydratedWorkspaces, const terminalIds = new Set(pendingScriptTerminalIds.keys());
isMissingWorkspaceExecutionAuthority, for (const script of workspaceDescriptor?.scripts ?? []) {
onTerminalCreated: handleTerminalCreated, if (script.terminalId) {
onScriptTerminalSelected: handleScriptTerminalSelected, terminalIds.add(script.terminalId);
onWorkspacePathUnavailable: handleWorkspacePathUnavailable, }
onTerminalCreateQueued: handleTerminalCreateQueued, }
return terminalIds;
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
const standaloneTerminalIds = useMemo(
() =>
terminals
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id),
[scriptTerminalIds, terminals],
);
const createTerminalMutation = useMutation({
mutationFn: async (_input?: { paneId?: string }) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, input) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
const cwd = current?.cwd ?? workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
if (createdTerminal) {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
if (!workspaceKey) {
return;
}
if (input?.paneId) {
focusWorkspacePane(workspaceKey, input.paneId);
}
useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
}
},
});
const killTerminalMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
}); });
const { archiveAgent } = useArchiveAgent(); const { archiveAgent } = useArchiveAgent();
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({ useEffect(() => {
client, if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
isConnected, return;
isRouteFocused, }
normalizedServerId,
normalizedWorkspaceId, const unsubscribeChanged = client.on("terminals_changed", (message) => {
workspaceDirectory, if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
const isCheckoutQueryEnabled = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
normalizedServerId,
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.getCheckoutStatus(workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}); });
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const hasHydratedAgents = useSessionStore( const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
); );
@@ -1569,6 +1618,30 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
hasHydratedWorkspaces, hasHydratedWorkspaces,
}); });
useEffect(() => {
if (!pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
const pendingInput = pendingTerminalCreateInput;
setPendingTerminalCreateInput(null);
createTerminalMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingTerminalCreateInput(null);
toast.error("Workspace path is not available yet");
}
}, [
canCreateTerminalNow,
createTerminalMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
pendingTerminalCreateInput,
toast,
]);
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({ const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading, isCheckoutStatusLoading,
isError: checkoutQuery.isError, isError: checkoutQuery.isError,
@@ -1665,6 +1738,15 @@ function WorkspaceScreenContent({
return () => handler.remove(); return () => handler.remove();
}, [isExplorerOpen, isRouteFocused, showMobileAgent]); }, [isExplorerOpen, isRouteFocused, showMobileAgent]);
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceLayout = useWorkspaceLayoutStore((state) => const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
); );
@@ -1679,6 +1761,7 @@ function WorkspaceScreenContent({
[workspaceLayout], [workspaceLayout],
); );
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused }); useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceTabInBackground = useWorkspaceLayoutStore( const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground, (state) => state.openTabInBackground,
); );
@@ -1694,6 +1777,39 @@ function WorkspaceScreenContent({
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
return nextTerminalIds;
});
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
},
[
openWorkspaceTabFocused,
persistenceKey,
queryClient,
terminalsQuery.dataUpdatedAt,
terminalsQueryKey,
],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const paneFocusSuppressedRef = useRef(false); const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -2084,7 +2200,24 @@ function WorkspaceScreenContent({
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey], [focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
); );
const handleCreateTerminal = useStableEvent(createTerminal); const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow) {
createTerminalMutation.mutate(input);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
toast.error("Workspace path is not available yet");
return;
}
setPendingTerminalCreateInput(input ?? {});
toast.show("Preparing workspace, opening terminal when ready...");
});
const handleCreateBrowserTab = useCallback( const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => { (input?: { paneId?: string }) => {
@@ -2151,7 +2284,10 @@ function WorkspaceScreenContent({
return; return;
} }
removeTerminalFromCache(terminalId); queryClient.setQueryData<ListTerminalsPayload>(
terminalsQueryKey,
removeTerminalFromPayload(terminalId),
);
setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) { if (persistenceKey) {
@@ -2161,16 +2297,18 @@ function WorkspaceScreenContent({
}); });
} }
void killTerminalAsync(terminalId).catch(invalidateTerminals); void killTerminalAsync(terminalId).catch(() => {
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
});
}); });
}, },
[ [
closeTab, closeTab,
closeWorkspaceTabWithCleanup, closeWorkspaceTabWithCleanup,
invalidateTerminals,
killTerminalAsync, killTerminalAsync,
persistenceKey, persistenceKey,
removeTerminalFromCache, queryClient,
terminalsQueryKey,
], ],
); );
@@ -1,7 +1,5 @@
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import { import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
@@ -88,7 +86,7 @@ interface InsertSplitInternalInput {
targetPaneId: string; targetPaneId: string;
tabId: string; tabId: string;
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; createNodeId: (prefix: "pane" | "group") => string;
} }
interface InsertSplitInternalResult { interface InsertSplitInternalResult {
@@ -144,7 +142,7 @@ interface SplitPaneInLayoutInput {
tabId: string; tabId: string;
targetPaneId: string; targetPaneId: string;
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number; maxTreeDepth: number;
} }
@@ -157,7 +155,7 @@ interface SplitPaneEmptyInLayoutInput {
layout: WorkspaceLayout; layout: WorkspaceLayout;
targetPaneId: string; targetPaneId: string;
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number; maxTreeDepth: number;
} }
@@ -234,6 +232,14 @@ function normalizeTabIds(list: unknown): string[] {
return next; return next;
} }
function generateNodeId(prefix: "pane" | "group"): string {
const randomValue =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}_${randomValue}`;
}
function createPaneNode(input: { function createPaneNode(input: {
id: string; id: string;
tabs?: WorkspaceTab[]; tabs?: WorkspaceTab[];
@@ -990,16 +996,13 @@ export function insertSplit(
targetPaneId: string, targetPaneId: string,
tabId: string, tabId: string,
position: "left" | "right" | "top" | "bottom", position: "left" | "right" | "top" | "bottom",
createNodeId: (
prefix: WorkspaceLayoutNodeIdPrefix,
) => string = defaultWorkspaceLayoutIds.createNodeId,
): SplitNode { ): SplitNode {
return insertSplitInternal({ return insertSplitInternal({
root: asInternalNode(root), root: asInternalNode(root),
targetPaneId, targetPaneId,
tabId, tabId,
position, position,
createNodeId, createNodeId: generateNodeId,
}).root; }).root;
} }
@@ -1,17 +0,0 @@
export type WorkspaceLayoutNodeIdPrefix = "pane" | "group";
export interface WorkspaceLayoutIdSource {
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
createFocusRestorationToken: () => string;
}
function createRandomIdValue(): string {
return typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const defaultWorkspaceLayoutIds: WorkspaceLayoutIdSource = {
createNodeId: (prefix) => `${prefix}_${createRandomIdValue()}`,
createFocusRestorationToken: () => `workspace-focus-${createRandomIdValue()}`,
};
@@ -20,7 +20,6 @@ import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
collectAllPanes, collectAllPanes,
collectAllTabs, collectAllTabs,
createWorkspaceLayoutStore,
createDefaultLayout, createDefaultLayout,
findPaneById, findPaneById,
findPaneContainingTab, findPaneContainingTab,
@@ -29,6 +28,7 @@ import {
insertSplit, insertSplit,
removePaneFromTree, removePaneFromTree,
removeTabFromTree, removeTabFromTree,
useWorkspaceLayoutStore,
type SplitNode, type SplitNode,
type SplitPane, type SplitPane,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
@@ -36,40 +36,6 @@ import {
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
const WORKSPACE_ID = "ws-main"; const WORKSPACE_ID = "ws-main";
function createDeterministicWorkspaceLayoutIds() {
let values: string[] = [];
let fallbackIndex = 0;
function nextValue(): string {
const value = values.shift();
if (value) {
return value;
}
fallbackIndex += 1;
return `generated-${fallbackIndex}`;
}
return {
useValues: (nextValues: string[]) => {
values = nextValues.slice();
fallbackIndex = 0;
},
reset: () => {
values = [];
fallbackIndex = 0;
},
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
};
}
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
function useWorkspaceLayoutIds(...values: string[]) {
workspaceLayoutIds.useValues(values);
}
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab { function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
return { return {
tabId, tabId,
@@ -202,14 +168,13 @@ describe("workspace-layout-store helpers", () => {
describe("workspace-layout-store tree transforms", () => { describe("workspace-layout-store tree transforms", () => {
beforeEach(() => { beforeEach(() => {
workspaceLayoutIds.reset(); vi.restoreAllMocks();
}); });
it("insertSplit wraps root-level same-direction splits in a nested group", () => { it("insertSplit wraps root-level same-direction splits in a nested group", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"11111111-1111-1111-1111-111111111111", .mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
"22222222-2222-2222-2222-222222222222", .mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
);
const root: SplitNode = { const root: SplitNode = {
kind: "group", kind: "group",
@@ -224,7 +189,7 @@ describe("workspace-layout-store tree transforms", () => {
}, },
}; };
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId); const nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextGroup = expectGroup(nextRoot); const nextGroup = expectGroup(nextRoot);
const nestedGroup = expectGroup(nextGroup.group.children[1]); const nestedGroup = expectGroup(nextGroup.group.children[1]);
@@ -305,20 +270,22 @@ describe("workspace-layout-store tree transforms", () => {
describe("workspace-layout-store actions", () => { describe("workspace-layout-store actions", () => {
beforeEach(() => { beforeEach(() => {
workspaceLayoutIds.reset(); useWorkspaceLayoutStore.setState({
workspaceLayoutStore.setState({
layoutByWorkspace: {}, layoutByWorkspace: {},
splitSizesByWorkspace: {}, splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {}, pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {}, focusRestorationByWorkspace: {},
}); });
vi.restoreAllMocks();
}); });
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -341,7 +308,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/b.ts", path: "/repo/worktree/b.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("file_/repo/worktree/a.ts"); expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe("file_/repo/worktree/b.ts"); expect(secondTabId).toBe("file_/repo/worktree/b.ts");
@@ -355,14 +322,14 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground inserts a tab without stealing focus", () => { it("openTabInBackground inserts a tab without stealing focus", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const setupTabId = store.openTabInBackground(workspaceKey, { const setupTabId = store.openTabInBackground(workspaceKey, {
kind: "setup", kind: "setup",
workspaceId: "ws-main", workspaceId: "ws-main",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layout.root, "main")!; const pane = findPaneById(layout.root, "main")!;
expect(agentTabId).toBe("agent_agent-1"); expect(agentTabId).toBe("agent_agent-1");
@@ -374,7 +341,7 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground on an existing target is a no-op", () => { it("openTabInBackground on an existing target is a no-op", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -388,7 +355,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/a.ts", path: "/repo/worktree/a.ts",
}); });
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layoutAfter.root, "main")!; const pane = findPaneById(layoutAfter.root, "main")!;
expect(duplicateTabId).toBe(firstTabId); expect(duplicateTabId).toBe(firstTabId);
@@ -398,25 +365,27 @@ describe("workspace-layout-store actions", () => {
it("unfocuses and restores the previous focused pane", () => { it("unfocuses and restores the previous focused pane", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const token = store.unfocusPane(workspaceKey); const token = store.unfocusPane(workspaceKey);
expect(token).toBeTruthy(); expect(token).toBeTruthy();
expect( expect(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull(); ).toBeNull();
store.restorePaneFocus(workspaceKey, token!); store.restorePaneFocus(workspaceKey, token!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main", "main",
); );
}); });
it("does not restore stale focus after another pane is focused", () => { it("does not restore stale focus after another pane is focused", () => {
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.splitPane(workspaceKey, { store.splitPane(workspaceKey, {
@@ -430,14 +399,14 @@ describe("workspace-layout-store actions", () => {
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
store.restorePaneFocus(workspaceKey, token!); store.restorePaneFocus(workspaceKey, token!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
); );
}); });
it("waits for nested focus restorations before restoring", () => { it("waits for nested focus restorations before restoring", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const outerToken = store.unfocusPane(workspaceKey); const outerToken = store.unfocusPane(workspaceKey);
@@ -445,22 +414,22 @@ describe("workspace-layout-store actions", () => {
store.restorePaneFocus(workspaceKey, outerToken!); store.restorePaneFocus(workspaceKey, outerToken!);
expect( expect(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull(); ).toBeNull();
store.restorePaneFocus(workspaceKey, innerToken!); store.restorePaneFocus(workspaceKey, innerToken!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main", "main",
); );
}); });
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => { it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("draft-1"); expect(firstTabId).toBe("draft-1");
expect(secondTabId).toBe("draft-2"); expect(secondTabId).toBe("draft-2");
@@ -481,9 +450,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => { it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"77777777-7777-7777-7777-777777777777",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const newPaneId = store.splitPaneEmpty(workspaceKey, { const newPaneId = store.splitPaneEmpty(workspaceKey, {
@@ -494,7 +465,7 @@ describe("workspace-layout-store actions", () => {
kind: "draft", kind: "draft",
draftId: "draft-split", draftId: "draft-split",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777"); expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
expect(draftTabId).toBe("draft-split"); expect(draftTabId).toBe("draft-split");
@@ -505,9 +476,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("focusTab moves workspace focus to the pane containing the tab", () => { it("focusTab moves workspace focus to the pane containing the tab", () => {
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const fileTabId = store.openTabFocused(workspaceKey, { const fileTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -524,20 +497,22 @@ describe("workspace-layout-store actions", () => {
}); });
store.focusTab(workspaceKey, fileTabId!); store.focusTab(workspaceKey, fileTabId!);
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
store.focusTab(workspaceKey, terminalTabId!); store.focusTab(workspaceKey, terminalTabId!);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
expect(layout.focusedPaneId).toBe(splitPaneId); expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId); expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
}); });
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => { it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
@@ -548,7 +523,7 @@ describe("workspace-layout-store actions", () => {
}); });
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1"); const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const splitPane = findPaneById(layout.root, splitPaneId); const splitPane = findPaneById(layout.root, splitPaneId);
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId); const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
@@ -565,7 +540,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab keeps a draft tab in place while updating its target", () => { it("retargetTab keeps a draft tab in place while updating its target", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, { const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft", kind: "draft",
@@ -575,7 +550,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/retargeted.ts", path: "/repo/worktree/retargeted.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(draftTabId).toBe("draft-retarget"); expect(draftTabId).toBe("draft-retarget");
expect(nextTabId).toBe(draftTabId); expect(nextTabId).toBe(draftTabId);
@@ -590,9 +565,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => { it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"55555555-5555-5555-5555-555555555555",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const existingFileTabId = store.openTabFocused(workspaceKey, { const existingFileTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -613,7 +590,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/existing.ts", path: "/repo/worktree/existing.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts"); expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
expect(draftTabId).toBe("draft-dup"); expect(draftTabId).toBe("draft-dup");
@@ -629,7 +606,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab closes a draft tab and focuses an existing matching target tab", () => { it("retargetTab closes a draft tab and focuses an existing matching target tab", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstDraftTabId = store.openTabFocused(workspaceKey, { const firstDraftTabId = store.openTabFocused(workspaceKey, {
kind: "draft", kind: "draft",
@@ -648,7 +625,7 @@ describe("workspace-layout-store actions", () => {
kind: "agent", kind: "agent",
agentId: "agent-1", agentId: "agent-1",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstAgentTabId).toBe(firstDraftTabId); expect(firstAgentTabId).toBe(firstDraftTabId);
expect(nextTabId).toBe(firstDraftTabId); expect(nextTabId).toBe(firstDraftTabId);
@@ -664,7 +641,7 @@ describe("workspace-layout-store actions", () => {
it("reorderTabs reorders tabs within the focused pane", () => { it("reorderTabs reorders tabs within the focused pane", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -680,7 +657,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]); store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")).toEqual({ expect(findPaneById(layout.root, "main")).toEqual({
id: "main", id: "main",
@@ -707,9 +684,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => { it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"34343434-3434-3434-3434-343434343434",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.openTabFocused(workspaceKey, { store.openTabFocused(workspaceKey, {
@@ -733,7 +712,7 @@ describe("workspace-layout-store actions", () => {
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!); store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
store.focusPane(workspaceKey, "main"); store.focusPane(workspaceKey, "main");
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]); store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434"); expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
@@ -757,9 +736,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("focusPane switches workspace focus to a different pane", () => { it("focusPane switches workspace focus to a different pane", () => {
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"56565656-5656-5656-5656-565656565656",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -773,20 +754,22 @@ describe("workspace-layout-store actions", () => {
}); });
store.focusPane(workspaceKey, "main"); store.focusPane(workspaceKey, "main");
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
store.focusPane(workspaceKey, splitPaneId!); store.focusPane(workspaceKey, splitPaneId!);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656"); expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
expect(layout.focusedPaneId).toBe(splitPaneId); expect(layout.focusedPaneId).toBe(splitPaneId);
}); });
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => { it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"cccccccc-cccc-cccc-cccc-cccccccccccc",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -800,7 +783,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.closeTab(workspaceKey, secondTabId!); store.closeTab(workspaceKey, secondTabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc"); expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
@@ -808,19 +791,18 @@ describe("workspace-layout-store actions", () => {
}); });
it("splitPane enforces the maximum depth of four", () => { it("splitPane enforces the maximum depth of four", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"11111111-1111-1111-1111-111111111111", .mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
"22222222-2222-2222-2222-222222222222", .mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
"33333333-3333-3333-3333-333333333333", .mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
"44444444-4444-4444-4444-444444444444", .mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
"55555555-5555-5555-5555-555555555555", .mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
"66666666-6666-6666-6666-666666666666", .mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
"77777777-7777-7777-7777-777777777777", .mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
"88888888-8888-8888-8888-888888888888", .mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
@@ -849,7 +831,7 @@ describe("workspace-layout-store actions", () => {
position: "bottom", position: "bottom",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111"); expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333"); expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555"); expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
@@ -858,9 +840,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("moveTabToPane collapses the source pane when its last tab moves out", () => { it("moveTabToPane collapses the source pane when its last tab moves out", () => {
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"dddddddd-dddd-dddd-dddd-dddddddddddd",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const leftTabId = store.openTabFocused(workspaceKey, { const leftTabId = store.openTabFocused(workspaceKey, {
kind: "file", kind: "file",
@@ -877,7 +861,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!); store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe(splitPaneId); expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]); expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
@@ -888,14 +872,13 @@ describe("workspace-layout-store actions", () => {
}); });
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => { it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"78787878-7878-7878-7878-787878787878", .mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
"89898989-8989-8989-8989-898989898989", .mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a", .mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -918,7 +901,7 @@ describe("workspace-layout-store actions", () => {
}); });
store.closeTab(workspaceKey, secondTabId!); store.closeTab(workspaceKey, secondTabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const rootGroup = expectGroup(layout.root); const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
@@ -954,9 +937,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("openTab focuses the existing tab instead of creating a duplicate entry", () => { it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"abababab-abab-abab-abab-abababababab",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { const secondTabId = store.openTabFocused(workspaceKey, {
@@ -974,7 +959,7 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/b.ts", path: "/repo/worktree/b.ts",
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab"); expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
expect(duplicateTabId).toBe(secondTabId); expect(duplicateTabId).toBe(secondTabId);
@@ -986,14 +971,13 @@ describe("workspace-layout-store actions", () => {
}); });
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
useWorkspaceLayoutIds( vi.spyOn(globalThis.crypto, "randomUUID")
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", .mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
"ffffffff-ffff-ffff-ffff-ffffffffffff", .mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
"11111111-1111-1111-1111-111111111111", .mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
@@ -1011,12 +995,12 @@ describe("workspace-layout-store actions", () => {
position: "right", position: "right",
}); });
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitGroup = expectGroup(splitRoot); const splitGroup = expectGroup(splitRoot);
const nestedGroup = expectGroup(splitGroup.group.children[1]); const nestedGroup = expectGroup(splitGroup.group.children[1]);
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]); store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedGroup = expectGroup(resizedRoot); const resizedGroup = expectGroup(resizedRoot);
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]); const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0); const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
@@ -1030,11 +1014,11 @@ describe("workspace-layout-store actions", () => {
it("closing the last tab keeps a single empty pane in the layout", () => { it("closing the last tab keeps a single empty pane in the layout", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.closeTab(workspaceKey, tabId!); store.closeTab(workspaceKey, tabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout).toEqual(createDefaultLayout()); expect(layout).toEqual(createDefaultLayout());
}); });
@@ -1048,12 +1032,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy(); expect(otherWorkspaceKey).toBeTruthy();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(otherWorkspaceKey as string, "agent-2"); store.pinAgent(otherWorkspaceKey as string, "agent-2");
let state = workspaceLayoutStore.getState(); let state = useWorkspaceLayoutStore.getState();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
@@ -1061,13 +1045,13 @@ describe("workspace-layout-store actions", () => {
store.unpinAgent(workspaceKey, "agent-1"); store.unpinAgent(workspaceKey, "agent-1");
state = workspaceLayoutStore.getState(); state = useWorkspaceLayoutStore.getState();
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
]); ]);
const partialize = workspaceLayoutStore.persist.getOptions().partialize; const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function"); expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({ expect(partialize?.(state)).toEqual({
layoutByWorkspace: {}, layoutByWorkspace: {},
@@ -1084,12 +1068,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy(); expect(otherWorkspaceKey).toBeTruthy();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(otherWorkspaceKey as string, "agent-2"); store.hideAgent(otherWorkspaceKey as string, "agent-2");
let state = workspaceLayoutStore.getState(); let state = useWorkspaceLayoutStore.getState();
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
@@ -1097,13 +1081,13 @@ describe("workspace-layout-store actions", () => {
store.unhideAgent(workspaceKey, "agent-1"); store.unhideAgent(workspaceKey, "agent-1");
state = workspaceLayoutStore.getState(); state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2", "agent-2",
]); ]);
const partialize = workspaceLayoutStore.persist.getOptions().partialize; const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function"); expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({ expect(partialize?.(state)).toEqual({
layoutByWorkspace: {}, layoutByWorkspace: {},
@@ -1112,9 +1096,11 @@ describe("workspace-layout-store actions", () => {
}); });
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => { it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"67676767-6767-6767-6767-676767676767",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, { const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft", kind: "draft",
@@ -1128,7 +1114,7 @@ describe("workspace-layout-store actions", () => {
}); });
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1"); const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767"); expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
expect(nextTabId).toBe("agent_agent-1"); expect(nextTabId).toBe("agent_agent-1");
@@ -1140,7 +1126,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => { it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.setState((state) => ({ useWorkspaceLayoutStore.setState((state) => ({
...state, ...state,
layoutByWorkspace: { layoutByWorkspace: {
...state.layoutByWorkspace, ...state.layoutByWorkspace,
@@ -1183,7 +1169,7 @@ describe("workspace-layout-store actions", () => {
}, },
})); }));
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: ["agent-1"], activeAgentIds: ["agent-1"],
@@ -1193,7 +1179,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = collectAllTabs(layout.root); const tabs = collectAllTabs(layout.root);
expect(tabs.map((tab) => tab.tabId)).toEqual([ expect(tabs.map((tab) => tab.tabId)).toEqual([
@@ -1214,14 +1200,14 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not re-add locally hidden agent tabs", () => { it("reconcileTabs does not re-add locally hidden agent tabs", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.setState((state) => ({ useWorkspaceLayoutStore.setState((state) => ({
...state, ...state,
hiddenAgentIdsByWorkspace: { hiddenAgentIdsByWorkspace: {
[workspaceKey]: new Set<string>(["agent-1"]), [workspaceKey]: new Set<string>(["agent-1"]),
}, },
})); }));
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: ["agent-1"], activeAgentIds: ["agent-1"],
@@ -1231,13 +1217,13 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
}); });
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => { it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"], activeAgentIds: ["parent-agent", "child-agent"],
@@ -1248,7 +1234,7 @@ describe("workspace-layout-store actions", () => {
}); });
expect( expect(
workspaceLayoutStore useWorkspaceLayoutStore
.getState() .getState()
.getWorkspaceTabs(workspaceKey) .getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId), .map((tab) => tab.tabId),
@@ -1257,7 +1243,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => { it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1272,7 +1258,7 @@ describe("workspace-layout-store actions", () => {
}); });
expect( expect(
workspaceLayoutStore useWorkspaceLayoutStore
.getState() .getState()
.getWorkspaceTabs(workspaceKey) .getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId), .map((tab) => tab.tabId),
@@ -1281,7 +1267,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => { it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1296,7 +1282,7 @@ describe("workspace-layout-store actions", () => {
}); });
expect( expect(
workspaceLayoutStore useWorkspaceLayoutStore
.getState() .getState()
.getWorkspaceTabs(workspaceKey) .getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId), .map((tab) => tab.tabId),
@@ -1305,7 +1291,7 @@ describe("workspace-layout-store actions", () => {
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => { it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "child-agent"); store.hideAgent(workspaceKey, "child-agent");
store.reconcileTabs(workspaceKey, { store.reconcileTabs(workspaceKey, {
@@ -1318,11 +1304,11 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_child-agent", "agent_child-agent",
@@ -1331,7 +1317,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => { it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const scriptTabId = store.openTabFocused(workspaceKey, { const scriptTabId = store.openTabFocused(workspaceKey, {
kind: "terminal", kind: "terminal",
@@ -1349,8 +1335,8 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey); const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]); expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId); expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
}); });
@@ -1358,7 +1344,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not auto-open live non-standalone terminals", () => { it("reconcileTabs does not auto-open live non-standalone terminals", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true, agentsHydrated: true,
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: [], activeAgentIds: [],
@@ -1369,44 +1355,46 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false, hasActivePendingDraftCreate: false,
}); });
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
}); });
it("explicitly opening an agent tab clears hidden intent", () => { it("explicitly opening an agent tab clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
}); });
it("pinning an agent clears hidden intent", () => { it("pinning an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined(); expect(
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
).toBeDefined();
store.pinAgent(workspaceKey, "agent-1"); store.pinAgent(workspaceKey, "agent-1");
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
}); });
it("retargeting a tab to an agent clears hidden intent", () => { it("retargeting a tab to an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1"); store.hideAgent(workspaceKey, "agent-1");
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" }); store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
const state = workspaceLayoutStore.getState(); const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -1,142 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
class FakeDaemonClient implements DaemonProbeClient { const daemonClientMock = vi.hoisted(() => {
readonly lastError: string | null; const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
let nextConnectError: Error | null = null;
let nextLastError: string | null = null;
constructor( class MockDaemonClient {
private readonly probe: FakeDaemonProbe, public lastError: string | null = nextLastError;
readonly config: DaemonClientConfig, private lastServerInfo = {
) { status: "server_info" as const,
this.lastError = probe.nextLastError; serverId: "srv_probe_test",
} hostname: "probe-host" as string | null,
version: "0.0.0",
};
async connect(): Promise<void> { constructor(config: { clientId?: string; url?: string; password?: string }) {
if (this.probe.nextConnectError) { createdConfigs.push(config);
throw this.probe.nextConnectError; }
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(): () => void {
return () => undefined;
}
async connect(): Promise<void> {
if (nextConnectError) {
throw nextConnectError;
}
return;
}
getLastServerInfoMessage() {
return this.lastServerInfo;
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
} }
} }
getLastServerInfoMessage() { return {
return { MockDaemonClient,
serverId: "srv_probe_test", createdConfigs,
hostname: "probe-host", setNextConnectFailure: (error: Error, lastError: string | null) => {
}; nextConnectError = error;
} nextLastError = lastError;
async close(): Promise<void> {
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
nextConnectError: Error | null = null;
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
}, },
resolveAppVersion: () => null, reset: () => {
createLocalTransportFactory: () => null, createdConfigs.length = 0;
buildLocalTransportUrl: ({ transportType, transportPath }) => nextConnectError = null;
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`, nextLastError = null;
createClient: (config) => {
const client = new FakeDaemonClient(this, config);
this.createdClients.push(client);
return client;
}, },
}; };
});
failNextConnection(error: Error, lastError: string | null): void { const clientIdMock = vi.hoisted(() => ({
this.nextConnectError = error; getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
this.nextLastError = lastError; }));
}
createdConfigs(): DaemonClientConfig[] { vi.mock("@server/client/daemon-client", () => ({
return this.createdClients.map((client) => client.config); DaemonClient: daemonClientMock.MockDaemonClient,
} }));
}
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
buildLocalDaemonTransportUrl: vi.fn(
({
transportType,
transportPath,
}: {
transportType: "socket" | "pipe";
transportPath: string;
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
),
}));
describe("test-daemon-connection connectToDaemon", () => { describe("test-daemon-connection connectToDaemon", () => {
let probe: FakeDaemonProbe;
beforeEach(() => { beforeEach(() => {
daemonClientMock.reset();
clientIdMock.getOrCreateClientId.mockClear();
vi.stubGlobal("__DEV__", false); vi.stubGlobal("__DEV__", false);
probe = new FakeDaemonProbe();
}); });
it("reuses the app clientId for direct connections", async () => { it("reuses the app clientId for direct connections", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const first = await connectToDaemon(
{ const first = await mod.connectToDaemon({
id: "direct:lan:6767", id: "direct:lan:6767",
type: "directTcp", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, });
undefined,
probe.deps,
);
await first.client.close(); await first.client.close();
const second = await connectToDaemon( const second = await mod.connectToDaemon({
{ id: "direct:lan:6767",
id: "direct:lan:6767", type: "directTcp",
type: "directTcp", endpoint: "lan:6767",
endpoint: "lan:6767", });
},
undefined,
probe.deps,
);
await second.client.close(); await second.client.close();
const [firstConfig, secondConfig] = probe.createdConfigs(); const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
expect(firstConfig?.clientId).toBe("cid_shared_probe_test"); expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
expect(secondConfig?.clientId).toBe("cid_shared_probe_test"); expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
expect(probe.clientIdsRequested).toBe(2); expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
}); });
it("encodes the local socket target into the client config", async () => { it("encodes the local socket target into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const result = await connectToDaemon(
{ const result = await mod.connectToDaemon({
id: "socket:/tmp/paseo.sock", id: "socket:/tmp/paseo.sock",
type: "directSocket", type: "directSocket",
path: "/tmp/paseo.sock", path: "/tmp/paseo.sock",
}, });
undefined,
probe.deps,
);
await result.client.close(); await result.client.close();
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"); expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
);
}); });
it("passes direct TCP connection passwords into the client config", async () => { it("passes direct TCP connection passwords into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const result = await connectToDaemon(
{ const result = await mod.connectToDaemon({
id: "direct:lan:6767", id: "direct:lan:6767",
type: "directTcp", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
password: "shared-secret", password: "shared-secret",
}, });
undefined,
probe.deps,
);
await result.client.close(); await result.client.close();
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret"); expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
}); });
it("uses relay TLS from the stored connection", async () => { it("uses relay TLS from the stored connection", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
const tlsResult = await connectToDaemon(
const tlsResult = await mod.connectToDaemon(
{ {
id: "relay:wss:[::1]:443", id: "relay:wss:[::1]:443",
type: "relay", type: "relay",
@@ -145,11 +157,10 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey", daemonPublicKeyB64: "pubkey",
}, },
{ serverId: "srv_probe_test" }, { serverId: "srv_probe_test" },
probe.deps,
); );
await tlsResult.client.close(); await tlsResult.client.close();
const plainResult = await connectToDaemon( const plainResult = await mod.connectToDaemon(
{ {
id: "relay:relay.paseo.sh:443", id: "relay:relay.paseo.sh:443",
type: "relay", type: "relay",
@@ -158,52 +169,43 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey", daemonPublicKeyB64: "pubkey",
}, },
{ serverId: "srv_probe_test" }, { serverId: "srv_probe_test" },
probe.deps,
); );
await plainResult.client.close(); await plainResult.client.close();
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/); expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
}); });
it("surfaces auth rejection as an incorrect password", async () => { it("surfaces auth rejection as an incorrect password", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
probe.failNextConnection( daemonClientMock.setNextConnectFailure(
new Error("Transport closed (code 4001)"), new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)", "Transport closed (code 4001)",
); );
await expect( await expect(
connectToDaemon( mod.connectToDaemon({
{ id: "direct:lan:6767",
id: "direct:lan:6767", type: "directTcp",
type: "directTcp", endpoint: "lan:6767",
endpoint: "lan:6767", password: "wrong-secret",
password: "wrong-secret", }),
},
undefined,
probe.deps,
),
).rejects.toMatchObject({ ).rejects.toMatchObject({
message: "Incorrect password", message: "Incorrect password",
}); });
}); });
it("keeps generic transport failures generic when a password was supplied", async () => { it("keeps generic transport failures generic when a password was supplied", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
probe.failNextConnection(new Error("Transport error"), "Transport error"); daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
await expect( await expect(
connectToDaemon( mod.connectToDaemon({
{ id: "direct:lan:6767",
id: "direct:lan:6767", type: "directTcp",
type: "directTcp", endpoint: "lan:6767",
endpoint: "lan:6767", password: "shared-secret",
password: "shared-secret", }),
},
undefined,
probe.deps,
),
).rejects.toMatchObject({ ).rejects.toMatchObject({
message: "Transport error", message: "Transport error",
}); });
@@ -13,34 +13,6 @@ import {
createDesktopLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient {
readonly lastError: string | null;
connect(): Promise<void>;
close(): Promise<void>;
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
}
interface LocalTransportUrlInput {
transportType: "socket" | "pipe";
transportPath: string;
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient;
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
getClientId: getOrCreateClientId,
resolveAppVersion,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config),
};
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -97,17 +69,13 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig( export async function buildClientConfig(
connection: HostConnection, connection: HostConnection,
serverId?: string, serverId?: string,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientId = await deps.getClientId(); const clientId = await getOrCreateClientId();
const localTransportFactory = deps.createLocalTransportFactory(); const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = { const base = {
clientId, clientId,
clientType: "mobile" as const, clientType: "mobile" as const,
appVersion: deps.resolveAppVersion() ?? undefined, appVersion: resolveAppVersion() ?? undefined,
suppressSendErrors: true, suppressSendErrors: true,
reconnect: { enabled: false }, reconnect: { enabled: false },
...((connection.type === "directSocket" || connection.type === "directPipe") && ...((connection.type === "directSocket" || connection.type === "directPipe") &&
@@ -119,7 +87,7 @@ export async function buildClientConfig(
if (connection.type === "directSocket" || connection.type === "directPipe") { if (connection.type === "directSocket" || connection.type === "directPipe") {
return { return {
...base, ...base,
url: deps.buildLocalTransportUrl({ url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe", transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path, transportPath: connection.path,
}), }),
@@ -152,23 +120,10 @@ export async function buildClientConfig(
export function connectAndProbe( export function connectAndProbe(
config: DaemonClientConfig, config: DaemonClientConfig,
timeoutMs: number, timeoutMs: number,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>; ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
export function connectAndProbe<TClient extends DaemonProbeClient>( const client = new DaemonClient(config);
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"createClient"
> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const client = deps.createClient(config);
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>( return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
(resolve, reject) => { (resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
void client.close().catch(() => undefined); void client.close().catch(() => undefined);
@@ -228,20 +183,10 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
return connection.type === "relay" ? 10_000 : 6_000; return connection.type === "relay" ? 10_000 : 6_000;
} }
export function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectToDaemon<TClient extends DaemonProbeClient>(
connection: HostConnection,
options: ProbeOptions | undefined,
deps: DaemonConnectionDependencies<TClient>,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export async function connectToDaemon( export async function connectToDaemon(
connection: HostConnection, connection: HostConnection,
options?: ProbeOptions, options?: ProbeOptions,
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies, ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { const config = await buildClientConfig(connection, options?.serverId);
const config = await buildClientConfig(connection, options?.serverId, deps); return connectAndProbe(config, resolveTimeout(connection, options));
return connectAndProbe(config, resolveTimeout(connection, options), deps);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.75", "version": "0.1.71",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -24,7 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.75", "@getpaseo/server": "0.1.71",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -1,67 +1,32 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { const mocks = vi.hoisted(() => ({
type DaemonLaunchRuntime, spawnSync: vi.fn(),
type DetachedDaemonProcess, spawnProcess: vi.fn(),
startLocalDaemonDetached, }));
startLocalDaemonForeground,
} from "./local-daemon.js";
type RecordedDaemonLaunch = vi.mock("node:child_process", async () => {
| { const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mode: "detached"; return {
command: string; ...actual,
args: string[]; spawnSync: mocks.spawnSync,
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2]; };
} });
| {
mode: "foreground";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
};
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess { vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
pid = 4242; pid = 4242;
wasUnreferenced = false; unref = vi.fn();
unref(): void {
this.wasUnreferenced = true;
}
}
class FakeDaemonRuntime implements DaemonLaunchRuntime {
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
readonly daemonProcess = new FakeDaemonProcess();
foregroundStatus = 0;
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
resolveRunnerEntry(): string {
return this.runnerEntry;
}
resolveHome(env: NodeJS.ProcessEnv): string {
return env.PASEO_HOME ?? "/tmp/paseo";
}
spawnDetached(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
): DetachedDaemonProcess {
this.recordedLaunches.push({ mode: "detached", command, args, options });
return this.daemonProcess;
}
spawnForeground(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
) {
this.recordedLaunches.push({ mode: "foreground", command, args, options });
return { status: this.foregroundStatus, error: undefined };
}
} }
function expectSupervisorLaunch(argv: string[]): void { function expectSupervisorLaunch(argv: string[]): void {
@@ -76,59 +41,59 @@ function expectSupervisorLaunch(argv: string[]): void {
describe("local daemon launch supervision", () => { describe("local daemon launch supervision", () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers(); vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
}); });
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
const runtime = new FakeDaemonRuntime(); mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime); const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
expect(status).toBe(0); expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); expect(mocks.spawnSync).toHaveBeenCalledOnce();
const launch = runtime.recordedLaunches[0]; const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(launch?.mode).toBe("foreground"); expect(command).toBe(process.execPath);
expect(launch?.command).toBe(process.execPath); expectSupervisorLaunch(argv);
expectSupervisorLaunch(launch?.args ?? []); expect(argv).toContain("--no-relay");
expect(launch?.args).toContain("--no-relay");
}); });
test("detached start spawns supervisor-entrypoint instead of server/index", async () => { test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const runtime = new FakeDaemonRuntime(); const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const resultPromise = startLocalDaemonDetached( const { startLocalDaemonDetached } = await import("./local-daemon.js");
{ home: "/tmp/paseo-test", mcp: false }, const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
runtime,
);
await vi.advanceTimersByTimeAsync(1200); await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise; const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" }); expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(runtime.daemonProcess.wasUnreferenced).toBe(true); expect(child.unref).toHaveBeenCalledOnce();
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]); expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const launch = runtime.recordedLaunches[0]; const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(launch?.mode).toBe("detached"); expect(command).toBe(process.execPath);
expect(launch?.command).toBe(process.execPath); expectSupervisorLaunch(argv);
expectSupervisorLaunch(launch?.args ?? []); expect(argv).toContain("--no-mcp");
expect(launch?.args).toContain("--no-mcp");
}); });
test("relay TLS flag is passed to the supervised daemon", async () => { test("relay TLS flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime(); mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const status = startLocalDaemonForeground( const { startLocalDaemonForeground } = await import("./local-daemon.js");
{ const status = startLocalDaemonForeground({
home: "/tmp/paseo-test", home: "/tmp/paseo-test",
relayUseTls: true, relayUseTls: true,
}, });
runtime,
);
expect(status).toBe(0); expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
const launch = runtime.recordedLaunches[0]; string,
expect(launch?.mode).toBe("foreground"); string[],
expect(launch?.args).toContain("--relay-use-tls"); { env?: NodeJS.ProcessEnv },
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true"); ];
expect(argv).toContain("--relay-use-tls");
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
}); });
}); });
@@ -1,4 +1,4 @@
import { spawnSync, type ChildProcess } from "node:child_process"; import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
@@ -68,28 +68,6 @@ interface ProcessExitDetails {
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails); type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
export interface ForegroundDaemonProcessResult {
status: number | null;
error?: Error;
}
export interface DaemonLaunchRuntime {
resolveRunnerEntry(): string;
resolveHome(env: NodeJS.ProcessEnv): string;
spawnDetached(
command: string,
args: string[],
options: Parameters<typeof spawnProcess>[2],
): DetachedDaemonProcess;
spawnForeground(
command: string,
args: string[],
options: Parameters<typeof spawnSync>[2],
): ForegroundDaemonProcessResult;
}
const DETACHED_STARTUP_GRACE_MS = 1200; const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100; const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log"; const DAEMON_LOG_FILENAME = "daemon.log";
@@ -100,13 +78,6 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
spawnDetached: spawnProcess,
spawnForeground: spawnSync,
};
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }); const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
@@ -424,18 +395,17 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
export async function startLocalDaemonDetached( export async function startLocalDaemonDetached(
options: DaemonStartOptions, options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): Promise<DetachedStartResult> { ): Promise<DetachedStartResult> {
if (options.listen && options.port) { if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together"); throw new Error("Cannot use --listen and --port together");
} }
const daemonRunnerEntry = runtime.resolveRunnerEntry(); const daemonRunnerEntry = resolveDaemonRunnerEntry();
const childEnv = buildChildEnv(options); const childEnv = buildChildEnv(options);
const paseoHome = runtime.resolveHome(childEnv); const paseoHome = resolvePaseoHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME); const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const child = runtime.spawnDetached( const child = spawnProcess(
process.execPath, process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{ {
@@ -491,17 +461,14 @@ export async function startLocalDaemonDetached(
}; };
} }
export function startLocalDaemonForeground( export function startLocalDaemonForeground(options: DaemonStartOptions): number {
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): number {
if (options.listen && options.port) { if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together"); throw new Error("Cannot use --listen and --port together");
} }
const daemonRunnerEntry = runtime.resolveRunnerEntry(); const daemonRunnerEntry = resolveDaemonRunnerEntry();
const childEnv = buildChildEnv(options); const childEnv = buildChildEnv(options);
const result = runtime.spawnForeground( const result = spawnSync(
process.execPath, process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{ {
@@ -1,104 +0,0 @@
import type { DaemonClient } from "@getpaseo/server";
import type { CommandError, CommandOptions } from "../../output/index.js";
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
type CreatePaseoWorktreeRequest = Parameters<DaemonClient["createPaseoWorktree"]>[0];
export function buildCreateWorktreeRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off":
return buildBranchOffRequest(options, cwd);
case "checkout-branch":
return buildCheckoutBranchRequest(options, cwd);
case "checkout-pr":
return buildCheckoutPrRequest(options, cwd);
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
function buildBranchOffRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
worktreeSlug: options.newBranch,
action: "branch-off",
...(options.base ? { refName: options.base } : {}),
};
}
function buildCheckoutBranchRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return {
cwd,
action: "checkout",
refName: options.branch,
};
}
function buildCheckoutPrRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const prNumber = Number(options.prNumber);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return {
cwd,
action: "checkout",
githubPrNumber: prNumber,
};
}
function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message };
}
@@ -1,17 +1,21 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js"; import {
buildCreateWorktreeInput,
toDaemonCreateInput,
type WorktreeCreateOptions,
} from "./create.js";
const REPO = "/tmp/repo"; const REPO = "/tmp/repo";
function build(options: WorktreeCreateOptions): unknown { function build(options: WorktreeCreateOptions): unknown {
try { try {
return buildCreateWorktreeRequest(options, REPO); return buildCreateWorktreeInput(options, REPO);
} catch (err) { } catch (err) {
return err; return err;
} }
} }
describe("buildCreateWorktreeRequest", () => { describe("buildCreateWorktreeInput", () => {
it("requires --mode", () => { it("requires --mode", () => {
expect(build({})).toMatchObject({ code: "MISSING_MODE" }); expect(build({})).toMatchObject({ code: "MISSING_MODE" });
}); });
@@ -24,20 +28,17 @@ describe("buildCreateWorktreeRequest", () => {
expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" }); expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" });
}); });
it("branch-off builds a daemon request with a new branch", () => { it("branch-off parses with new branch only", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({ expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({
cwd: REPO, cwd: REPO,
worktreeSlug: "feature-x", target: { mode: "branch-off", newBranch: "feature-x" },
action: "branch-off",
}); });
}); });
it("branch-off includes the base ref when provided", () => { it("branch-off parses with base ref", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({ expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({
cwd: REPO, cwd: REPO,
worktreeSlug: "feature-x", target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
action: "branch-off",
refName: "main",
}); });
}); });
@@ -45,11 +46,10 @@ describe("buildCreateWorktreeRequest", () => {
expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" }); expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" });
}); });
it("checkout-branch builds a checkout request for the branch", () => { it("checkout-branch parses with branch", () => {
expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({ expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({
cwd: REPO, cwd: REPO,
action: "checkout", target: { mode: "checkout-branch", branch: "feat/x" },
refName: "feat/x",
}); });
}); });
@@ -69,8 +69,62 @@ describe("buildCreateWorktreeRequest", () => {
}); });
}); });
it("checkout-pr builds a checkout request for the pull request", () => { it("checkout-pr parses positive integers", () => {
expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({ expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
});
});
});
describe("toDaemonCreateInput", () => {
it("maps branch-off without base", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
});
});
it("maps branch-off with base ref", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
});
});
it("maps checkout-branch to action=checkout + refName", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-branch", branch: "feat/x" },
}),
).toEqual({
cwd: REPO,
action: "checkout",
refName: "feat/x",
});
});
it("maps checkout-pr to action=checkout + githubPrNumber", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
}),
).toEqual({
cwd: REPO, cwd: REPO,
action: "checkout", action: "checkout",
githubPrNumber: 42, githubPrNumber: 42,
+112 -4
View File
@@ -2,8 +2,12 @@ import path from "node:path";
import type { Command } from "commander"; import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/server"; import type { DaemonClient } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; import type {
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js"; CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
export interface WorktreeCreateResult { export interface WorktreeCreateResult {
name: string; name: string;
@@ -20,6 +24,110 @@ export const createSchema: OutputSchema<WorktreeCreateResult> = {
], ],
}; };
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
export type WorktreeCreateTarget =
| { mode: "branch-off"; newBranch: string; base?: string }
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
export interface ParsedWorktreeCreateInput {
cwd: string;
target: WorktreeCreateTarget;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
export function buildCreateWorktreeInput(
options: WorktreeCreateOptions,
cwd: string,
): ParsedWorktreeCreateInput {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off": {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
target: {
mode: "branch-off",
newBranch: options.newBranch,
...(options.base ? { base: options.base } : {}),
},
};
}
case "checkout-branch": {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return { cwd, target: { mode: "checkout-branch", branch: options.branch } };
}
case "checkout-pr": {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const parsed = Number(options.prNumber);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return { cwd, target: { mode: "checkout-pr", prNumber: parsed } };
}
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
switch (parsed.target.mode) {
case "branch-off":
return {
cwd: parsed.cwd,
worktreeSlug: parsed.target.newBranch,
action: "branch-off" as const,
...(parsed.target.base ? { refName: parsed.target.base } : {}),
};
case "checkout-branch":
return {
cwd: parsed.cwd,
action: "checkout" as const,
refName: parsed.target.branch,
};
case "checkout-pr":
return {
cwd: parsed.cwd,
action: "checkout" as const,
githubPrNumber: parsed.target.prNumber,
};
default:
throw new Error("unreachable");
}
}
function cmdError(code: string, message: string, details?: string): CommandError { function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message }; return details ? { code, message, details } : { code, message };
} }
@@ -29,7 +137,7 @@ export async function runCreateCommand(
_command: Command, _command: Command,
): Promise<SingleResult<WorktreeCreateResult>> { ): Promise<SingleResult<WorktreeCreateResult>> {
const cwd = options.cwd ?? process.cwd(); const cwd = options.cwd ?? process.cwd();
const request = buildCreateWorktreeRequest(options, cwd); const parsed = buildCreateWorktreeInput(options, cwd);
const host = getDaemonHost({ host: options.host }); const host = getDaemonHost({ host: options.host });
let client: DaemonClient; let client: DaemonClient;
@@ -45,7 +153,7 @@ export async function runCreateCommand(
} }
try { try {
const response = await client.createPaseoWorktree(request); const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
const workspace = response.workspace; const workspace = response.workspace;
if (!workspace || response.error) { if (!workspace || response.error) {
+5 -22
View File
@@ -8,26 +8,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
async function waitForLoopInList(
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
id: string,
) {
for (let attempt = 0; attempt < 20; attempt++) {
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
if (listedJson.some((item: { id: string }) => item.id === id)) {
return listedJson;
}
await sleep(250);
}
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
return JSON.parse(listed.stdout);
}
console.log("=== Loop And Schedule Command Tests ===\n"); console.log("=== Loop And Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 }); const ctx = await createE2ETestContext({ timeout: 30000 });
@@ -163,10 +143,13 @@ try {
const runJson = JSON.parse(run.stdout); const runJson = JSON.parse(run.stdout);
assert.strictEqual(runJson.name, "smoke-loop"); assert.strictEqual(runJson.name, "smoke-loop");
const listedJson = await waitForLoopInList(ctx, runJson.id); const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
assert( assert(
listedJson.some((item: { id: string }) => item.id === runJson.id), listedJson.some((item: { id: string }) => item.id === runJson.id),
JSON.stringify(listedJson), listed.stdout,
); );
async function pollStatus(attempt: number): Promise<string> { async function pollStatus(attempt: number): Promise<string> {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.75", "version": "0.1.71",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
@@ -1,4 +1,3 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
@@ -71,34 +70,6 @@ function desktopSettingsWithManagement(enabled: boolean) {
}; };
} }
type MockChildProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
pid: number;
spawnfile: string;
spawnargs: string[];
unref: ReturnType<typeof vi.fn>;
};
function createMockChildProcess(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 1234;
child.spawnfile = "node";
child.spawnargs = ["node", "daemon.js"];
child.unref = vi.fn();
return child;
}
function scheduleFailedStartupOutput(child: MockChildProcess): void {
setImmediate(() => {
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
child.emit("exit", 1, null);
});
}
describe("daemon-manager commands", () => { describe("daemon-manager commands", () => {
beforeEach(() => { beforeEach(() => {
mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.settings = DEFAULT_DESKTOP_SETTINGS;
@@ -194,61 +165,4 @@ describe("daemon-manager commands", () => {
"--json", "--json",
]); ]);
}); });
it("uses a reachable daemon when the PID file is stale", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 7675,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.2",
desktopManaged: true,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: "dev-host",
pid: null,
home: "/tmp/paseo-home",
version: "1.2.2",
desktopManaged: false,
error: null,
});
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("bounds captured daemon startup output", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
});
mocks.spawnProcess.mockImplementation(() => {
const child = createMockChildProcess();
scheduleFailedStartupOutput(child);
return child;
});
const handlers = createDaemonCommandHandlers();
let thrown: Error | null = null;
try {
await handlers.start_desktop_daemon();
} catch (error) {
thrown = error instanceof Error ? error : new Error(String(error));
}
expect(thrown).toBeInstanceOf(Error);
const message = thrown?.message ?? "";
expect(message).toContain("Daemon failed to start: exit code 1");
expect(message).toContain("output truncated to the last 65536 chars");
expect(message).toContain("stdout-tail");
expect(message).toContain("stderr-tail");
expect(message.length).toBeLessThan(150_000);
});
}); });
+15 -58
View File
@@ -43,7 +43,6 @@ const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200; const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150; const STARTUP_POLL_MAX_ATTEMPTS = 150;
const DETACHED_STARTUP_GRACE_MS = 1200; const DETACHED_STARTUP_GRACE_MS = 1200;
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -70,11 +69,6 @@ interface DesktopPairingOffer {
qr: string | null; qr: string | null;
} }
interface StartupOutputCapture {
text: string;
truncated: boolean;
}
function parseReleaseChannel( function parseReleaseChannel(
args: Record<string, unknown> | undefined, args: Record<string, unknown> | undefined,
): AppReleaseChannel | undefined { ): AppReleaseChannel | undefined {
@@ -151,30 +145,6 @@ function tailFile(filePath: string, lines = 50): string {
} }
} }
function createStartupOutputCapture(): StartupOutputCapture {
return { text: "", truncated: false };
}
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
const nextText = capture.text + chunk.toString();
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
return { text: nextText, truncated: capture.truncated };
}
return {
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
truncated: true,
};
}
function formatStartupOutput(capture: StartupOutputCapture): string {
if (!capture.truncated) {
return capture.text;
}
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
}
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void { function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
log.info("[desktop daemon]", message, { log.info("[desktop daemon]", message, {
pid: process.pid, pid: process.pid,
@@ -227,28 +197,17 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
unknown unknown
>; >;
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
const connectedDaemon = const running = localDaemon === "running";
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
status = "running";
} else if (localDaemon === "unresponsive") {
status = "errored";
}
return { return {
serverId: typeof payload.serverId === "string" ? payload.serverId : "", serverId: typeof payload.serverId === "string" ? payload.serverId : "",
status, status: running ? "running" : "stopped",
listen: typeof payload.listen === "string" ? payload.listen : null, listen: typeof payload.listen === "string" ? payload.listen : null,
hostname: hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null, pid: running && typeof payload.pid === "number" ? payload.pid : null,
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
home, home,
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true, desktopManaged: payload.desktopManaged === true,
error: null, error: null,
}; };
} catch (error) { } catch (error) {
@@ -289,17 +248,15 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
function buildStartupFailureError( function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error }, result: { code: number | null; signal: string | null; error?: Error },
stdout: StartupOutputCapture, stdout: string,
stderr: StartupOutputCapture, stderr: string,
): Error { ): Error {
const reason = result.error const reason = result.error
? result.error.message ? result.error.message
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`; : `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
const parts = [`Daemon failed to start: ${reason}`]; const parts = [`Daemon failed to start: ${reason}`];
const formattedStderr = formatStartupOutput(stderr).trim(); if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
const formattedStdout = formatStartupOutput(stdout).trim(); if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
const logs = tailFile(logFilePath(), 15); const logs = tailFile(logFilePath(), 15);
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`); if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
return new Error(parts.join("\n\n")); return new Error(parts.join("\n\n"));
@@ -380,13 +337,13 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
let stdout = createStartupOutputCapture(); let stdout = "";
let stderr = createStartupOutputCapture(); let stderr = "";
child.stdout!.on("data", (data: Buffer) => { child.stdout!.on("data", (data: Buffer) => {
stdout = appendStartupOutput(stdout, data); stdout += data.toString();
}); });
child.stderr!.on("data", (data: Buffer) => { child.stderr!.on("data", (data: Buffer) => {
stderr = appendStartupOutput(stderr, data); stderr += data.toString();
}); });
logDesktopDaemonLifecycle("detached spawn returned", { logDesktopDaemonLifecycle("detached spawn returned", {
@@ -424,8 +381,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
logDesktopDaemonLifecycle("detached startup grace period completed", { logDesktopDaemonLifecycle("detached startup grace period completed", {
childPid: child.pid ?? null, childPid: child.pid ?? null,
exitedEarly: result.exitedEarly, exitedEarly: result.exitedEarly,
stdout: formatStartupOutput(stdout).slice(0, 2000), stdout: stdout.slice(0, 2000),
stderr: formatStartupOutput(stderr).slice(0, 2000), stderr: stderr.slice(0, 2000),
...(result.exitedEarly ...(result.exitedEarly
? { ? {
exitCode: result.code, exitCode: result.code,
@@ -1,53 +0,0 @@
import { ipcMain, shell } from "electron";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener";
vi.mock("electron", () => ({
ipcMain: { handle: vi.fn() },
shell: { openExternal: vi.fn() },
}));
function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise<void> {
registerOpenerHandlers();
const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
return channel === "paseo:opener:openUrl";
})?.[1];
if (typeof handler !== "function") {
throw new Error("open URL handler was not registered");
}
return handler as (_event: unknown, url: unknown) => Promise<void>;
}
describe("desktop opener", () => {
beforeEach(() => {
vi.mocked(ipcMain.handle).mockReset();
vi.mocked(shell.openExternal).mockReset();
});
it("allows only http and https external URLs", () => {
expect(isAllowedExternalUrl("https://example.com/path")).toBe(true);
expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true);
expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false);
expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false);
expect(isAllowedExternalUrl("paseo://settings")).toBe(false);
expect(isAllowedExternalUrl("/relative/path")).toBe(false);
expect(isAllowedExternalUrl(null)).toBe(false);
});
it("opens allowed URLs through Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await handler({}, "https://example.com");
expect(shell.openExternal).toHaveBeenCalledWith("https://example.com");
});
it("rejects blocked URLs before invoking Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});
+1 -19
View File
@@ -1,25 +1,7 @@
import { shell, ipcMain } from "electron"; import { shell, ipcMain } from "electron";
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
export function isAllowedExternalUrl(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
try {
const url = new URL(value);
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol);
} catch {
return false;
}
}
export function registerOpenerHandlers(): void { export function registerOpenerHandlers(): void {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => { ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
if (!isAllowedExternalUrl(url)) {
throw new Error("Unsupported external URL");
}
await shell.openExternal(url); await shell.openExternal(url);
}); });
} }
-1
View File
@@ -62,7 +62,6 @@ function resolveShellEnv(): Record<string, string> | undefined {
const result = spawnSync(shell, [...shellArgs, command], { const result = spawnSync(shell, [...shellArgs, command], {
encoding: "utf8", encoding: "utf8",
timeout: RESOLVE_TIMEOUT_MS, timeout: RESOLVE_TIMEOUT_MS,
windowsHide: true,
env: { env: {
...shellEnv, ...shellEnv,
ELECTRON_RUN_AS_NODE: "1", ELECTRON_RUN_AS_NODE: "1",
-2
View File
@@ -191,7 +191,6 @@ if (forcedUserDataDir) {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8", encoding: "utf-8",
timeout: 3000, timeout: 3000,
windowsHide: true,
}).trim(); }).trim();
devWorktreeName = path.basename(topLevel); devWorktreeName = path.basename(topLevel);
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge. // Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
@@ -201,7 +200,6 @@ if (forcedUserDataDir) {
cwd: topLevel, cwd: topLevel,
encoding: "utf-8", encoding: "utf-8",
timeout: 3000, timeout: 3000,
windowsHide: true,
}).trim(), }).trim(),
); );
const isWorktree = path.resolve(topLevel, ".git") !== commonDir; const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.75", "version": "0.1.71",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.75", "version": "0.1.71",
"files": [ "files": [
"dist" "dist"
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.75", "version": "0.1.71",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist" "dist"
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.75", "version": "0.1.71",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -58,14 +58,14 @@
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133", "@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.75", "@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.75", "@getpaseo/relay": "0.1.71",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2", "@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0", "@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
@@ -10,9 +10,12 @@ import {
import { resolvePaseoHome } from "../src/server/paseo-home.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js"; import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
interface DaemonRunnerConfig { interface DaemonRunnerConfig {
devMode: boolean; devMode: boolean;
workerArgs: string[]; workerArgs: string[];
@@ -74,6 +77,28 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
return existsSync(runnerPath) ? runnerPath : null; return existsSync(runnerPath) ? runnerPath : null;
} }
function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
async function main(): Promise<void> { async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2)); const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
@@ -88,7 +113,7 @@ async function main(): Promise<void> {
const paseoHome = resolvePaseoHome(workerEnv); const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome); const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv); const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
try { try {
await acquirePidLock(paseoHome, null, { await acquirePidLock(paseoHome, null, {
@@ -1,42 +0,0 @@
import path from "node:path";
import type { loadPersistedConfig } from "../src/server/persisted-config.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 3;
export function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
env: NodeJS.ProcessEnv = process.env,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
const envRotateSize = env.PASEO_LOG_ROTATE_SIZE?.trim();
const envRotateMaxFiles = parseOptionalPositiveInteger(env.PASEO_LOG_ROTATE_COUNT);
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? envRotateSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles:
configuredFile?.rotate?.maxFiles ?? envRotateMaxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
function parseOptionalPositiveInteger(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
@@ -5,7 +5,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js"; import { isPlatform } from "../src/test-utils/platform.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)); const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -88,57 +87,6 @@ async function runSupervisorFixture(options: {
} }
describe("supervisor durable logging", () => { describe("supervisor durable logging", () => {
test("resolves rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(paseoHome, {}, {});
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "10m", maxFiles: 3 },
});
});
test("lets persisted rotation override env rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{
log: {
file: {
path: "logs/daemon.log",
rotate: { maxSize: "25m", maxFiles: 4 },
},
},
},
{
PASEO_LOG_ROTATE_SIZE: "200m",
PASEO_LOG_ROTATE_COUNT: "12",
},
);
expect(logFile).toEqual({
path: path.resolve(paseoHome, "logs", "daemon.log"),
rotate: { maxSize: "25m", maxFiles: 4 },
});
});
test("uses env rotation when persisted rotation is absent", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{},
{
PASEO_LOG_ROTATE_SIZE: "50m",
PASEO_LOG_ROTATE_COUNT: "8",
},
);
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "50m", maxFiles: 8 },
});
});
test("writes supervised worker stdout and stderr to daemon.log", async () => { test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({ const result = await runSupervisorFixture({
workerSource: ` workerSource: `
@@ -1,70 +0,0 @@
import { expect, test } from "vitest";
import { buildArchivedAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord } from "./agent-storage.js";
const BASE_RECORD: StoredAgentRecord = {
id: "agent-1",
provider: "codex",
cwd: "/workspace/project",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
labels: {},
lastStatus: "idle",
config: null,
};
test("archives a stored agent without changing terminal statuses", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["idle", "error", "closed"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe(status);
expect(archived.archivedAt).toBe("2025-01-03T00:00:00.000Z");
expect(archived.updatedAt).toBe(BASE_RECORD.updatedAt);
}
});
test("archives busy stored agents as idle", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["initializing", "running"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe("idle");
}
});
test("clears persisted attention when archiving", () => {
const archived = buildArchivedAgentRecord(
{
...BASE_RECORD,
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: "2025-01-02T12:00:00.000Z",
},
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived).toMatchObject({
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
});
test("can stamp updatedAt to the archive timestamp", () => {
const archived = buildArchivedAgentRecord(BASE_RECORD, {
archivedAt: "2025-01-03T00:00:00.000Z",
updatedAt: "2025-01-03T00:00:00.000Z",
});
expect(archived.updatedAt).toBe("2025-01-03T00:00:00.000Z");
});
@@ -1,30 +0,0 @@
import type { StoredAgentRecord } from "./agent-storage.js";
export type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface BuildArchivedAgentRecordOptions {
archivedAt?: string;
updatedAt?: string;
}
export function buildArchivedAgentRecord(
record: StoredAgentRecord,
options?: BuildArchivedAgentRecordOptions,
): ArchivedStoredAgentRecord {
const archivedAt = options?.archivedAt ?? new Date().toISOString();
return {
...record,
archivedAt,
updatedAt: options?.updatedAt ?? record.updatedAt,
lastStatus: normalizeArchivedStatus(record.lastStatus),
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
}
function normalizeArchivedStatus(
status: StoredAgentRecord["lastStatus"],
): StoredAgentRecord["lastStatus"] {
return status === "running" || status === "initializing" ? "idle" : status;
}
@@ -716,7 +716,6 @@ test("createAgent passes daemon launch env through the provider launch context",
modeId: "auto", modeId: "auto",
}); });
expect(client.lastLaunchContext).toEqual({ expect(client.lastLaunchContext).toEqual({
agentId: snapshot.id,
env: { env: {
PASEO_AGENT_ID: snapshot.id, PASEO_AGENT_ID: snapshot.id,
}, },
@@ -1180,7 +1179,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
}, },
}); });
expect(client.lastResumeLaunchContext).toEqual({ expect(client.lastResumeLaunchContext).toEqual({
agentId: resumed.id,
env: { env: {
PASEO_AGENT_ID: resumed.id, PASEO_AGENT_ID: resumed.id,
}, },
@@ -1285,7 +1283,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
}); });
expect(client.lastCreateLaunchContext).toEqual({ expect(client.lastCreateLaunchContext).toEqual({
agentId: snapshot.id,
env: { env: {
PASEO_AGENT_ID: snapshot.id, PASEO_AGENT_ID: snapshot.id,
}, },
@@ -1296,7 +1293,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
}); });
expect(client.lastResumeLaunchContext).toEqual({ expect(client.lastResumeLaunchContext).toEqual({
agentId: snapshot.id,
env: { env: {
PASEO_AGENT_ID: snapshot.id, PASEO_AGENT_ID: snapshot.id,
}, },
+65 -218
View File
@@ -10,33 +10,31 @@ import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { import type {
getAgentStreamEventTurnId, AgentCapabilityFlags,
type AgentCapabilityFlags, AgentClient,
type AgentClient, AgentCreateSessionOptions,
type AgentCreateSessionOptions, AgentFeature,
type AgentFeature, AgentLaunchContext,
type AgentLaunchContext, AgentSlashCommand,
type AgentSlashCommand, AgentMode,
type AgentMode, AgentPermissionRequest,
type AgentPermissionRequest, AgentPermissionResponse,
type AgentPermissionResponse, AgentPermissionResult,
type AgentPermissionResult, AgentPersistenceHandle,
type AgentPersistenceHandle, AgentPromptInput,
type AgentPromptInput, AgentProvider,
type AgentProvider, AgentRunOptions,
type AgentRunOptions, AgentRunResult,
type AgentRunResult, AgentSession,
type AgentSession, AgentSessionConfig,
type AgentSessionConfig, AgentStreamEvent,
type AgentStreamEvent, AgentTimelineItem,
type AgentTimelineItem, AgentUsage,
type AgentUsage, AgentRuntimeInfo,
type AgentRuntimeInfo, ListPersistedAgentsOptions,
type ListPersistedAgentsOptions, PersistedAgentDescriptor,
type PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import { import {
InMemoryAgentTimelineStore, InMemoryAgentTimelineStore,
@@ -68,6 +66,7 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
}; };
type TimeoutResult = "completed" | "timed_out"; type TimeoutResult = "completed" | "timed_out";
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface TimeoutOptions { interface TimeoutOptions {
operation: Promise<void>; operation: Promise<void>;
@@ -1006,28 +1005,18 @@ export class AgentManager {
this.logger.trace( this.logger.trace(
{ {
agentId, agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle, lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId, activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size, pendingPermissions: agent.pendingPermissions.size,
}, },
"agent.manager.close.start", "closeAgent: start",
); );
const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
await agent.session.close(); await agent.session.close();
this.timelineStore.delete(agentId); this.timelineStore.delete(agentId);
await this.persistSnapshot(closedAgent); await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent, { persist: false }); this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace( this.logger.trace({ agentId }, "closeAgent: completed");
{
agentId,
provider: closedAgent.provider,
sessionId: closedAgent.persistence?.sessionId ?? undefined,
},
"agent.manager.close.complete",
);
} }
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
@@ -1082,7 +1071,19 @@ export class AgentManager {
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> { private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const registry = this.requireRegistry(); const registry = this.requireRegistry();
const archivedAt = new Date().toISOString(); const archivedAt = new Date().toISOString();
const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt }); const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const archivedRecord: ArchivedStoredAgentRecord = {
...record,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await registry.upsert(archivedRecord); await registry.upsert(archivedRecord);
@@ -1258,7 +1259,19 @@ export class AgentManager {
throw new Error(`Agent not found: ${agentId}`); throw new Error(`Agent not found: ${agentId}`);
} }
const nextRecord = buildArchivedAgentRecord(record, { archivedAt }); const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const nextRecord: StoredAgentRecord = {
...record,
archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await registry.upsert(nextRecord); await registry.upsert(nextRecord);
await this.archiveNativeSessionBestEffort(record.provider, record.persistence); await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
@@ -1481,28 +1494,22 @@ export class AgentManager {
this.logger.trace( this.logger.trace(
{ {
agentId, agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle, lifecycle: existingAgent.lifecycle,
activeForegroundTurnId: existingAgent.activeForegroundTurnId, activeForegroundTurnId: existingAgent.activeForegroundTurnId,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId), hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
promptType: typeof prompt === "string" ? "string" : "structured", promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options), hasRunOptions: Boolean(options),
}, },
"agent.manager.stream.request", "streamAgent: requested",
); );
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) { if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
this.logger.trace( this.logger.trace(
{ {
agentId, agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle, lifecycle: existingAgent.lifecycle,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId), hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
}, },
"agent.manager.stream.reject", "streamAgent: rejected because a foreground run is already in flight",
); );
throw new Error(`Agent ${agentId} already has an active run`); throw new Error(`Agent ${agentId} already has an active run`);
} }
@@ -1539,13 +1546,10 @@ export class AgentManager {
this.logger.trace( this.logger.trace(
{ {
agentId, agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle, lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId, activeForegroundTurnId: agent.activeForegroundTurnId,
}, },
"agent.manager.stream.start", "streamAgent: started",
); );
turnStream = this.foregroundRuns.createTurnStream(turnId); turnStream = this.foregroundRuns.createTurnStream(turnId);
@@ -1597,14 +1601,11 @@ export class AgentManager {
this.logger.trace( this.logger.trace(
{ {
agentId: agent.id, agentId: agent.id,
provider: agent.provider,
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: mutableAgent.lifecycle, lifecycle: mutableAgent.lifecycle,
terminalError, terminalError,
pendingReplacement: mutableAgent.pendingReplacement, pendingReplacement: mutableAgent.pendingReplacement,
}, },
"agent.manager.finalize", "finalizeForegroundTurn: applying terminal state",
); );
if (!shouldHoldBusyForReplacement) { if (!shouldHoldBusyForReplacement) {
this.touchUpdatedAt(mutableAgent); this.touchUpdatedAt(mutableAgent);
@@ -2394,16 +2395,6 @@ export class AgentManager {
} }
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void { private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.enqueue",
);
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve(); const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
const next = previous const next = previous
.catch(() => undefined) .catch(() => undefined)
@@ -2415,16 +2406,6 @@ export class AgentManager {
if (current.session == null) { if (current.session == null) {
return; return;
} }
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: current.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.dequeue",
);
await this.dispatchSessionEvent(current, event); await this.dispatchSessionEvent(current, event);
return; return;
}) })
@@ -2448,19 +2429,8 @@ export class AgentManager {
agent: ActiveManagedAgent, agent: ActiveManagedAgent,
event: AgentStreamEvent, event: AgentStreamEvent,
): Promise<void> { ): Promise<void> {
const turnId = getAgentStreamEventTurnId(event); const turnId = (event as { turnId?: string }).turnId;
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId); const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
matchingWaiterCount: matchingWaiters.length,
event,
},
"agent.manager.dispatch_session_event",
);
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event); const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
@@ -2471,18 +2441,6 @@ export class AgentManager {
this.foregroundRuns.notifyWaiters(matchingWaiters, event, { this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
terminal: isTurnTerminalEvent(event), terminal: isTurnTerminalEvent(event),
}); });
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
notifiedWaiterCount: matchingWaiters.length,
terminal: isTurnTerminalEvent(event),
event,
},
"agent.manager.notify_waiters",
);
} }
private async resolveInitialPersistedTitle( private async resolveInitialPersistedTitle(
@@ -2595,7 +2553,7 @@ export class AgentManager {
} }
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void { private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
const turnId = getAgentStreamEventTurnId(event); const turnId = (event as { turnId?: string }).turnId;
if (turnId == null) { if (turnId == null) {
return; return;
} }
@@ -2606,16 +2564,6 @@ export class AgentManager {
} }
this.foregroundRuns.notifyAgentWaiters(agent, event); this.foregroundRuns.notifyAgentWaiters(agent, event);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.notify_waiters.coalesced",
);
} }
private async handleStreamEvent( private async handleStreamEvent(
@@ -2623,9 +2571,8 @@ export class AgentManager {
event: AgentStreamEvent, event: AgentStreamEvent,
options?: HandleStreamEventOptions, options?: HandleStreamEventOptions,
): Promise<boolean> { ): Promise<boolean> {
const eventTurnId = getAgentStreamEventTurnId(event); const eventTurnId = (event as { turnId?: string }).turnId;
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId); const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
if ( if (
eventTurnId && eventTurnId &&
isTurnTerminalEvent(event) && isTurnTerminalEvent(event) &&
@@ -2638,7 +2585,6 @@ export class AgentManager {
if (!options?.fromHistory) { if (!options?.fromHistory) {
this.touchUpdatedAt(agent); this.touchUpdatedAt(agent);
if (this.agentStreamCoalescer.handle(agent.id, event)) { if (this.agentStreamCoalescer.handle(agent.id, event)) {
this.traceCoalescerBuffered(agent, event, eventTurnId);
return false; return false;
} }
this.agentStreamCoalescer.flushFor(agent.id); this.agentStreamCoalescer.flushFor(agent.id);
@@ -2666,71 +2612,9 @@ export class AgentManager {
this.dispatchStream(agent.id, event); this.dispatchStream(agent.id, event);
} }
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
return flags.shouldNotifyWaiters; return flags.shouldNotifyWaiters;
} }
private traceHandleStreamEventStart(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
isForegroundEvent: boolean,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
isForegroundEvent,
event,
},
"agent.manager.handle_stream_event.start",
);
}
private traceCoalescerBuffered(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.coalescer.buffer",
);
}
private traceHandleStreamEventEnd(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
flags: StreamEventFlags,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
shouldDispatchEvent: flags.shouldDispatchEvent,
shouldNotifyWaiters: flags.shouldNotifyWaiters,
event,
},
"agent.manager.handle_stream_event.end",
);
}
private dispatchStreamEventByType(params: { private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent; agent: ActiveManagedAgent;
event: AgentStreamEvent; event: AgentStreamEvent;
@@ -2887,13 +2771,11 @@ export class AgentManager {
this.logger.trace( this.logger.trace(
{ {
agentId: agent.id, agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle, lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId, activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
}, },
"agent.manager.turn.completed", "handleStreamEvent: turn_completed",
); );
agent.lastUsage = event.usage; agent.lastUsage = event.usage;
agent.lastError = undefined; agent.lastError = undefined;
@@ -2920,9 +2802,6 @@ export class AgentManager {
this.logger.warn( this.logger.warn(
{ {
agentId: agent.id, agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle, lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId, activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId, eventTurnId,
@@ -2963,14 +2842,11 @@ export class AgentManager {
this.logger.trace( this.logger.trace(
{ {
agentId: agent.id, agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle, lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId, activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId, eventTurnId,
}, },
"agent.manager.turn.canceled", "handleStreamEvent: turn_canceled",
); );
if (!isForegroundEvent && !agent.pendingReplacement) { if (!isForegroundEvent && !agent.pendingReplacement) {
agent.lifecycle = "idle"; agent.lifecycle = "idle";
@@ -2991,13 +2867,11 @@ export class AgentManager {
this.logger.trace( this.logger.trace(
{ {
agentId: agent.id, agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle, lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId, activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
}, },
"agent.manager.turn.started", "handleStreamEvent: turn_started",
); );
if (!isForegroundEvent) { if (!isForegroundEvent) {
agent.lifecycle = "running"; agent.lifecycle = "running";
@@ -3143,20 +3017,6 @@ export class AgentManager {
this.syncFeaturesFromSession(agent); this.syncFeaturesFromSession(agent);
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
persist: options?.persist !== false,
},
"agent.manager.emit_state",
);
this.dispatch({ this.dispatch({
type: "agent_state", type: "agent_state",
agent: { ...agent }, agent: { ...agent },
@@ -3284,18 +3144,6 @@ export class AgentManager {
event: AgentStreamEvent, event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string }, metadata?: { seq?: number; epoch?: string },
): void { ): void {
const agent = this.agents.get(agentId);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
metadata,
event,
},
"agent.manager.dispatch_stream",
);
this.dispatch({ type: "agent_stream", agentId, event, ...metadata }); this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
} }
@@ -3391,7 +3239,6 @@ export class AgentManager {
private buildLaunchContext(agentId: string): AgentLaunchContext { private buildLaunchContext(agentId: string): AgentLaunchContext {
return { return {
agentId,
env: { env: {
PASEO_AGENT_ID: agentId, PASEO_AGENT_ID: agentId,
}, },
@@ -17,19 +17,6 @@ export function startAgentRun(
logger: Logger, logger: Logger,
options?: StartAgentRunOptions, options?: StartAgentRunOptions,
): { outOfBand: boolean } { ): { outOfBand: boolean } {
const snapshot = agentManager.getAgent(agentId);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
turnId: snapshot?.activeForegroundTurnId ?? undefined,
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options?.runOptions),
replaceRunning: Boolean(options?.replaceRunning),
},
"agent.session.start_stream.request",
);
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an // Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn — replaceAgentRun would interrupt the running turn. The // in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint. // intercept lives at this layer so it covers every prompt entrypoint.
@@ -41,38 +28,12 @@ export function startAgentRun(
const iterator = shouldReplace const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt, runOptions) ? agentManager.replaceAgentRun(agentId, prompt, runOptions)
: agentManager.streamAgent(agentId, prompt, runOptions); : agentManager.streamAgent(agentId, prompt, runOptions);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
shouldReplace,
},
"agent.session.start_stream.iterator_returned",
);
void (async () => { void (async () => {
try { try {
for await (const _ of iterator) { for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers. // Events are broadcast via AgentManager subscribers.
} }
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
},
"agent.session.iterator.drained",
);
} catch (error) { } catch (error) {
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
err: error,
},
"agent.session.iterator.error",
);
logger.error({ err: error, agentId }, "Agent stream failed"); logger.error({ err: error, agentId }, "Agent stream failed");
} }
})(); })();
@@ -358,10 +358,6 @@ export type AgentStreamEvent =
timestamp: string; timestamp: string;
}; };
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
return "turnId" in event ? event.turnId : undefined;
}
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other"; export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
export type AgentPermissionUpdate = AgentMetadata; export type AgentPermissionUpdate = AgentMetadata;
@@ -480,7 +476,6 @@ export interface AgentSessionConfig {
} }
export interface AgentLaunchContext { export interface AgentLaunchContext {
agentId?: string;
env?: Record<string, string>; env?: Record<string, string>;
} }
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { getAgentStreamEventTurnId, type AgentStreamEvent } from "./agent-sdk-types.js"; import type { AgentStreamEvent } from "./agent-sdk-types.js";
export interface ForegroundTurnWaiter { export interface ForegroundTurnWaiter {
turnId: string; turnId: string;
@@ -105,7 +105,7 @@ export class ForegroundRunState {
event: AgentStreamEvent, event: AgentStreamEvent,
options?: { terminal?: boolean }, options?: { terminal?: boolean },
): void { ): void {
const waiters = this.getMatchingWaiters(agent, getAgentStreamEventTurnId(event)); const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false }); this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
} }
@@ -226,3 +226,7 @@ function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
pendingRun.settled = true; pendingRun.settled = true;
pendingRun.resolveSettled(); pendingRun.resolveSettled();
} }
function eventTurnId(event: AgentStreamEvent): string | undefined {
return (event as { turnId?: string }).turnId;
}
@@ -230,7 +230,6 @@ export function findExecutable(name: string): string | null {
const result = execFileSync(cmd, [trimmed], { const result = execFileSync(cmd, [trimmed], {
encoding: "utf8", encoding: "utf8",
env: createProviderEnv({ baseEnv: process.env }), env: createProviderEnv({ baseEnv: process.env }),
windowsHide: true,
}).trim(); }).trim();
const lines = result.split(/\r?\n/).filter((l: string) => l.trim()); const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
const candidate = lines.at(-1)?.trim() ?? null; const candidate = lines.at(-1)?.trim() ?? null;
@@ -11,7 +11,6 @@ export interface AgentModeVisuals {
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> & export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
AgentModeVisuals & { AgentModeVisuals & {
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
isUnattended?: boolean; isUnattended?: boolean;
}; };
@@ -97,9 +96,9 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
colorTier: "planning", colorTier: "planning",
}, },
{ {
id: "allow-all", id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Allow All", label: "Autopilot",
description: "Automatically approves all Copilot tool, path, and URL requests.", description: "Autonomous mode that runs until task completion without user interaction",
icon: "ShieldOff", icon: "ShieldOff",
colorTier: "dangerous", colorTier: "dangerous",
isUnattended: true, isUnattended: true,
@@ -67,21 +67,10 @@ export interface BuildProviderRegistryOptions {
isDev?: boolean; isDev?: boolean;
} }
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService"
> {
customProvider?: {
id: string;
label: string;
extends: string;
};
}
type ProviderClientFactory = ( type ProviderClientFactory = (
logger: Logger, logger: Logger,
runtimeSettings?: ProviderRuntimeSettings, runtimeSettings?: ProviderRuntimeSettings,
options?: ProviderClientFactoryOptions, options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
) => AgentClient; ) => AgentClient;
interface ResolvedProvider { interface ResolvedProvider {
@@ -103,7 +92,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
codex: (logger, runtimeSettings, options) => codex: (logger, runtimeSettings, options) =>
new CodexAppServerAgentClient(logger, runtimeSettings, { new CodexAppServerAgentClient(logger, runtimeSettings, {
workspaceGitService: options?.workspaceGitService, workspaceGitService: options?.workspaceGitService,
customProvider: options?.customProvider,
}), }),
copilot: (logger, runtimeSettings) => copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({ new CopilotACPAgentClient({
@@ -522,11 +510,10 @@ function addDerivedProviders(
continue; continue;
} }
const baseProviderId = override.extends; const baseProvider = resolvedProviders.get(override.extends);
const baseProvider = resolvedProviders.get(baseProviderId);
if (!baseProvider) { if (!baseProvider) {
throw new Error( throw new Error(
`Custom provider '${providerId}' extends unknown provider '${baseProviderId}'`, `Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
); );
} }
@@ -535,7 +522,7 @@ function addDerivedProviders(
toRuntimeSettings(override), toRuntimeSettings(override),
); );
const baseDefinition = baseProvider.definition; const baseDefinition = baseProvider.definition;
const baseFactory = getProviderClientFactory(baseProviderId); const baseFactory = getProviderClientFactory(override.extends);
resolvedProviders.set(providerId, { resolvedProviders.set(providerId, {
definition: createDerivedDefinition(providerId, baseDefinition, override), definition: createDerivedDefinition(providerId, baseDefinition, override),
@@ -543,15 +530,8 @@ function addDerivedProviders(
profileModels: override.models ?? [], profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [], additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false, enabled: override.enabled !== false,
derivedFromProviderId: baseProviderId, derivedFromProviderId: override.extends,
createBaseClient: (logger) => createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
baseFactory(logger, mergedRuntimeSettings, {
customProvider: {
id: providerId,
label: override.label ?? providerId,
extends: baseProviderId,
},
}),
}); });
} }
} }
@@ -21,15 +21,6 @@ import {
resolveACPModeSelection, resolveACPModeSelection,
resolveACPModelSelection, resolveACPModelSelection,
} from "./acp-agent.js"; } from "./acp-agent.js";
import {
COPILOT_ALLOW_ALL_MODE_ID,
COPILOT_MODES,
beforeCopilotModeWriter,
transformCopilotConfigOptions,
transformCopilotModeId,
transformCopilotSessionResponse,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js"; import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -149,73 +140,6 @@ function selectConfigOption(
}; };
} }
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
return new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: modeId ?? undefined,
},
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
}
function copilotModeConfigOption(currentValue: string): SessionConfigOption {
return {
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue,
options: [
{
value: "https://agentclientprotocol.com/protocol/session-modes#agent",
name: "Agent",
},
{
value: "https://agentclientprotocol.com/protocol/session-modes#plan",
name: "Plan",
},
{
value: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
name: "Autopilot",
},
],
};
}
function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigOption {
return {
id: "allow_all",
name: "Allow All",
category: "permissions",
type: "select",
currentValue,
options: [
{ value: "on", name: "On" },
{ value: "off", name: "Off" },
],
};
}
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string { function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
if (category === "mode") { if (category === "mode") {
return "Mode"; return "Mode";
@@ -266,7 +190,7 @@ function prepareConfiguredOverrideSession(
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => { test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const childLogger = { trace: vi.fn(), warn: vi.fn() }; const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger)); vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const session = createSessionWithConfig({}, logger); const session = createSessionWithConfig({}, logger);
const setSessionConfigOption = vi.fn(async () => ({ const setSessionConfigOption = vi.fn(async () => ({
@@ -653,7 +577,7 @@ describe("ACPAgentSession Zed parity", () => {
expect(await validSession.getCurrentMode()).toBe("default"); expect(await validSession.getCurrentMode()).toBe("default");
const logger = createTestLogger(); const logger = createTestLogger();
const childLogger = { trace: vi.fn(), warn: vi.fn() }; const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger)); vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const invalidSession = createSessionWithConfig( const invalidSession = createSessionWithConfig(
{ modeId: "acceptEdits", model: "opus" }, { modeId: "acceptEdits", model: "opus" },
@@ -872,10 +796,12 @@ describe("ACPAgentSession Zed parity", () => {
}); });
}); });
test("passes generic ACP permission requests through to the user", async () => { test("passes Copilot Autopilot ACP permission requests through to the user", async () => {
// Zed parity: Copilot Autopilot no longer auto-approves ACP permission requests,
// so the accepted UX regression is that every tool request reaches the user UI.
const session = createSessionWithConfig({ const session = createSessionWithConfig({
provider: "cursor-acp", provider: "copilot",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent", modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
}); });
const events: Array<{ type: string; request?: { id: string } }> = []; const events: Array<{ type: string; request?: { id: string } }> = [];
const permissionOptions: PermissionOption[] = [ const permissionOptions: PermissionOption[] = [
@@ -909,126 +835,6 @@ describe("ACPAgentSession Zed parity", () => {
outcome: { outcome: "selected", optionId: "allow-once" }, outcome: { outcome: "selected", optionId: "allow-once" },
}); });
}); });
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
const { internals } = prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
await internals.applyConfiguredOverrides();
unsubscribe();
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
});
test("accepts Copilot's legacy autopilot mode ID as Allow All", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig();
prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#autopilot");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
test("switching Copilot away from Allow All turns allow_all off before setting the ACP mode", async () => {
const setSessionConfigOption = vi.fn(async (input: { value: string }) => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption(input.value === "on" ? "on" : "off"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
prepareConfiguredOverrideSession(session, {
currentMode: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption(COPILOT_ALLOW_ALL_MODE_ID),
copilotAllowAllConfigOption("on"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#agent");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "off",
});
expect(setSessionMode).toHaveBeenCalledWith({
sessionId: "session-1",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
});
test("trusts Copilot allow_all config updates as the current mode source", async () => {
const session = createCopilotSessionWithConfig();
const internals = asInternals<ACPSessionInternals>(session);
const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
});
expect(events).toMatchObject([
{
type: "mode_changed",
provider: "copilot",
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: expect.arrayContaining([
expect.objectContaining({ id: COPILOT_ALLOW_ALL_MODE_ID, label: "Allow All" }),
]),
},
]);
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
}); });
describe("deriveModelDefinitionsFromACP", () => { describe("deriveModelDefinitionsFromACP", () => {
@@ -54,36 +54,35 @@ import {
} from "@agentclientprotocol/sdk"; } from "@agentclientprotocol/sdk";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { import type {
getAgentStreamEventTurnId, AgentCapabilityFlags,
type AgentCapabilityFlags, AgentClient,
type AgentClient, AgentLaunchContext,
type AgentLaunchContext, AgentMetadata,
type AgentMetadata, AgentMode,
type AgentMode, AgentModelDefinition,
type AgentModelDefinition, AgentPermissionRequest,
type AgentPermissionRequest, AgentPermissionRequestKind,
type AgentPermissionRequestKind, AgentPermissionResponse,
type AgentPermissionResponse, AgentPersistenceHandle,
type AgentPersistenceHandle, AgentPromptContentBlock,
type AgentPromptContentBlock, AgentPromptInput,
type AgentPromptInput, AgentRunOptions,
type AgentRunOptions, AgentRunResult,
type AgentRunResult, AgentRuntimeInfo,
type AgentRuntimeInfo, AgentSession,
type AgentSession, AgentSessionConfig,
type AgentSessionConfig, AgentSlashCommand,
type AgentSlashCommand, AgentStreamEvent,
type AgentStreamEvent, AgentTimelineItem,
type AgentTimelineItem, AgentUsage,
type AgentUsage, ListModesOptions,
type ListModesOptions, ListModelsOptions,
type ListModelsOptions, ListPersistedAgentsOptions,
type ListPersistedAgentsOptions, McpServerConfig,
type McpServerConfig, PersistedAgentDescriptor,
type PersistedAgentDescriptor, ToolCallDetail,
type ToolCallDetail, ToolCallTimelineItem,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { import {
createProviderEnvSpec, createProviderEnvSpec,
@@ -227,13 +226,7 @@ interface ACPAgentClientOptions {
defaultModes?: AgentMode[]; defaultModes?: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: ( thinkingOptionWriter?: (
connection: ClientSideConnection, connection: ClientSideConnection,
sessionId: string, sessionId: string,
@@ -252,13 +245,7 @@ interface ACPAgentSessionOptions {
defaultModes: AgentMode[]; defaultModes: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: ( thinkingOptionWriter?: (
connection: ClientSideConnection, connection: ClientSideConnection,
sessionId: string, sessionId: string,
@@ -266,7 +253,6 @@ interface ACPAgentSessionOptions {
) => Promise<void>; ) => Promise<void>;
capabilities: AgentCapabilityFlags; capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle; handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>; launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean; waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number; initialCommandsWaitTimeoutMs?: number;
@@ -351,26 +337,6 @@ interface ACPModelSelection {
hasAvailableModels: boolean; hasAvailableModels: boolean;
} }
export interface ACPProviderModeWriterContext {
connection: ClientSideConnection;
sessionId: string;
requestedModeId: string;
currentModeId: string | null;
selection: ACPModeSelection;
configOptions: SessionConfigOption[];
logger: Logger;
}
export interface ACPProviderModeWriteResult {
handled: boolean;
currentModeId?: string;
configOptions?: SessionConfigOption[];
}
export interface ACPBeforeModeWriteResult {
configOptions?: SessionConfigOption[];
}
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined { export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
if (!usage) { if (!usage) {
return undefined; return undefined;
@@ -499,17 +465,7 @@ export class ACPAgentClient implements AgentClient {
private readonly sessionResponseTransformer?: ( private readonly sessionResponseTransformer?: (
response: SessionStateResponse, response: SessionStateResponse,
) => SessionStateResponse; ) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: ( private readonly thinkingOptionWriter?: (
connection: ClientSideConnection, connection: ClientSideConnection,
sessionId: string, sessionId: string,
@@ -521,20 +477,13 @@ export class ACPAgentClient implements AgentClient {
constructor(options: ACPAgentClientOptions) { constructor(options: ACPAgentClientOptions) {
this.provider = options.provider; this.provider = options.provider;
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES; this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
this.logger = options.logger.child({ this.logger = options.logger.child({ module: "agent", provider: options.provider });
module: "agent",
provider: options.provider,
});
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand; this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes ?? []; this.defaultModes = options.defaultModes ?? [];
this.modelTransformer = options.modelTransformer; this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter; this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false; this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
@@ -555,14 +504,9 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes, defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer, modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer, sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter, thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities, capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands, waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -601,15 +545,10 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes, defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer, modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer, sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter, thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities, capabilities: this.capabilities,
handle, handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands, waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -808,16 +747,7 @@ export class ACPAgentClient implements AgentClient {
} }
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse { protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
const transformed = this.sessionResponseTransformer return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
? this.sessionResponseTransformer(response)
: response;
if (!this.configOptionsTransformer || !transformed.configOptions) {
return transformed;
}
return {
...transformed,
configOptions: this.configOptionsTransformer(transformed.configOptions),
};
} }
} }
@@ -833,23 +763,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly sessionResponseTransformer?: ( private readonly sessionResponseTransformer?: (
response: SessionStateResponse, response: SessionStateResponse,
) => SessionStateResponse; ) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: ( private readonly thinkingOptionWriter?: (
connection: ClientSideConnection, connection: ClientSideConnection,
sessionId: string, sessionId: string,
thinkingOptionId: string, thinkingOptionId: string,
) => Promise<void>; ) => Promise<void>;
private readonly agentId?: string;
private readonly launchEnv?: Record<string, string>; private readonly launchEnv?: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>(); private readonly pendingPermissions = new Map<string, PendingPermission>();
@@ -895,14 +814,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.defaultModes = options.defaultModes; this.defaultModes = options.defaultModes;
this.modelTransformer = options.modelTransformer; this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter; this.thinkingOptionWriter = options.thinkingOptionWriter;
this.availableModes = options.defaultModes; this.availableModes = options.defaultModes;
this.agentId = options.agentId;
this.launchEnv = options.launchEnv; this.launchEnv = options.launchEnv;
this.initialHandle = options.handle; this.initialHandle = options.handle;
this.config = { ...config, provider: options.provider }; this.config = { ...config, provider: options.provider };
@@ -1154,25 +1068,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized"); throw new Error("ACP session not initialized");
} }
const context = this.createProviderModeWriterContext(modeId, selection);
const providerResult = this.providerModeWriter
? await this.providerModeWriter(context)
: { handled: false };
if (providerResult.handled) {
this.currentMode = providerResult.currentModeId ?? modeId;
if (providerResult.configOptions) {
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
}
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
});
return;
}
if (selection.hasAvailableModes) { if (selection.hasAvailableModes) {
if (!selection.availableMode) { if (!selection.availableMode) {
this.warnInvalidSelection( this.warnInvalidSelection(
@@ -1183,32 +1078,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
); );
return; return;
} }
} else {
const modeOption = selection.configOption;
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
}
if (this.beforeModeWriter) {
const beforeResult = await this.beforeModeWriter(context);
if (beforeResult?.configOptions) {
this.configOptions = this.transformConfigOptions(beforeResult.configOptions);
}
}
if (selection.hasAvailableModes) {
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId }); await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
this.currentMode = modeId; this.currentMode = modeId;
this.pushEvent({ this.pushEvent({
@@ -1224,6 +1094,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!modeOption) { if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`); throw new Error(`${this.provider} does not expose ACP mode switching`);
} }
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
const response = await this.connection.setSessionConfigOption({ const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId, sessionId: this.sessionId,
@@ -1246,24 +1127,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}); });
} }
private createProviderModeWriterContext(
requestedModeId: string,
selection: ACPModeSelection,
): ACPProviderModeWriterContext {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
return {
connection: this.connection,
sessionId: this.sessionId,
requestedModeId,
currentModeId: this.currentMode,
selection,
configOptions: this.configOptions,
logger: this.logger,
};
}
async setModel(modelId: string | null): Promise<void> { async setModel(modelId: string | null): Promise<void> {
if (!this.connection || !this.sessionId) { if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized"); throw new Error("ACP session not initialized");
@@ -1418,9 +1281,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: string; requestedValue: string;
label: string; label: string;
}): string { }): string {
this.configOptions = this.transformConfigOptions(response.configOptions); this.configOptions = response.configOptions;
const responseOption = findSelectConfigOption({ const responseOption = findSelectConfigOption({
configOptions: this.configOptions, configOptions: response.configOptions,
category, category,
id: configId, id: configId,
}); });
@@ -1546,7 +1409,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
// Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through. // Match Zed acp.rs:3189-3220 — pure pass-through. Accepted UX regression: Copilot Autopilot will now prompt the user for every tool request.
const requestId = randomUUID(); const requestId = randomUUID();
let toolSnapshot = let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ?? this.toolCalls.get(params.toolCall.toolCallId) ??
@@ -1576,31 +1439,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
async sessionUpdate(params: SessionNotification): Promise<void> { async sessionUpdate(params: SessionNotification): Promise<void> {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: params.sessionId,
rawEvent: params,
},
"provider.acp.raw_event",
);
if (params.sessionId !== this.sessionId) { if (params.sessionId !== this.sessionId) {
return; return;
} }
const events = this.translateSessionUpdate(params.update); const events = this.translateSessionUpdate(params.update);
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: this.activeForegroundTurnId ?? undefined,
rawEvent: params,
events,
},
"provider.acp.parsed_event",
);
if (this.replayingHistory) { if (this.replayingHistory) {
for (const event of events) { for (const event of events) {
if (event.type === "timeline") { if (event.type === "timeline") {
@@ -1779,7 +1622,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
? this.sessionResponseTransformer(response) ? this.sessionResponseTransformer(response)
: response; : response;
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []); this.configOptions = transformed.configOptions ?? [];
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions); const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes; this.availableModes = modeInfo.modes;
@@ -1792,16 +1635,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId; deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
} }
private transformConfigOptions(configOptions: SessionConfigOption[]): SessionConfigOption[] {
return this.configOptionsTransformer
? this.configOptionsTransformer(configOptions)
: configOptions;
}
private transformModeId(modeId: string): string | null {
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
}
private async applyConfiguredOverrides(): Promise<void> { private async applyConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId; const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) { if (configuredModeId && configuredModeId !== this.currentMode) {
@@ -1939,11 +1772,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
private handleCurrentModeUpdate(update: CurrentModeUpdate): void { private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
this.currentMode = this.transformModeId(update.currentModeId); this.currentMode = update.currentModeId;
} }
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] { private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
this.configOptions = this.transformConfigOptions(update.configOptions); this.configOptions = update.configOptions;
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions); const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const nextMode = modeInfo.currentModeId; const nextMode = modeInfo.currentModeId;
const nextModel = deriveCurrentConfigValue(this.configOptions, "model"); const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
@@ -2031,16 +1864,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
private pushEvent(event: AgentStreamEvent): void { private pushEvent(event: AgentStreamEvent): void {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined,
event,
},
"provider.acp.event_emit",
);
for (const subscriber of this.subscribers) { for (const subscriber of this.subscribers) {
subscriber(event); subscriber(event);
} }
@@ -14,11 +14,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import pino from "pino"; import pino from "pino";
import { import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
getAgentStreamEventTurnId,
type AgentSession,
type AgentStreamEvent,
} from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./agent.js";
@@ -41,14 +37,15 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
); );
} }
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
type EventWithTurnId = AgentStreamEvent & { turnId: string }; type EventWithTurnId = AgentStreamEvent & { turnId: string };
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId { function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
return getAgentStreamEventTurnId(event) !== undefined; return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
} }
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] { function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
return events.filter((e) => getAgentStreamEventTurnId(e) === turnId); return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
} }
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] { function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
@@ -160,7 +157,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// Invariant 2: Every turn_started has exactly one matching terminal // Invariant 2: Every turn_started has exactly one matching terminal
const turnStartedIds = events const turnStartedIds = events
.filter((e) => e.type === "turn_started" && hasTurnId(e)) .filter((e) => e.type === "turn_started" && hasTurnId(e))
.map((e) => e.turnId); .map((e) => (e as EventWithTurnId).turnId);
for (const turnId of turnStartedIds) { for (const turnId of turnStartedIds) {
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent); const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
@@ -280,7 +277,7 @@ test("Test 3: Lifecycle doesn't get stuck in running", async () => {
// Any turn_started after terminal must have a different turnId // Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect(ts.turnId).not.toBe(turnId); expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
} }
assertInvariants(events, [turnId]); assertInvariants(events, [turnId]);
@@ -316,9 +313,8 @@ test("Test 4: Autonomous run", async () => {
// Autonomous turn_started with a different turnId // Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter( const autoStarts = afterForeground.filter(
(e): e is EventWithTurnId => (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, ) as EventWithTurnId[];
);
if (autoStarts.length === 0) { if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]); assertInvariants(events, [fgTurnId]);
return; return;
@@ -42,35 +42,34 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../prov
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import { import type {
getAgentStreamEventTurnId, AgentPermissionAction,
type AgentPermissionAction, AgentCapabilityFlags,
type AgentCapabilityFlags, AgentClient,
type AgentClient, AgentCreateSessionOptions,
type AgentCreateSessionOptions, AgentLaunchContext,
type AgentLaunchContext, AgentMetadata,
type AgentMetadata, AgentMode,
type AgentMode, AgentModelDefinition,
type AgentModelDefinition, AgentPermissionRequest,
type AgentPermissionRequest, AgentPermissionRequestKind,
type AgentPermissionRequestKind, AgentPermissionResponse,
type AgentPermissionResponse, AgentPermissionUpdate,
type AgentPermissionUpdate, AgentPersistenceHandle,
type AgentPersistenceHandle, AgentPromptInput,
type AgentPromptInput, AgentRunOptions,
type AgentRunOptions, AgentRunResult,
type AgentRunResult, AgentSession,
type AgentSession, AgentSessionConfig,
type AgentSessionConfig, AgentSlashCommand,
type AgentSlashCommand, AgentStreamEvent,
type AgentStreamEvent, AgentTimelineItem,
type AgentTimelineItem, AgentUsage,
type AgentUsage, AgentRuntimeInfo,
type AgentRuntimeInfo, ListModelsOptions,
type ListModelsOptions, ListPersistedAgentsOptions,
type ListPersistedAgentsOptions, McpServerConfig,
type McpServerConfig, PersistedAgentDescriptor,
type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import { import {
createProviderEnv, createProviderEnv,
@@ -251,7 +250,6 @@ interface ClaudeAgentSessionOptions {
defaults?: { agents?: Record<string, AgentDefinition> }; defaults?: { agents?: Record<string, AgentDefinition> };
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle; handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>; launchEnv?: Record<string, string>;
persistSession?: boolean; persistSession?: boolean;
logger: Logger; logger: Logger;
@@ -1154,6 +1152,8 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
}; };
} }
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
export class ClaudeAgentClient implements AgentClient { export class ClaudeAgentClient implements AgentClient {
readonly provider = "claude" as const; readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES; readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1181,7 +1181,6 @@ export class ClaudeAgentClient implements AgentClient {
return new ClaudeAgentSession(claudeConfig, { return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults, defaults: this.defaults,
runtimeSettings: this.runtimeSettings, runtimeSettings: this.runtimeSettings,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
persistSession: options?.persistSession, persistSession: options?.persistSession,
logger: this.logger, logger: this.logger,
@@ -1210,7 +1209,6 @@ export class ClaudeAgentClient implements AgentClient {
defaults: this.defaults, defaults: this.defaults,
runtimeSettings: this.runtimeSettings, runtimeSettings: this.runtimeSettings,
handle, handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
logger: this.logger, logger: this.logger,
queryFactory: this.queryFactory, queryFactory: this.queryFactory,
@@ -1479,7 +1477,6 @@ class ClaudeAgentSession implements AgentSession {
private readonly config: ClaudeAgentConfig; private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>; private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string;
private readonly defaults?: { agents?: Record<string, AgentDefinition> }; private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean; private readonly persistSession?: boolean;
@@ -1529,11 +1526,10 @@ class ClaudeAgentSession implements AgentSession {
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) { constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config; this.config = config;
this.launchEnv = options.launchEnv; this.launchEnv = options.launchEnv;
this.agentId = options.agentId;
this.defaults = options.defaults; this.defaults = options.defaults;
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
this.persistSession = options.persistSession; this.persistSession = options.persistSession;
this.logger = options.logger.child({ agentId: this.agentId }); this.logger = options.logger;
this.queryFactory = options.queryFactory; this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary; this.resolveBinary = options.resolveBinary;
const handle = options.handle; const handle = options.handle;
@@ -1873,16 +1869,13 @@ class ClaudeAgentSession implements AgentSession {
async close(): Promise<void> { async close(): Promise<void> {
this.logger.trace( this.logger.trace(
{ {
agentId: this.agentId, claudeSessionId: this.claudeSessionId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
turnState: this.turnState, turnState: this.turnState,
hasQuery: Boolean(this.query), hasQuery: Boolean(this.query),
hasInput: Boolean(this.input), hasInput: Boolean(this.input),
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId), hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
}, },
"provider.claude.session_close.start", "Claude session close: start",
); );
this.closed = true; this.closed = true;
this.rejectAllPendingPermissions(new Error("Claude session closed")); this.rejectAllPendingPermissions(new Error("Claude session closed"));
@@ -1917,13 +1910,8 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
this.logger.trace( this.logger.trace(
{ { claudeSessionId: this.claudeSessionId, turnState: this.turnState },
agentId: this.agentId, "Claude session close: completed",
provider: "claude",
sessionId: this.claudeSessionId,
turnState: this.turnState,
},
"provider.claude.session_close.complete",
); );
} }
@@ -2200,41 +2188,16 @@ class ClaudeAgentSession implements AgentSession {
label: string, label: string,
): Promise<void> { ): Promise<void> {
if (!promise) { if (!promise) {
this.logger.trace( this.logger.trace({ label }, "Claude query operation skipped (no promise)");
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.skip",
);
return; return;
} }
const startedAt = Date.now(); const startedAt = Date.now();
this.logger.trace( this.logger.trace({ label }, "Claude query operation wait start");
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.start",
);
try { try {
await withTimeout(promise, 3_000, "timeout"); await withTimeout(promise, 3_000, "timeout");
this.logger.trace( this.logger.trace(
{ { label, durationMs: Date.now() - startedAt },
agentId: this.agentId, "Claude query operation settled",
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
durationMs: Date.now() - startedAt,
},
"provider.claude.query_operation.settled",
); );
} catch (error) { } catch (error) {
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly"); this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
@@ -2630,16 +2593,7 @@ class ClaudeAgentSession implements AgentSession {
} }
const pump = this.runQueryPump().catch((error) => { const pump = this.runQueryPump().catch((error) => {
this.logger.trace( this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.exit_unexpected",
);
}); });
this.queryPumpPromise = pump; this.queryPumpPromise = pump;
@@ -2655,34 +2609,24 @@ class ClaudeAgentSession implements AgentSession {
try { try {
activeQuery = await this.ensureQuery(); activeQuery = await this.ensureQuery();
} catch (error) { } catch (error) {
this.logger.trace( this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed"); this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return; return;
} }
let consecutiveInterruptAbortRecoveries = 0; let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => { const logRawMessage = (message: SDKMessage): void => {
if (!claudeDebug) {
return;
}
this.logger.trace( this.logger.trace(
{ {
agentId: this.agentId, claudeSessionId: this.claudeSessionId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
messageType: message.type, messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : undefined, messageSubtype: "subtype" in message ? message.subtype : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined, messageUuid: "uuid" in message ? message.uuid : undefined,
rawEvent: message,
}, },
"provider.claude.raw_event", "Claude query pump: raw SDK message",
); );
}; };
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => { const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
@@ -2795,18 +2739,16 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null; const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message); const identifiers = readEventIdentifiers(message);
this.logger.trace( if (claudeDebug) {
{ this.logger.trace(
agentId: this.agentId, {
provider: "claude", claudeSessionId: this.claudeSessionId,
sessionId: this.claudeSessionId, messageType: message.type,
turnId: turnId ?? undefined, turnId,
messageType: message.type, },
identifiers, "Claude query pump: SDK message",
rawEvent: message, );
}, }
"provider.claude.parsed_event",
);
const messageEvents = this.translateMessageToEvents(message, { const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true, suppressAssistantText: true,
@@ -2874,6 +2816,7 @@ class ClaudeAgentSession implements AgentSession {
this.logger.warn( this.logger.warn(
{ {
claudeSessionId: this.claudeSessionId,
error: staleResumeError, error: staleResumeError,
}, },
"Claude resumed session no longer exists; invalidating persisted session", "Claude resumed session no longer exists; invalidating persisted session",
@@ -2903,15 +2846,7 @@ class ClaudeAgentSession implements AgentSession {
private async interruptActiveTurn(): Promise<void> { private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query; const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") { if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.trace( this.logger.trace("interruptActiveTurn: no query to interrupt");
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
},
"provider.claude.interrupt.no_query",
);
return; return;
} }
this.pendingInterruptAbort = true; this.pendingInterruptAbort = true;
@@ -3520,16 +3455,6 @@ class ClaudeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void { private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id; const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event; const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.claude.event_emit",
);
for (const callback of this.subscribers) { for (const callback of this.subscribers) {
try { try {
callback(tagged); callback(tagged);
@@ -1,11 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { __codexAppServerInternals } from "./codex-app-server-agent.js"; import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex"; const CODEX_PROVIDER = "codex";
@@ -31,7 +27,39 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
}, },
]; ];
type CodexFeaturesTestSession = AgentSession; interface CodexRequestFn {
(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
interface CodexClientLike {
request: CodexRequestFn;
}
interface CodexSessionTestAccess {
client: CodexClientLike | null;
connected: boolean;
currentThreadId: string | null;
serviceTier: "fast" | null;
planModeEnabled: boolean;
cachedRuntimeInfo: unknown;
ensureThreadLoaded: () => Promise<void>;
ensureThread: () => Promise<void>;
buildUserInput: (...args: unknown[]) => Promise<unknown>;
resolveSlashCommandInvocation: (...args: unknown[]) => Promise<unknown>;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
}
type CodexFeaturesTestSession = AgentSession & {
connected: boolean;
currentThreadId: string | null;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
};
function asInternals(session: CodexFeaturesTestSession): CodexSessionTestAccess {
return session as unknown as CodexSessionTestAccess;
}
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig { function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return { return {
@@ -43,36 +71,28 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
}; };
} }
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): { function createSession(
session: CodexFeaturesTestSession; configOverrides: Partial<AgentSessionConfig> = {},
appServer: FakeCodexAppServer; ): CodexFeaturesTestSession {
} {
const config = createConfig(configOverrides); const config = createConfig(configOverrides);
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession( const session = new __codexAppServerInternals.CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER }, { ...config, provider: CODEX_PROVIDER },
null, null,
createTestLogger(), createTestLogger(),
async () => appServer.child, () => {
) as CodexFeaturesTestSession; throw new Error("Test session cannot spawn Codex app-server");
return { session, appServer }; },
} ) as unknown as CodexFeaturesTestSession;
session.connected = true;
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{ session.currentThreadId = "test-thread";
session: CodexFeaturesTestSession; session.collaborationModes = TEST_COLLABORATION_MODES;
appServer: FakeCodexAppServer; session.refreshResolvedCollaborationMode();
}> { return session;
const harness = createSessionHarness(configOverrides);
await harness.session.connect();
harness.appServer.assertNoErrors();
return harness;
} }
describe("Codex app-server provider features", () => { describe("Codex app-server provider features", () => {
test("features returns fast and plan toggles when supported", async () => { test("features returns fast and plan toggles when supported", async () => {
const { session } = await createConnectedSession(); const session = createSession();
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
@@ -120,8 +140,8 @@ describe("Codex app-server provider features", () => {
]); ]);
}); });
test("features returns only plan toggle when model does not support fast mode", async () => { test("features returns only plan toggle when model does not support fast mode", () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" }); const session = createSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
@@ -137,56 +157,49 @@ describe("Codex app-server provider features", () => {
}); });
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ expect(asInternals(session).serviceTier).toBe("fast");
serviceTier: "fast",
});
}); });
test("setFeature('fast_mode', false) clears serviceTier to null", async () => { test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
const { session, appServer } = await createConnectedSession({ const session = createSession({
featureValues: { fast_mode: true }, featureValues: { fast_mode: true },
}); });
await session.setFeature?.("fast_mode", false); await session.setFeature?.("fast_mode", false);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ expect(asInternals(session).serviceTier).toBeNull();
serviceTier: expect.anything(),
});
}); });
test("setFeature invalidates runtime info", async () => { test("setFeature invalidates cachedRuntimeInfo", async () => {
const { session } = await createConnectedSession(); const session = createSession();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({ await session.getRuntimeInfo();
extra: { collaborationMode: "Plan" }, expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
});
await session.setFeature?.("plan_mode", true); await session.setFeature?.("fast_mode", true);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ expect(asInternals(session).cachedRuntimeInfo).toBeNull();
extra: { collaborationMode: "Plan" },
});
}); });
test("setFeature throws for unknown feature ids", async () => { test("setFeature throws for unknown feature ids", async () => {
const { session } = createSessionHarness(); const session = createSession();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow( await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature", "Unknown Codex feature: unknown_feature",
); );
}); });
test("constructor restores feature flags from config.featureValues", async () => { test("constructor restores feature flags from config.featureValues", () => {
const { session, appServer } = await createConnectedSession({ const session = createSession({
featureValues: { fast_mode: true, plan_mode: true }, featureValues: { fast_mode: true, plan_mode: true },
}); });
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(true);
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
type: "toggle", type: "toggle",
@@ -207,29 +220,41 @@ describe("Codex app-server provider features", () => {
value: true, value: true,
}, },
]); ]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
collaborationMode: expect.objectContaining({
mode: "plan",
}),
});
}); });
test("startTurn includes serviceTier when fast mode is enabled", async () => { test("startTurn includes serviceTier when fast mode is enabled", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ expect(request).toHaveBeenCalledWith(
serviceTier: "fast", "turn/start",
}); expect.objectContaining({
serviceTier: "fast",
}),
expect.any(Number),
);
}); });
test("setModel clears fast mode when switching to an unsupported model", async () => { test("setModel clears fast mode when switching to an unsupported model", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo"); await session.setModel("gpt-3.5-turbo");
@@ -245,23 +270,41 @@ describe("Codex app-server provider features", () => {
value: false, value: false,
}, },
]); ]);
expect(asInternals(session).serviceTier).toBeNull();
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ expect(request).toHaveBeenCalledWith(
serviceTier: expect.anything(), "turn/start",
}); expect.not.objectContaining({
serviceTier: expect.anything(),
}),
expect.any(Number),
);
}); });
test("startTurn switches collaboration mode when plan mode is enabled", async () => { test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const { session, appServer } = await createConnectedSession(); const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("plan_mode", true); await session.setFeature?.("plan_mode", true);
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ expect(request).toHaveBeenCalledWith(
collaborationMode: expect.objectContaining({ "turn/start",
mode: "plan", expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
}), }),
}); expect.any(Number),
);
}); });
}); });
@@ -1,11 +1,9 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { PassThrough } from "node:stream";
import type { import type {
AgentLaunchContext, AgentLaunchContext,
@@ -19,12 +17,11 @@ import {
codexAppServerTurnInputFromPrompt, codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js"; } from "./codex-app-server-agent.js";
import { import {
createFakeCodexAppServer, createCodexAppServerChildProcessStub,
waitForNextPermission, TestCodexAppServerPeer,
} from "./codex/test-utils/fake-app-server.js"; } from "./codex/test-utils/test-app-server-peer.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js"; import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord { interface CollaborationModeRecord {
name: string; name: string;
@@ -35,7 +32,6 @@ interface CollaborationModeRecord {
} }
interface CodexSessionTestAccess { interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>; handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void; handleNotification(method: string, params: unknown): void;
loadPersistedHistory(): Promise<void>; loadPersistedHistory(): Promise<void>;
@@ -104,98 +100,32 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")"); return match[1].replace(/\\\)/g, ")");
} }
type CapturedFakeCodexRecord = Record<string, unknown>; function waitForNextPermission(
session: AgentSession,
async function runCustomCodexProviderTurn( events: AgentStreamEvent[],
providerId: string, ): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
baseUrl: string, const existing = events.find(
): Promise<CapturedFakeCodexRecord[]> { (event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-")); event.type === "permission_requested",
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
writeFileSync(
fakeAppServerPath,
`
const fs = require("node:fs");
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
let buffer = "";
fs.appendFileSync(capturePath, JSON.stringify({
kind: "env",
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
}) + "\\n");
function record(method, params) {
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n");
}
function resultFor(method) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "skills/list") return { data: [] };
if (method === "config/read") return { config: {} };
if (method === "getUserSavedConfig") return { config: {} };
if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] };
if (method === "thread/start") return { thread: { id: "thread-1" } };
if (method === "turn/start") return {};
return {};
}
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
const message = JSON.parse(line);
record(message.method, message.params);
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
}
});
`,
); );
if (existing) {
const registry = buildProviderRegistry(createTestLogger(), { return Promise.resolve(existing);
providerOverrides: {
[providerId]: {
extends: "codex",
label: "Custom Codex",
command: [process.execPath, fakeAppServerPath],
env: {
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: baseUrl,
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
},
},
},
});
const session = await registry[providerId].createClient(createTestLogger()).createSession({
provider: providerId,
cwd: "/workspace/project",
modeId: "auto",
model: "custom-model",
});
try {
await session.startTurn("use the custom endpoint");
return readFileSync(capturedRequestsPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord);
} finally {
await session.close();
rmSync(tempDir, { recursive: true, force: true });
} }
}
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown { return new Promise((resolve, reject) => {
const threadStart = records.find((record) => record.method === "thread/start"); const timeout = setTimeout(() => {
const params = threadStart?.params as Record<string, unknown> | undefined; unsubscribe();
return params?.config; reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
} }
describe("Codex app-server provider", () => { describe("Codex app-server provider", () => {
@@ -261,21 +191,15 @@ describe("Codex app-server provider", () => {
test("disposes an unresponsive app-server child with SIGKILL", async () => { test("disposes an unresponsive app-server child with SIGKILL", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const child = new EventEmitter() as ChildProcessWithoutNullStreams; const child = createCodexAppServerChildProcessStub({ exitOnKill: false });
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger()); const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
try { try {
const disposePromise = client.dispose(); const disposePromise = client.dispose();
expect(child.kill).toHaveBeenCalledWith("SIGTERM"); expect(child.killSignals).toEqual(["SIGTERM"]);
await vi.advanceTimersByTimeAsync(2_000); await vi.advanceTimersByTimeAsync(2_000);
expect(child.kill).toHaveBeenCalledWith("SIGKILL"); expect(child.killSignals).toEqual(["SIGTERM", "SIGKILL"]);
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
await expect(disposePromise).resolves.toBeUndefined(); await expect(disposePromise).resolves.toBeUndefined();
@@ -285,7 +209,7 @@ describe("Codex app-server provider", () => {
}); });
test("round-trips server-initiated command approvals through the real app-server transport", async () => { test("round-trips server-initiated command approvals through the real app-server transport", async () => {
const appServer = createFakeCodexAppServer({ const peer = new TestCodexAppServerPeer({
initialize: () => ({}), initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }), "collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }), "skills/list": () => ({ data: [] }),
@@ -294,21 +218,28 @@ describe("Codex app-server provider", () => {
createConfig({ cwd: "/workspace/project" }), createConfig({ cwd: "/workspace/project" }),
null, null,
createTestLogger(), createTestLogger(),
async () => appServer.child, async () => peer.child,
); );
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect(); await session.connect();
appServer.assertNoErrors(); peer.assertNoErrors();
const permissionRequested = waitForNextPermission(session); const permissionRequested = waitForNextPermission(session, events);
appServer.requestCommandApproval({
itemId: "exec-approval-1", peer.writeRequest(
threadId: "thread-1", "item/commandExecution/requestApproval",
turnId: "turn-1", {
command: "git restore README.md", itemId: "exec-approval-1",
cwd: "/workspace/project", threadId: "thread-1",
reason: "requires escalated permissions", turnId: "turn-1",
}); command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
},
41,
);
const permissionEvent = await permissionRequested; const permissionEvent = await permissionRequested;
expect(permissionEvent.request).toMatchObject({ expect(permissionEvent.request).toMatchObject({
@@ -331,128 +262,14 @@ describe("Codex app-server provider", () => {
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" }); await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({ await expect(peer.waitForResponse(41, { decision: "accept" })).resolves.toMatchObject({
decision: "accept", id: 41,
result: { decision: "accept" },
}); });
appServer.assertNoErrors(); peer.assertNoErrors();
await session.close(); await session.close();
}); });
test("configures Codex app-server to use a custom provider base URL", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-iisb",
"https://custom-relay.example.com",
);
expect(capturedRequests[0]).toEqual({
kind: "env",
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: "https://custom-relay.example.com",
});
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-iisb",
model_providers: {
"codex-iisb": {
name: "Custom Codex",
base_url: "https://custom-relay.example.com/v1",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
wire_api: "responses",
},
},
});
});
test("does not append v1 twice for custom Codex provider base URLs", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-custom",
"https://custom-relay.example.com/v1/",
);
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-custom",
model_providers: {
"codex-custom": expect.objectContaining({
base_url: "https://custom-relay.example.com/v1",
}),
},
});
});
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
},
"thread/start": () => {
threadRequests.push("thread/start");
return { thread: { id: "replacement-empty-thread-id" } };
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
getUserSavedConfig: () => {
threadRequests.push("getUserSavedConfig");
return { config: {} };
},
"config/read": () => {
threadRequests.push("config/read");
return { config: {} };
},
"model/list": () => {
threadRequests.push("model/list");
return {
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
};
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
Promise.resolve(false);
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const outcome = await Promise.race([
provider
.resumeSession({
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
})
.then(
() => "resolved" as const,
(error) => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"no rollout found for thread id archived-thread-id",
);
return "rejected" as const;
},
),
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
]);
if (outcome === "timed_out") {
appServer.child.kill("SIGTERM");
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume"]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => { test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-")); const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
const cwd = path.join(tempDir, "repo", "packages", "app"); const cwd = path.join(tempDir, "repo", "packages", "app");
@@ -463,8 +280,12 @@ describe("Codex app-server provider", () => {
path.join(repoSkillDir, "SKILL.md"), path.join(repoSkillDir, "SKILL.md"),
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n", "---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
); );
const resolvedRepoRoots: string[] = [];
const workspaceGitService = { const workspaceGitService = {
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")), resolveRepoRoot: async (pathToResolve: string) => {
resolvedRepoRoots.push(pathToResolve);
return path.join(tempDir, "repo");
},
}; };
try { try {
@@ -475,7 +296,7 @@ describe("Codex app-server provider", () => {
description: "Ship changes carefully.", description: "Ship changes carefully.",
argumentHint: "", argumentHint: "",
}); });
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd); expect(resolvedRepoRoots).toEqual([cwd]);
} finally { } finally {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
} }
@@ -1114,52 +935,6 @@ describe("Codex app-server provider", () => {
}); });
}); });
test("keeps the parent sub-agent running when a child command fails during the child turn", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-child-command-failure",
tool: "spawnAgent",
status: "completed",
prompt: "Fix the regression test-first.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "running", message: null },
},
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
item: {
type: "commandExecution",
id: "child-failing-command",
status: "failed",
command: "npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts",
aggregatedOutput: "expected false to be true",
exitCode: 1,
error: { message: "Command failed" },
},
});
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Fix the regression test-first.",
},
});
});
test("loads Codex persisted history from the app-server thread", async () => { test("loads Codex persisted history from the app-server thread", async () => {
const session = createSession(); const session = createSession();
const requests: Array<{ method: string; params: unknown }> = []; const requests: Array<{ method: string; params: unknown }> = [];
@@ -1209,37 +984,6 @@ describe("Codex app-server provider", () => {
]); ]);
}); });
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
const session = createSession({ thinkingOptionId: "medium" });
session.currentThreadId = "archived-thread-id";
const requests: Array<{ method: string; params: unknown }> = [];
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: [] };
}
if (method === "thread/resume") {
throw new Error("no rollout found for thread id archived-thread-id");
}
if (method === "thread/start") {
return { thread: { id: "replacement-empty-thread-id" } };
}
return {};
}),
};
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
"no rollout found for thread id archived-thread-id",
);
expect(session.currentThreadId).toBe("archived-thread-id");
expect(requests).toEqual([
{ method: "thread/loaded/list", params: {} },
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
]);
});
test("appends blank-line spacing to /goal status messages", async () => { test("appends blank-line spacing to /goal status messages", async () => {
const requests: Array<{ method: string; params: unknown }> = []; const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession({}, { goalsEnabled: true }); const session = createSession({}, { goalsEnabled: true });
@@ -2046,14 +1790,7 @@ describe("Codex persisted sessions", () => {
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>( castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider, provider,
).spawnAppServer = async () => { ).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams; return createCodexAppServerChildProcessStub();
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
}; };
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" }); const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });
@@ -1,33 +1,32 @@
import { import type {
getAgentStreamEventTurnId, AgentPermissionAction,
type AgentPermissionAction, AgentCapabilityFlags,
type AgentCapabilityFlags, AgentClient,
type AgentClient, AgentCreateSessionOptions,
type AgentCreateSessionOptions, AgentFeature,
type AgentFeature, AgentLaunchContext,
type AgentLaunchContext, AgentMode,
type AgentMode, AgentModelDefinition,
type AgentModelDefinition, McpServerConfig,
type McpServerConfig, AgentPersistenceHandle,
type AgentPersistenceHandle, AgentPermissionRequest,
type AgentPermissionRequest, AgentPermissionResponse,
type AgentPermissionResponse, AgentPermissionResult,
type AgentPermissionResult, AgentPromptContentBlock,
type AgentPromptContentBlock, AgentPromptInput,
type AgentPromptInput, AgentRunOptions,
type AgentRunOptions, AgentRunResult,
type AgentRunResult, AgentRuntimeInfo,
type AgentRuntimeInfo, AgentSession,
type AgentSession, AgentSessionConfig,
type AgentSessionConfig, AgentSlashCommand,
type AgentSlashCommand, AgentStreamEvent,
type AgentStreamEvent, AgentTimelineItem,
type AgentTimelineItem, ToolCallTimelineItem,
type ToolCallTimelineItem, AgentUsage,
type AgentUsage, ListModelsOptions,
type ListModelsOptions, ListPersistedAgentsOptions,
type ListPersistedAgentsOptions, PersistedAgentDescriptor,
type PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -56,10 +55,7 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
import { spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import { import { CodexAppServerClient } from "./codex/app-server-transport.js";
CodexAppServerClient,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { import {
renderProviderImageOutputAsAssistantMarkdown, renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput, type ProviderImageOutput,
@@ -178,16 +174,9 @@ interface CodexAppServerClientLike {
interface CodexAppServerAgentDeps { interface CodexAppServerAgentDeps {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">; workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
id: string;
label: string;
extends: string;
};
customCodexConfig?: Record<string, unknown> | null;
_createCodexClient?: ( _createCodexClient?: (
child: ChildProcessWithoutNullStreams, child: ChildProcessWithoutNullStreams,
logger: Logger, logger: Logger,
getTraceContext: () => CodexAppServerTraceContext,
) => CodexAppServerClientLike; ) => CodexAppServerClientLike;
} }
@@ -2536,50 +2525,6 @@ function buildCodexAppServerInitializeParams(): {
}; };
} }
function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const withoutTrailingSlashes = trimmed.replace(/\/+$/u, "");
if (withoutTrailingSlashes.endsWith("/v1")) {
return withoutTrailingSlashes;
}
return `${withoutTrailingSlashes}/v1`;
}
function buildCodexCustomProviderConfig(
runtimeSettings: ProviderRuntimeSettings | undefined,
customProvider: CodexAppServerAgentDeps["customProvider"],
): Record<string, unknown> | null {
if (customProvider?.extends !== CODEX_PROVIDER) {
return null;
}
const baseUrl = runtimeSettings?.env?.OPENAI_BASE_URL;
if (typeof baseUrl !== "string") {
return null;
}
const normalizedBaseUrl = normalizeOpenAICompatibleBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return null;
}
const providerConfig: Record<string, unknown> = {
name: customProvider.label,
base_url: normalizedBaseUrl,
wire_api: "responses",
};
if (runtimeSettings?.env?.OPENAI_API_KEY?.trim()) {
providerConfig.env_key = "OPENAI_API_KEY";
providerConfig.requires_openai_auth = false;
}
return {
model_provider: customProvider.id,
model_providers: {
[customProvider.id]: providerConfig,
},
};
}
interface CodexSubAgentCallState { interface CodexSubAgentCallState {
callId: string; callId: string;
toolCall: ToolCallTimelineItem; toolCall: ToolCallTimelineItem;
@@ -2658,13 +2603,8 @@ class CodexAppServerAgentSession implements AgentSession {
private readonly deps: CodexAppServerAgentDeps = {}, private readonly deps: CodexAppServerAgentDeps = {},
private readonly ephemeral: boolean = false, private readonly ephemeral: boolean = false,
private readonly goalsEnabled: boolean = false, private readonly goalsEnabled: boolean = false,
private readonly agentId?: string,
) { ) {
this.logger = logger.child({ this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
module: "agent",
provider: CODEX_PROVIDER,
agentId: this.agentId,
});
if (config.modeId === undefined) { if (config.modeId === undefined) {
throw new Error("Codex agent requires modeId to be specified"); throw new Error("Codex agent requires modeId to be specified");
} }
@@ -2701,7 +2641,7 @@ class CodexAppServerAgentSession implements AgentSession {
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.connected) return; if (this.connected) return;
const child = await this.spawnAppServer(); const child = await this.spawnAppServer();
this.client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); this.client = new CodexAppServerClient(child, this.logger);
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers(); this.registerRequestHandlers();
@@ -2719,14 +2659,6 @@ class CodexAppServerAgentSession implements AgentSession {
this.connected = true; this.connected = true;
} }
private traceContext(): CodexAppServerTraceContext {
return {
agentId: this.agentId,
sessionId: this.currentThreadId ?? undefined,
turnId: this.activeForegroundTurnId ?? undefined,
};
}
private async loadCollaborationModes(): Promise<void> { private async loadCollaborationModes(): Promise<void> {
if (!this.client) return; if (!this.client) return;
try { try {
@@ -2747,16 +2679,7 @@ class CodexAppServerAgentSession implements AgentSession {
}; };
}); });
} catch (error) { } catch (error) {
this.logger.trace( this.logger.trace({ error }, "Failed to load collaboration modes");
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.collaboration_modes_failed",
);
this.collaborationModes = []; this.collaborationModes = [];
} }
this.refreshResolvedCollaborationMode(); this.refreshResolvedCollaborationMode();
@@ -2788,16 +2711,7 @@ class CodexAppServerAgentSession implements AgentSession {
} }
this.cachedSkills = skills; this.cachedSkills = skills;
} catch (error) { } catch (error) {
this.logger.trace( this.logger.trace({ error }, "Failed to load skills list");
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.skills_failed",
);
this.cachedSkills = []; this.cachedSkills = [];
} }
} }
@@ -2987,10 +2901,9 @@ class CodexAppServerAgentSession implements AgentSession {
} }
await this.client.request("thread/resume", params); await this.client.request("thread/resume", params);
} catch (error) { } catch (error) {
const threadId = this.currentThreadId; this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
const message = error instanceof Error ? error.message : String(error); this.currentThreadId = null;
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread"); await this.ensureThread();
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
} }
} }
@@ -3674,9 +3587,6 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.extra?.codex) { if (this.config.extra?.codex) {
Object.assign(innerConfig, this.config.extra.codex); Object.assign(innerConfig, this.config.extra.codex);
} }
if (this.deps.customCodexConfig) {
Object.assign(innerConfig, this.deps.customCodexConfig);
}
return Object.keys(innerConfig).length > 0 ? innerConfig : null; return Object.keys(innerConfig).length > 0 ? innerConfig : null;
} }
@@ -3694,16 +3604,6 @@ class CodexAppServerAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void { private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId; const turnId = this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event; const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.codex.event_emit",
);
for (const callback of this.subscribers) { for (const callback of this.subscribers) {
try { try {
callback(tagged); callback(tagged);
@@ -3719,7 +3619,6 @@ class CodexAppServerAgentSession implements AgentSession {
private handleNotification(method: string, params: unknown): void { private handleNotification(method: string, params: unknown): void {
const parsed = CodexNotificationSchema.parse({ method, params }); const parsed = CodexNotificationSchema.parse({ method, params });
this.traceParsedNotification(method, params, parsed);
switch (parsed.kind) { switch (parsed.kind) {
case "thread_started": case "thread_started":
this.handleThreadStartedNotification(parsed); this.handleThreadStartedNotification(parsed);
@@ -3776,25 +3675,6 @@ class CodexAppServerAgentSession implements AgentSession {
} }
} }
private traceParsedNotification(
method: string,
params: unknown,
parsed: z.infer<typeof CodexNotificationSchema>,
): void {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
parsed,
},
"provider.codex.parsed_event",
);
}
private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null { private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null {
if (!threadId || threadId === this.currentThreadId) { if (!threadId || threadId === this.currentThreadId) {
return null; return null;
@@ -3906,7 +3786,10 @@ class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId);
} }
this.emitSubAgentActivityUpdate(callId, "running"); this.emitSubAgentActivityUpdate(
callId,
timelineItem.type === "tool_call" && timelineItem.status === "failed" ? "failed" : "running",
);
} }
private shouldSkipCompletedThreadItem( private shouldSkipCompletedThreadItem(
@@ -4387,17 +4270,7 @@ class CodexAppServerAgentSession implements AgentSession {
return; return;
} }
this.warnedUnknownNotificationMethods.add(method); this.warnedUnknownNotificationMethods.add(method);
this.logger.trace( this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
},
"provider.codex.event_unhandled",
);
} }
private warnInvalidNotificationPayload(method: string, params: unknown): void { private warnInvalidNotificationPayload(method: string, params: unknown): void {
@@ -4658,16 +4531,6 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {}, private readonly deps: CodexAppServerAgentDeps = {},
) {} ) {}
private sessionDeps(): CodexAppServerAgentDeps {
return {
...this.deps,
customCodexConfig: buildCodexCustomProviderConfig(
this.runtimeSettings,
this.deps.customProvider,
),
};
}
private resolveGoalsEnabled(): Promise<boolean> { private resolveGoalsEnabled(): Promise<boolean> {
if (!this.goalsEnabledPromise) { if (!this.goalsEnabledPromise) {
this.goalsEnabledPromise = (async () => { this.goalsEnabledPromise = (async () => {
@@ -4675,14 +4538,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command); const versionOutput = await resolveBinaryVersion(launchPrefix.command);
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION); const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
this.logger.trace( this.logger.trace({ versionOutput, enabled }, "Resolved codex goals feature gate");
{
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.goals_resolved",
);
return enabled; return enabled;
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate"); this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
@@ -4695,7 +4551,7 @@ export class CodexAppServerAgentClient implements AgentClient {
private async spawnAppServer( private async spawnAppServer(
launchEnv?: Record<string, string>, launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string }, options?: { goalsEnabled?: boolean },
): Promise<ChildProcessWithoutNullStreams> { ): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const args = [...launchPrefix.args, "app-server"]; const args = [...launchPrefix.args, "app-server"];
@@ -4704,12 +4560,10 @@ export class CodexAppServerAgentClient implements AgentClient {
} }
this.logger.trace( this.logger.trace(
{ {
agentId: options?.agentId,
provider: CODEX_PROVIDER,
launchPrefix, launchPrefix,
goalsEnabled: options?.goalsEnabled === true, goalsEnabled: options?.goalsEnabled === true,
}, },
"provider.codex.spawn", "Spawning Codex app server",
); );
const child = spawnProcess(launchPrefix.command, args, { const child = spawnProcess(launchPrefix.command, args, {
detached: process.platform !== "win32", detached: process.platform !== "win32",
@@ -4741,12 +4595,10 @@ export class CodexAppServerAgentClient implements AgentClient {
sessionConfig, sessionConfig,
null, null,
this.logger, this.logger,
() => () => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), this.deps,
this.sessionDeps(),
options?.persistSession === false, options?.persistSession === false,
goalsEnabled, goalsEnabled,
launchContext?.agentId,
); );
await session.connect(); await session.connect();
return session; return session;
@@ -4769,12 +4621,10 @@ export class CodexAppServerAgentClient implements AgentClient {
merged, merged,
handle, handle,
this.logger, this.logger,
() => () => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), this.deps,
this.sessionDeps(),
false, false,
goalsEnabled, goalsEnabled,
launchContext?.agentId,
); );
await session.connect(); await session.connect();
return session; return session;
@@ -4785,7 +4635,7 @@ export class CodexAppServerAgentClient implements AgentClient {
): Promise<PersistedAgentDescriptor[]> { ): Promise<PersistedAgentDescriptor[]> {
const child = await this.spawnAppServer(); const child = await this.spawnAppServer();
const client = const client =
this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? this.deps._createCodexClient?.(child, this.logger) ??
new CodexAppServerClient(child, this.logger); new CodexAppServerClient(child, this.logger);
try { try {
@@ -1,22 +1,20 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js"; import { CodexAppServerClient } from "./app-server-transport.js";
import { TestCodexAppServerPeer } from "./test-utils/test-app-server-peer.js";
describe("Codex app-server transport", () => { describe("Codex app-server transport", () => {
test("ignores non-JSON stdout lines without dropping pending requests", async () => { test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createCodexAppServerChildProcess(); const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(child, createTestLogger()); const client = new CodexAppServerClient(peer.child, createTestLogger());
const request = client.request("model/list", {}); const request = client.request("model/list", {});
child.stdout.write("Codex ha iniciado en modo localizado\n"); peer.writeNonJsonStdout("Codex ha iniciado en modo localizado");
child.stdout.write('{"id":1,"result":{"data":[]}}\n'); peer.writeResponse(1, { data: [] });
await expect(request).resolves.toEqual({ data: [] }); await expect(request).resolves.toEqual({ data: [] });
child.stdout.end(); peer.close();
child.stderr.end();
child.stdin.end();
}); });
test.each([ test.each([
@@ -25,23 +23,19 @@ describe("Codex app-server transport", () => {
"item/tool/requestUserInput", "item/tool/requestUserInput",
"tool/requestUserInput", "tool/requestUserInput",
])("answers server-initiated %s requests through registered handlers", async (method) => { ])("answers server-initiated %s requests through registered handlers", async (method) => {
const child = createCodexAppServerChildProcess(); const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(child, createTestLogger()); const client = new CodexAppServerClient(peer.child, createTestLogger());
const handlerCalls: unknown[] = []; const handlerCalls: unknown[] = [];
client.setRequestHandler(method, async (params) => { client.setRequestHandler(method, async (params) => {
handlerCalls.push(params); handlerCalls.push(params);
return { ok: true }; return { ok: true };
}); });
const response = new Promise<string>((resolve) => { const response = peer.nextPaseoOutputLine();
child.stdin.once("data", (chunk) => resolve(chunk.toString())); peer.writeRequest(method);
});
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n'); await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
expect(handlerCalls).toEqual([{}]); expect(handlerCalls).toEqual([{}]);
child.stdout.end(); peer.close();
child.stderr.end();
child.stdin.end();
}); });
}); });
@@ -35,12 +35,6 @@ interface PendingRequest {
type RequestHandler = (params: unknown) => unknown; type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void; type NotificationHandler = (method: string, params: unknown) => void;
export interface CodexAppServerTraceContext {
agentId?: string;
sessionId?: string;
turnId?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value); return value != null && typeof value === "object" && !Array.isArray(value);
} }
@@ -60,24 +54,6 @@ function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
return typeof msg.method === "string" && msg.id === undefined; return typeof msg.method === "string" && msg.id === undefined;
} }
function readProviderSessionId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
return typeof params.threadId === "string" ? params.threadId : undefined;
}
function readProviderTurnId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
if (typeof params.turnId === "string") {
return params.turnId;
}
const turn = params.turn;
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
export class CodexAppServerClient { export class CodexAppServerClient {
private readonly rl: readline.Interface; private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>(); private readonly pending = new Map<number, PendingRequest>();
@@ -90,7 +66,6 @@ export class CodexAppServerClient {
constructor( constructor(
private readonly child: ChildProcessWithoutNullStreams, private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger, private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) { ) {
this.rl = readline.createInterface({ input: child.stdout }); this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => { this.rl.on("line", (line) => {
@@ -249,19 +224,6 @@ export class CodexAppServerClient {
} }
if (isJsonRpcNotification(raw)) { if (isJsonRpcNotification(raw)) {
const traceContext = this.getTraceContext();
this.logger.trace(
{
provider: "codex",
agentId: traceContext.agentId,
sessionId: traceContext.sessionId ?? readProviderSessionId(raw.params),
turnId: traceContext.turnId ?? readProviderTurnId(raw.params),
method: raw.method,
params: raw.params,
rawEvent: raw,
},
"provider.codex.raw_event",
);
this.notificationHandler?.(raw.method, raw.params); this.notificationHandler?.(raw.method, raw.params);
} }
} }
@@ -1,228 +0,0 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
};
export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess;
assertNoErrors(): void;
waitForTurnStart(): Promise<JsonObject>;
requestCommandApproval(params: {
itemId: string;
threadId: string;
turnId: string;
command: string;
cwd: string;
reason: string;
}): void;
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
}
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export function createFakeCodexAppServer(
handlers: Record<string, FakeCodexAppServerHandler> = {},
): FakeCodexAppServer {
const child = createCodexAppServerChildProcess();
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"config/read": () => ({ config: {} }),
getUserSavedConfig: () => ({ config: {} }),
"model/list": () => ({
data: [
{
id: "gpt-5.4",
isDefault: true,
defaultReasoningEffort: "medium",
},
],
}),
"skills/list": () => ({ data: [] }),
"thread/start": () => ({ thread: { id: "thread-1" } }),
"thread/loaded/list": () => ({ data: [] }),
"thread/resume": () => ({}),
"turn/start": () => ({}),
...handlers,
};
const messages: JsonObject[] = [];
const errors: Error[] = [];
const approvalRequestIds = new Map<string, number>();
const waiters = new Set<{
predicate: (message: JsonObject) => boolean;
resolve: (message: JsonObject) => void;
}>();
let buffer = "";
let nextServerRequestId = 1;
function processMessage(message: JsonObject): void {
messages.push(message);
for (const waiter of Array.from(waiters)) {
if (waiter.predicate(message)) {
waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = responseHandlers[message.method];
if (!handler) {
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
return undefined;
})
.catch((error) => {
child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const parsed: unknown = JSON.parse(line);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
processMessage(parsed as JsonObject);
}
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
});
function waitForMessage(
predicate: (message: JsonObject) => boolean,
label: string,
): Promise<JsonObject> {
const existing = messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonObject) => {
clearTimeout(timeout);
resolve(message);
},
};
waiters.add(waiter);
});
}
return {
child,
assertNoErrors() {
if (errors.length > 0) {
throw errors[0];
}
},
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "turn/start",
"turn start request",
);
return toJsonObject(message.params);
},
requestCommandApproval(params) {
const requestId = nextServerRequestId;
nextServerRequestId += 1;
approvalRequestIds.set(params.itemId, requestId);
child.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: requestId,
method: "item/commandExecution/requestApproval",
params,
})}\n`,
);
},
async waitForCommandApprovalDecision(itemId) {
const requestId = approvalRequestIds.get(itemId);
if (requestId === undefined) {
throw new Error(`No pending fake Codex app-server approval for ${itemId}`);
}
const message = await waitForMessage(
(candidate) =>
candidate.id === requestId && !("method" in candidate) && "result" in candidate,
"command approval response",
);
return message.result;
},
};
}
function toJsonObject(value: unknown): JsonObject {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonObject;
}
return {};
}
export function waitForNextPermission(
session: AgentSession,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}
@@ -0,0 +1,183 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { isDeepStrictEqual } from "node:util";
interface JsonRecord {
[key: string]: unknown;
}
type RequestHandler = (params: unknown) => unknown;
type StubbedCodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killSignals: Array<NodeJS.Signals | number | null>;
};
function isJsonRecord(value: unknown): value is JsonRecord {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function parseJsonLine(line: string): JsonRecord | null {
const parsed: unknown = JSON.parse(line);
return isJsonRecord(parsed) ? parsed : null;
}
export function createCodexAppServerChildProcessStub(
options: { exitOnKill?: boolean } = {},
): StubbedCodexAppServerChildProcess {
const child = new EventEmitter() as StubbedCodexAppServerChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killSignals = [];
Object.defineProperty(child, "exitCode", { value: null, configurable: true });
Object.defineProperty(child, "signalCode", { value: null, configurable: true });
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killSignals.push(signal ?? null);
if (options.exitOnKill !== false) {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
}
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export class TestCodexAppServerPeer {
readonly child: StubbedCodexAppServerChildProcess;
private readonly messages: JsonRecord[] = [];
private readonly errors: Error[] = [];
private readonly waiters = new Set<{
predicate: (message: JsonRecord) => boolean;
resolve: (message: JsonRecord) => void;
}>();
private buffer = "";
constructor(handlers: Record<string, RequestHandler> = {}) {
this.child = createCodexAppServerChildProcessStub();
this.child.stdin.on("data", (chunk) => {
this.acceptPaseoOutput(chunk.toString(), handlers);
});
}
writeNonJsonStdout(line: string): void {
this.child.stdout.write(`${line}\n`);
}
writeResponse(id: number, result: unknown): void {
this.child.stdout.write(`${JSON.stringify({ id, result })}\n`);
}
writeRequest(method: string, params: unknown = {}, id = 7): void {
this.child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
}
nextPaseoOutputLine(): Promise<string> {
return new Promise((resolve) => {
this.child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
}
async waitForResponse(id: number, result: unknown): Promise<JsonRecord> {
return this.waitForMessage(
(message) =>
message.id === id && !("method" in message) && isDeepStrictEqual(message.result, result),
`response ${id}`,
);
}
assertNoErrors(): void {
if (this.errors.length > 0) {
throw this.errors[0];
}
}
close(): void {
this.child.stdout.end();
this.child.stderr.end();
this.child.stdin.end();
}
private acceptPaseoOutput(chunk: string, handlers: Record<string, RequestHandler>): void {
this.buffer += chunk;
for (;;) {
const newlineIndex = this.buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const message = parseJsonLine(line);
if (message) {
this.processPaseoMessage(message, handlers);
}
} catch (error) {
this.errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
}
private processPaseoMessage(message: JsonRecord, handlers: Record<string, RequestHandler>): void {
this.messages.push(message);
for (const waiter of Array.from(this.waiters)) {
if (waiter.predicate(message)) {
this.waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = handlers[message.method];
if (!handler) {
this.errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
this.writeResponse(message.id as number, result);
return undefined;
})
.catch((error) => {
this.child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
private waitForMessage(
predicate: (message: JsonRecord) => boolean,
label: string,
): Promise<JsonRecord> {
const existing = this.messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonRecord) => {
clearTimeout(timeout);
resolve(message);
},
};
this.waiters.add(waiter);
});
}
}
@@ -64,39 +64,6 @@ describe("codex tool-call mapper", () => {
}); });
}); });
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-pwsh-string",
status: "running",
command:
'"C:\\Users\\example\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe" -NoLogo -NoProfile -Command "echo hello"',
cwd: "C:\\repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "C:\\repo",
});
});
it("unwraps cmd wrapper arrays for commandExecution on Windows", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-cmd-array",
status: "running",
command: ["cmd.exe", "/c", "echo hello"],
cwd: "C:\\repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "C:\\repo",
});
});
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => { it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
const item = mapCodexToolCallFromThreadItem({ const item = mapCodexToolCallFromThreadItem({
type: "commandExecution", type: "commandExecution",
@@ -200,48 +200,21 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
function maybeUnwrapShellWrapperCommand(command: string): string { function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim(); const trimmed = command.trim();
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/); const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (unixWrapperMatch) { if (!wrapperMatch) {
const candidate = unixWrapperMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
return stripMatchingEdgeQuotes(candidate);
}
const windowsWrapperMatch = trimmed.match(
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i,
);
if (!windowsWrapperMatch) {
return trimmed; return trimmed;
} }
const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? ""; const candidate = wrapperMatch[1]?.trim() ?? "";
if (!wrappedCommand) {
return trimmed;
}
const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i);
if (!commandMatch) {
return trimmed;
}
const candidate = commandMatch[1]?.trim() ?? "";
if (!candidate) { if (!candidate) {
return trimmed; return trimmed;
} }
return stripMatchingEdgeQuotes(candidate);
}
function stripMatchingEdgeQuotes(value: string): string {
if ( if (
(value.startsWith('"') && value.endsWith('"')) || (candidate.startsWith('"') && candidate.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")) (candidate.startsWith("'") && candidate.endsWith("'"))
) { ) {
return value.slice(1, -1); return candidate.slice(1, -1);
} }
return value; return candidate;
}
function isWindowsShellCommand(command: string): boolean {
const normalized = command.replace(/^["']|["']$/g, "");
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
} }
function normalizeCommandExecutionCommand(value: unknown): string | undefined { function normalizeCommandExecutionCommand(value: unknown): string | undefined {
@@ -263,14 +236,6 @@ function normalizeCommandExecutionCommand(value: unknown): string | undefined {
const unwrapped = parts[2]?.trim(); const unwrapped = parts[2]?.trim();
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined; return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
} }
if (
parts.length >= 3 &&
isWindowsShellCommand(parts[0] ?? "") &&
/^(-command|-c|\/c)$/i.test(parts[1] ?? "")
) {
const unwrapped = parts.slice(2).join(" ").trim();
return unwrapped.length > 0 ? stripMatchingEdgeQuotes(unwrapped) : undefined;
}
return parts.join(" "); return parts.join(" ");
} }
@@ -1,17 +1,10 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import { homedir } from "node:os"; import { homedir } from "node:os";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js"; import { findExecutable } from "../../../utils/executable.js";
import { import { ACPAgentClient } from "./acp-agent.js";
ACPAgentClient,
type ACPBeforeModeWriteResult,
type ACPProviderModeWriteResult,
type ACPProviderModeWriterContext,
type SessionStateResponse,
} from "./acp-agent.js";
import { import {
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
@@ -29,31 +22,21 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
supportsToolInvocations: true, supportsToolInvocations: true,
}; };
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent"; const COPILOT_MODES: AgentMode[] = [
const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
const COPILOT_AUTOPILOT_MODE_ID =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
export const COPILOT_ALLOW_ALL_MODE_ID = "allow-all";
const COPILOT_ALLOW_ALL_CONFIG_ID = "allow_all";
const COPILOT_ALLOW_ALL_ON = "on";
const COPILOT_ALLOW_ALL_OFF = "off";
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
export const COPILOT_MODES: AgentMode[] = [
{ {
id: COPILOT_AGENT_MODE_ID, id: "https://agentclientprotocol.com/protocol/session-modes#agent",
label: "Agent", label: "Agent",
description: "Default agent mode for conversational interactions", description: "Default agent mode for conversational interactions",
}, },
{ {
id: COPILOT_PLAN_MODE_ID, id: "https://agentclientprotocol.com/protocol/session-modes#plan",
label: "Plan", label: "Plan",
description: "Plan mode for creating and executing multi-step plans", description: "Plan mode for creating and executing multi-step plans",
}, },
{ {
id: COPILOT_ALLOW_ALL_MODE_ID, id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Allow All", label: "Autopilot",
description: "Automatically approves all Copilot tool, path, and URL requests.", description: "Autonomous mode that runs until task completion without user interaction",
}, },
]; ];
@@ -70,11 +53,6 @@ export class CopilotACPAgentClient extends ACPAgentClient {
runtimeSettings: options.runtimeSettings, runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"], defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES, defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
capabilities: COPILOT_CAPABILITIES, capabilities: COPILOT_CAPABILITIES,
}); });
} }
@@ -135,126 +113,3 @@ export class CopilotACPAgentClient extends ACPAgentClient {
} }
} }
} }
export function transformCopilotSessionResponse(
response: SessionStateResponse,
): SessionStateResponse {
if (!response.modes) {
return response;
}
const allowAllEnabled = isCopilotAllowAllEnabled(response.configOptions ?? []);
return {
...response,
modes: {
...response.modes,
availableModes: response.modes.availableModes
?.filter(
(mode) => mode.id !== COPILOT_AUTOPILOT_MODE_ID && mode.id !== COPILOT_ALLOW_ALL_MODE_ID,
)
.concat({
id: COPILOT_ALLOW_ALL_MODE_ID,
name: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
}),
currentModeId: allowAllEnabled
? COPILOT_ALLOW_ALL_MODE_ID
: (transformCopilotModeId(response.modes.currentModeId ?? COPILOT_AGENT_MODE_ID) ??
COPILOT_AGENT_MODE_ID),
},
};
}
export function transformCopilotConfigOptions(
configOptions: SessionConfigOption[],
): SessionConfigOption[] {
const allowAllEnabled = isCopilotAllowAllEnabled(configOptions);
return configOptions.map((option) => {
if (option.type !== "select" || option.category !== "mode") {
return option;
}
// Trust Copilot's allow_all config value as the source of truth when it changes in-process.
const options = flattenCopilotModeOptions(option.options)
.filter(
(choice) =>
choice.value !== COPILOT_AUTOPILOT_MODE_ID && choice.value !== COPILOT_ALLOW_ALL_MODE_ID,
)
.concat({
value: COPILOT_ALLOW_ALL_MODE_ID,
name: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
});
return {
...option,
currentValue: allowAllEnabled
? COPILOT_ALLOW_ALL_MODE_ID
: (transformCopilotModeId(option.currentValue) ?? COPILOT_AGENT_MODE_ID),
options,
};
});
}
function flattenCopilotModeOptions(
options: SelectConfigOption["options"],
): Array<{ value: string; name: string; description?: string | null }> {
const flattened: Array<{ value: string; name: string; description?: string | null }> = [];
for (const option of options) {
if ("value" in option) {
flattened.push(option);
continue;
}
flattened.push(...option.options);
}
return flattened;
}
export function transformCopilotModeId(modeId: string): string | null {
return modeId === COPILOT_AUTOPILOT_MODE_ID ? COPILOT_AGENT_MODE_ID : modeId;
}
export async function writeCopilotProviderMode(
context: ACPProviderModeWriterContext,
): Promise<ACPProviderModeWriteResult> {
// COMPAT(copilotAutopilotMode): added in v0.1.75, remove after 2026-11-12 once old clients no longer send Copilot's old ACP autopilot mode ID.
const requestsAllowAll =
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID ||
context.requestedModeId === COPILOT_AUTOPILOT_MODE_ID;
if (!requestsAllowAll) {
return { handled: false };
}
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId,
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
value: COPILOT_ALLOW_ALL_ON,
});
return {
handled: true,
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
configOptions: response.configOptions,
};
}
export async function beforeCopilotModeWriter(
context: ACPProviderModeWriterContext,
): Promise<ACPBeforeModeWriteResult> {
if (
context.currentModeId !== COPILOT_ALLOW_ALL_MODE_ID ||
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID
) {
return {};
}
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId,
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
value: COPILOT_ALLOW_ALL_OFF,
});
return { configOptions: response.configOptions };
}
function isCopilotAllowAllEnabled(configOptions: SessionConfigOption[]): boolean {
return configOptions.some(
(option) =>
option.type === "select" &&
option.id === COPILOT_ALLOW_ALL_CONFIG_ID &&
option.currentValue === COPILOT_ALLOW_ALL_ON,
);
}
@@ -78,14 +78,6 @@ async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Pr
return result; return result;
} }
function createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
function isBinaryInstalled(binary: string): boolean { function isBinaryInstalled(binary: string): boolean {
try { try {
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim(); const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
@@ -574,160 +566,6 @@ describe("OpenCode adapter context-window normalization", () => {
}); });
describe("OpenCode adapter startTurn error handling", () => { describe("OpenCode adapter startTurn error handling", () => {
test("unwraps OpenCode global event payloads during a turn", async () => {
const globalEvents = [
{
payload: {
type: "server.connected",
properties: {},
},
},
{
directory: "/tmp/other",
payload: {
type: "message.part.delta",
properties: {
sessionID: "other-session",
messageID: "msg_other",
partID: "prt_other",
field: "text",
delta: "ignore me",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.updated",
properties: {
info: {
id: "msg_assistant",
sessionID: "ses_unit_test",
role: "assistant",
},
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.part.delta",
properties: {
sessionID: "ses_unit_test",
messageID: "msg_assistant",
partID: "prt_text",
field: "text",
delta: "Hello from global",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: { type: "idle" },
},
},
},
];
const fakeClient = {
event: {
subscribe: vi.fn(),
},
global: {
event: vi.fn().mockResolvedValue({ stream: createAsyncIterable(globalEvents) }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const turn = await collectTurnEvents(streamSession(session, "hello"));
expect(fakeClient.global.event).toHaveBeenCalledWith({
signal: expect.any(AbortSignal),
sseMaxRetryAttempts: 0,
});
expect(fakeClient.event.subscribe).not.toHaveBeenCalled();
expect(turn.turnCompleted).toBe(true);
expect(turn.turnFailed).toBe(false);
expect(turn.assistantMessages.map((message) => message.text).join("")).toBe(
"Hello from global",
);
});
test("fails a turn when OpenCode retry status does not recover", async () => {
vi.useFakeTimers();
const retryStream: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let emitted = false;
return {
next: async () => {
if (!emitted) {
emitted = true;
return {
done: false,
value: {
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: {
type: "retry",
attempt: 1,
message: "model does not exist",
},
},
},
},
};
}
return new Promise(() => {});
},
};
},
};
const fakeClient = {
global: {
event: vi.fn().mockResolvedValue({ stream: retryStream }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.startTurn("hello");
await vi.advanceTimersByTimeAsync(10_000);
const failed = events.find((event) => event.type === "turn_failed");
expect(failed).toMatchObject({
type: "turn_failed",
error: expect.stringContaining("model does not exist"),
});
vi.useRealTimers();
});
test("deletes provider session on close when persistence is disabled", async () => { test("deletes provider session on close when persistence is disabled", async () => {
const fakeClient = { const fakeClient = {
session: { session: {
@@ -742,7 +580,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
new Map(), new Map(),
undefined, undefined,
false, false,
@@ -770,7 +607,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
await session.close(); await session.close();
@@ -779,29 +615,19 @@ describe("OpenCode adapter startTurn error handling", () => {
}); });
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => { test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
// Yield the server-connected event, then park forever. The adapter waits // Async iterable that never yields and never resolves. The IIFE in
// for that first event before sending the prompt. // startTurn synchronously hits the promptAsync throw and finishes the
// turn before this iterator is ever pulled, so the never-resolving
// promise inside next() is fine and gets garbage-collected.
const neverYieldingStream: AsyncIterable<OpenCodeEvent> = { const neverYieldingStream: AsyncIterable<OpenCodeEvent> = {
[Symbol.asyncIterator]: () => { [Symbol.asyncIterator]: () => ({
let emittedConnected = false; next: () => new Promise(() => {}),
return { }),
next: () => {
if (!emittedConnected) {
emittedConnected = true;
return Promise.resolve({
done: false,
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
});
}
return new Promise(() => {});
},
};
},
}; };
const fakeClient = { const fakeClient = {
global: { event: {
event: vi.fn().mockResolvedValue({ stream: neverYieldingStream }), subscribe: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
}, },
session: { session: {
promptAsync: vi.fn(() => { promptAsync: vi.fn(() => {
@@ -815,7 +641,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -830,58 +655,6 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(failed.error).toContain("boom: synchronous throw"); expect(failed.error).toContain("boom: synchronous throw");
} }
}); });
test("delays the next prompt until a slow interrupt abort settles", async () => {
vi.useFakeTimers();
const abortDeferred = createTestDeferred<{ data: boolean; error: undefined }>();
const promptAsync = vi.fn().mockResolvedValue({ data: {}, error: undefined });
const abort = vi
.fn()
.mockReturnValueOnce(abortDeferred.promise)
.mockResolvedValue({ data: true, error: undefined });
const fakeClient = {
global: {
event: vi.fn().mockImplementation(
async (options: {
signal: AbortSignal;
}): Promise<{ stream: AsyncIterable<OpenCodeEvent> }> => ({
stream: abortableOpenCodeStream(options.signal),
}),
),
},
session: {
promptAsync,
abort,
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
await session.startTurn("first");
expect(promptAsync).toHaveBeenCalledTimes(1);
const interruptPromise = session.interrupt();
await vi.advanceTimersByTimeAsync(2_000);
await interruptPromise;
expect(abort).toHaveBeenCalledTimes(1);
const secondTurnPromise = session.startTurn("second");
await vi.advanceTimersByTimeAsync(1_000);
expect(promptAsync).toHaveBeenCalledTimes(1);
abortDeferred.resolve({ data: true, error: undefined });
await secondTurnPromise;
expect(promptAsync).toHaveBeenCalledTimes(2);
await session.interrupt();
vi.useRealTimers();
});
}); });
describe("OpenCode persisted sessions", () => { describe("OpenCode persisted sessions", () => {
@@ -967,45 +740,3 @@ function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unk
mkdirSync(path.dirname(filePath), { recursive: true }); mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value), "utf8"); writeFileSync(filePath, JSON.stringify(value), "utf8");
} }
function createTestDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
return {
[Symbol.asyncIterator]: () => {
let emittedConnected = false;
return {
next: () => {
if (!emittedConnected) {
emittedConnected = true;
return Promise.resolve({
done: false,
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
});
}
return new Promise<IteratorResult<OpenCodeEvent>>((resolve) => {
if (signal.aborted) {
resolve({ done: true, value: undefined });
return;
}
signal.addEventListener("abort", () => resolve({ done: true, value: undefined }), {
once: true,
});
});
},
};
},
};
}
@@ -13,34 +13,33 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import { import type {
getAgentStreamEventTurnId, AgentCapabilityFlags,
type AgentCapabilityFlags, AgentClient,
type AgentClient, AgentCreateSessionOptions,
type AgentCreateSessionOptions, AgentLaunchContext,
type AgentLaunchContext, AgentMode,
type AgentMode, AgentModelDefinition,
type AgentModelDefinition, AgentPermissionRequest,
type AgentPermissionRequest, AgentPermissionResponse,
type AgentPermissionResponse, AgentPersistenceHandle,
type AgentPersistenceHandle, AgentPromptInput,
type AgentPromptInput, AgentRunOptions,
type AgentRunOptions, AgentRunResult,
type AgentRunResult, AgentRuntimeInfo,
type AgentRuntimeInfo, AgentSession,
type AgentSession, AgentSessionConfig,
type AgentSessionConfig, AgentSlashCommand,
type AgentSlashCommand, AgentStreamEvent,
type AgentStreamEvent, AgentTimelineItem,
type AgentTimelineItem, AgentUsage,
type AgentUsage, ListModelsOptions,
type ListModelsOptions, ListModesOptions,
type ListModesOptions, ListPersistedAgentsOptions,
type ListPersistedAgentsOptions, McpServerConfig,
type McpServerConfig, PersistedAgentDescriptor,
type PersistedAgentDescriptor, ToolCallDetail,
type ToolCallDetail, ToolCallTimelineItem,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js"; import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { withTimeout } from "../../../utils/promise-timeout.js"; import { withTimeout } from "../../../utils/promise-timeout.js";
@@ -75,8 +74,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build"; const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access"; const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200; const OPENCODE_STORAGE_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
const OPENCODE_RETRY_STATUS_FAILURE_MS = 10_000;
const DEFAULT_MODES: AgentMode[] = [ const DEFAULT_MODES: AgentMode[] = [
{ {
@@ -821,32 +818,18 @@ async function readOpenCodeSessionTimeline(
} }
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> { async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
const parts = await readOpenCodeStoredParts(storageRoot, messageId);
return readOpenCodeTextFromParts(parts);
}
async function readOpenCodeStoredParts(
storageRoot: string,
messageId: string,
): Promise<OpenCodeStoredPart[]> {
const partRoot = path.join(storageRoot, "part", messageId); const partRoot = path.join(storageRoot, "part", messageId);
const partFiles = await findJsonFiles(partRoot); const partFiles = await findJsonFiles(partRoot);
const parts: OpenCodeStoredPart[] = []; const parts: OpenCodeStoredPart[] = [];
for (const file of partFiles) { for (const file of partFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema); const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
if (parsed) { if (parsed?.type === "text" && typeof parsed.text === "string") {
parts.push(parsed); parts.push(parsed);
} }
} }
return parts.sort(
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
);
}
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
return parts return parts
.filter((part) => part.type === "text" && typeof part.text === "string") .sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
.map((part) => part.text?.trim() ?? "") .map((part) => part.text?.trim() ?? "")
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
@@ -967,7 +950,7 @@ export class OpenCodeAgentClient implements AgentClient {
async createSession( async createSession(
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, _launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions, options?: AgentCreateSessionOptions,
): Promise<AgentSession> { ): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config); const openCodeConfig = this.assertConfig(config);
@@ -1001,11 +984,9 @@ export class OpenCodeAgentClient implements AgentClient {
client, client,
session.id, session.id,
this.logger, this.logger,
this.storageRoot,
new Map(this.modelContextWindows), new Map(this.modelContextWindows),
acquisition.release, acquisition.release,
options?.persistSession, options?.persistSession,
launchContext?.agentId,
); );
} catch (error) { } catch (error) {
acquisition.release(); acquisition.release();
@@ -1016,7 +997,7 @@ export class OpenCodeAgentClient implements AgentClient {
async resumeSession( async resumeSession(
handle: AgentPersistenceHandle, handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext, _launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string); const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
if (!cwd) { if (!cwd) {
@@ -1044,11 +1025,8 @@ export class OpenCodeAgentClient implements AgentClient {
client, client,
handle.sessionId, handle.sessionId,
this.logger, this.logger,
this.storageRoot,
new Map(this.modelContextWindows), new Map(this.modelContextWindows),
acquisition.release, acquisition.release,
undefined,
launchContext?.agentId,
); );
} catch (error) { } catch (error) {
acquisition.release(); acquisition.release();
@@ -1280,28 +1258,6 @@ export interface OpenCodeEventTranslationState {
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void; onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
} }
interface OpenCodeTraceData {
turnId?: string;
[key: string]: unknown;
}
type OpenCodeTraceMessage =
| "provider.opencode.prompt_async.start"
| "provider.opencode.prompt_async.response"
| "provider.opencode.prompt_async.throw"
| "provider.opencode.subscribe.start"
| "provider.opencode.subscribe.ready"
| "provider.opencode.stream.eof"
| "provider.opencode.turn.fail_eof"
| "provider.opencode.subscribe.error"
| "provider.opencode.raw_event"
| "provider.opencode.event.skip"
| "provider.opencode.parsed_event"
| "provider.opencode.parsed_event.skip_active"
| "provider.opencode.event.terminal"
| "provider.opencode.finish_foreground_turn"
| "provider.opencode.event_emit";
type OpenCodeToolPartEventPart = Extract< type OpenCodeToolPartEventPart = Extract<
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"], Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
{ type: "tool" } { type: "tool" }
@@ -2166,24 +2122,6 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve, reject }; return { promise, resolve, reject };
} }
function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null {
const record = readOpenCodeRecord(event);
if (!record) {
return null;
}
const payload = readOpenCodeRecord(record.payload);
if (typeof payload?.type === "string") {
return payload as unknown as OpenCodeEvent;
}
if (typeof record.type === "string") {
return record as unknown as OpenCodeEvent;
}
return null;
}
class OpenCodeAgentSession implements AgentSession { class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const; readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES; readonly capabilities = OPENCODE_CAPABILITIES;
@@ -2196,7 +2134,6 @@ class OpenCodeAgentSession implements AgentSession {
private currentMode: string = "default"; private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>(); private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
private pendingAbortPromise: Promise<void> | null = null;
private accumulatedUsage: AgentUsage = {}; private accumulatedUsage: AgentUsage = {};
private mcpConfigured = false; private mcpConfigured = false;
private mcpSetupPromise: Promise<void> | null = null; private mcpSetupPromise: Promise<void> | null = null;
@@ -2220,22 +2157,19 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null; private releaseServer: (() => void) | null;
private readonly persistSession: boolean; private readonly persistSession: boolean;
private deletedFromProvider = false; private deletedFromProvider = false;
private retryFailureTimer: ReturnType<typeof setTimeout> | null = null;
constructor( constructor(
config: OpenCodeAgentConfig, config: OpenCodeAgentConfig,
client: OpencodeClient, client: OpencodeClient,
sessionId: string, sessionId: string,
logger: Logger, logger: Logger,
_storageRoot: string,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(), modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void, releaseServer?: () => void,
persistSession = true, persistSession = true,
private readonly agentId?: string,
) { ) {
this.config = config; this.config = config;
this.client = client; this.client = client;
this.sessionId = sessionId; this.sessionId = sessionId;
this.logger = logger.child({ agentId: this.agentId }); this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey; this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId); this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.releaseServer = releaseServer ?? null; this.releaseServer = releaseServer ?? null;
@@ -2289,18 +2223,9 @@ class OpenCodeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId; const turnId = this.activeForegroundTurnId;
const turnAbortController = this.abortController; const turnAbortController = this.abortController;
turnAbortController?.abort(); turnAbortController?.abort();
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until await this.client.session.abort({
// the running tool actually stops, which can be tens of seconds for sessionID: this.sessionId,
// long-running tools. Cap the wait so the user-visible cancel lands directory: this.config.cwd,
// quickly while still giving OpenCode a chance to confirm the abort
// cleanly. Drop the timeout once upstream returns abort acknowledgement
// before tool teardown.
const abortPromise = this.beginSessionAbort(turnId, "interrupt");
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId },
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
);
}); });
if (turnId) { if (turnId) {
this.finishForegroundTurn( this.finishForegroundTurn(
@@ -2310,46 +2235,6 @@ class OpenCodeAgentSession implements AgentSession {
} }
} }
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
const abortPromise = this.client.session
.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
})
.then(() => undefined)
.catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId, reason },
"OpenCode session.abort rejected",
);
});
const trackedAbortPromise = abortPromise.finally(() => {
if (this.pendingAbortPromise === trackedAbortPromise) {
this.pendingAbortPromise = null;
}
});
this.pendingAbortPromise = trackedAbortPromise;
return trackedAbortPromise;
}
private async awaitPendingAbortBeforeStartingTurn(): Promise<void> {
const pendingAbortPromise = this.pendingAbortPromise;
if (!pendingAbortPromise) {
return;
}
await withTimeout(
pendingAbortPromise,
OPENCODE_PENDING_ABORT_START_TIMEOUT_MS,
"OpenCode pending session.abort",
).catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId },
"OpenCode session.abort was still pending before starting the next turn",
);
});
}
async startTurn( async startTurn(
prompt: AgentPromptInput, prompt: AgentPromptInput,
options?: AgentRunOptions, options?: AgentRunOptions,
@@ -2357,13 +2242,11 @@ class OpenCodeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) { if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active"); throw new Error("A foreground turn is already active");
} }
await this.awaitPendingAbortBeforeStartingTurn();
this.runningToolCalls.clear(); this.runningToolCalls.clear();
this.subAgentsByCallId.clear(); this.subAgentsByCallId.clear();
this.subAgentCallIdByChildSessionId.clear(); this.subAgentCallIdByChildSessionId.clear();
this.pendingChildToolPartsBySessionId.clear(); this.pendingChildToolPartsBySessionId.clear();
this.clearRetryFailureTimer();
const turnAbortController = new AbortController(); const turnAbortController = new AbortController();
this.abortController = turnAbortController; this.abortController = turnAbortController;
await this.ensureMcpServersConfigured(); await this.ensureMcpServersConfigured();
@@ -2497,14 +2380,6 @@ class OpenCodeAgentSession implements AgentSession {
// SDK input validation) is caught alongside async rejections. A plain // SDK input validation) is caught alongside async rejections. A plain
// `.then().catch()` chain would let a sync throw escape unhandled. // `.then().catch()` chain would let a sync throw escape unhandled.
void (async () => { void (async () => {
this.traceOpenCode("provider.opencode.prompt_async.start", {
turnId,
sessionId: this.sessionId,
model,
effectiveMode,
effectiveVariant,
partTypes: parts.map((p) => p.type),
});
try { try {
const promptResponse = await this.client.session.promptAsync({ const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId, sessionID: this.sessionId,
@@ -2523,12 +2398,6 @@ class OpenCodeAgentSession implements AgentSession {
...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}),
}); });
this.traceOpenCode("provider.opencode.prompt_async.response", {
turnId,
hasError: promptResponse.error !== undefined,
error: promptResponse.error,
data: promptResponse.data,
});
if (promptResponse.error) { if (promptResponse.error) {
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
@@ -2540,13 +2409,6 @@ class OpenCodeAgentSession implements AgentSession {
); );
} }
} catch (error) { } catch (error) {
this.traceOpenCode("provider.opencode.prompt_async.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
type: "turn_failed", type: "turn_failed",
@@ -2574,51 +2436,36 @@ class OpenCodeAgentSession implements AgentSession {
turnAbortController: AbortController, turnAbortController: AbortController,
subscriptionReady: Deferred<void>, subscriptionReady: Deferred<void>,
): Promise<void> { ): Promise<void> {
this.traceOpenCode("provider.opencode.subscribe.start", {
turnId,
sessionId: this.sessionId,
cwd: this.config.cwd,
});
try { try {
const result = await this.client.global.event({ const result = await this.client.event.subscribe(
signal: turnAbortController.signal, { directory: this.config.cwd },
sseMaxRetryAttempts: 0, { signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
}); );
let eventCount = 0; subscriptionReady.resolve();
let subscriptionReadyResolved = false;
for await (const rawEvent of result.stream) { for await (const event of result.stream) {
eventCount += 1; if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
if (!subscriptionReadyResolved) { break;
subscriptionReadyResolved = true;
this.traceOpenCode("provider.opencode.subscribe.ready", {
turnId,
sessionId: this.sessionId,
});
subscriptionReady.resolve();
} }
const shouldContinue = await this.consumeOpenCodeStreamEvent({
rawEvent, const translated = await this.translateEvent(event);
eventCount, for (const e of translated) {
turnId, if (this.activeForegroundTurnId !== turnId) {
turnAbortController, return;
}); }
if (!shouldContinue) { if (e.type === "timeline" && e.item.type === "tool_call") {
return; this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
this.finishForegroundTurn(terminalEvent, turnId);
return;
}
this.notifySubscribers(e, turnId);
} }
} }
this.traceOpenCode("provider.opencode.stream.eof", {
turnId,
eventCount,
aborted: turnAbortController.signal.aborted,
stillActive: this.activeForegroundTurnId === turnId,
});
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.traceOpenCode("provider.opencode.turn.fail_eof", { turnId, eventCount });
if (!subscriptionReadyResolved) {
subscriptionReady.reject(new Error("OpenCode event stream ended before it became ready"));
}
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
type: "turn_failed", type: "turn_failed",
@@ -2629,11 +2476,6 @@ class OpenCodeAgentSession implements AgentSession {
); );
} }
} catch (error) { } catch (error) {
this.traceOpenCode("provider.opencode.subscribe.error", {
turnId,
error:
error instanceof Error ? { name: error.name, message: error.message } : String(error),
});
subscriptionReady.reject(error); subscriptionReady.reject(error);
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn( this.finishForegroundTurn(
@@ -2662,80 +2504,10 @@ class OpenCodeAgentSession implements AgentSession {
} }
} }
private async consumeOpenCodeStreamEvent(params: {
rawEvent: unknown;
eventCount: number;
turnId: string;
turnAbortController: AbortController;
}): Promise<boolean> {
const { rawEvent, eventCount, turnId, turnAbortController } = params;
const event = unwrapOpenCodeGlobalEvent(rawEvent);
this.traceOpenCode("provider.opencode.raw_event", {
turnId,
n: eventCount,
type: event?.type,
rawType: readOpenCodeRecord(rawEvent)?.type,
directory: readOpenCodeRecord(rawEvent)?.directory,
rawEvent,
properties: event?.properties,
});
if (!event) {
return true;
}
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
this.traceOpenCode("provider.opencode.event.skip", {
turnId,
n: eventCount,
aborted: turnAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
});
return false;
}
this.armRetryFailureTimerForStatus(event, turnId);
const translated = await this.translateEvent(event);
this.traceOpenCode("provider.opencode.parsed_event", {
turnId,
n: eventCount,
count: translated.length,
types: translated.map((t) => t.type),
events: translated,
});
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
this.traceOpenCode("provider.opencode.parsed_event.skip_active", { turnId, type: e.type });
return false;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
this.traceOpenCode("provider.opencode.event.terminal", {
turnId,
type: terminalEvent.type,
});
this.finishForegroundTurn(terminalEvent, turnId);
return false;
}
this.notifySubscribers(e, turnId);
}
return true;
}
private finishForegroundTurn( private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>, event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string, turnId: string,
): void { ): void {
this.traceOpenCode("provider.opencode.finish_foreground_turn", {
turnId,
activeTurnId: this.activeForegroundTurnId,
type: event.type,
error: event.type === "turn_failed" ? event.error : undefined,
reason: event.type === "turn_canceled" ? event.reason : undefined,
});
if (this.activeForegroundTurnId !== turnId) { if (this.activeForegroundTurnId !== turnId) {
return; return;
} }
@@ -2744,7 +2516,6 @@ class OpenCodeAgentSession implements AgentSession {
} else { } else {
this.runningToolCalls.clear(); this.runningToolCalls.clear();
} }
this.clearRetryFailureTimer();
this.activeForegroundTurnId = null; this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch. // Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort(); this.abortController?.abort();
@@ -2760,44 +2531,6 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.delete(item.callId); this.runningToolCalls.delete(item.callId);
} }
private armRetryFailureTimerForStatus(event: OpenCodeEvent, turnId: string): void {
if (this.retryFailureTimer || event.type !== "session.status") {
return;
}
if (event.properties.sessionID !== this.sessionId || event.properties.status.type !== "retry") {
return;
}
const retry = event.properties.status;
const message = typeof retry.message === "string" ? retry.message.trim() : "";
const error = message
? `OpenCode provider retry did not recover: ${message}`
: "OpenCode provider retry did not recover";
this.retryFailureTimer = setTimeout(() => {
this.retryFailureTimer = null;
if (this.activeForegroundTurnId !== turnId) {
return;
}
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error,
},
turnId,
);
}, OPENCODE_RETRY_STATUS_FAILURE_MS);
}
private clearRetryFailureTimer(): void {
if (!this.retryFailureTimer) {
return;
}
clearTimeout(this.retryFailureTimer);
this.retryFailureTimer = null;
}
private synthesizeInterruptedToolCalls(turnId: string): void { private synthesizeInterruptedToolCalls(turnId: string): void {
for (const item of this.runningToolCalls.values()) { for (const item of this.runningToolCalls.values()) {
const error = { message: "Tool execution aborted" }; const error = { message: "Tool execution aborted" };
@@ -2829,10 +2562,6 @@ class OpenCodeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void { private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
const turnId = turnIdOverride ?? this.activeForegroundTurnId; const turnId = turnIdOverride ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event; const tagged = turnId ? { ...event, turnId } : event;
this.traceOpenCode("provider.opencode.event_emit", {
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
});
for (const callback of this.subscribers) { for (const callback of this.subscribers) {
try { try {
callback(tagged); callback(tagged);
@@ -2846,19 +2575,6 @@ class OpenCodeAgentSession implements AgentSession {
return `opencode-turn-${this.nextTurnOrdinal++}`; return `opencode-turn-${this.nextTurnOrdinal++}`;
} }
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
this.logger.trace(
{
agentId: this.agentId,
provider: "opencode",
sessionId: this.sessionId,
turnId: data.turnId ?? this.activeForegroundTurnId ?? undefined,
...data,
},
msg,
);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> { async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
const response = await this.client.session.messages({ const response = await this.client.session.messages({
sessionID: this.sessionId, sessionID: this.sessionId,
@@ -47,7 +47,6 @@ export class TestOpenCodeClient {
appAgents: [] as unknown[], appAgents: [] as unknown[],
commandList: [] as unknown[], commandList: [] as unknown[],
eventSubscribe: [] as unknown[], eventSubscribe: [] as unknown[],
globalEvent: [] as unknown[],
permissionReply: [] as unknown[], permissionReply: [] as unknown[],
providerList: [] as unknown[], providerList: [] as unknown[],
questionReject: [] as unknown[], questionReject: [] as unknown[],
@@ -100,12 +99,6 @@ export class TestOpenCodeClient {
return { stream: this.eventStream }; return { stream: this.eventStream };
}, },
}, },
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
return { stream: this.eventStream };
},
},
mcp: { mcp: {
add: async () => ({}), add: async () => ({}),
connect: async () => ({}), connect: async () => ({}),
@@ -30,30 +30,29 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
import { z } from "zod"; import { z } from "zod";
import { import type {
getAgentStreamEventTurnId, AgentCapabilityFlags,
type AgentCapabilityFlags, AgentClient,
type AgentClient, AgentLaunchContext,
type AgentLaunchContext, AgentMetadata,
type AgentMetadata, AgentMode,
type AgentMode, AgentModelDefinition,
type AgentModelDefinition, AgentPermissionRequest,
type AgentPermissionRequest, AgentPermissionResponse,
type AgentPermissionResponse, AgentPersistenceHandle,
type AgentPersistenceHandle, AgentPromptInput,
type AgentPromptInput, AgentRunOptions,
type AgentRunOptions, AgentRunResult,
type AgentRunResult, AgentRuntimeInfo,
type AgentRuntimeInfo, AgentSession,
type AgentSession, AgentSessionConfig,
type AgentSessionConfig, AgentSlashCommand,
type AgentSlashCommand, AgentStreamEvent,
type AgentStreamEvent, AgentTimelineItem,
type AgentTimelineItem, AgentUsage,
type AgentUsage, ListModesOptions,
type ListModesOptions, ListModelsOptions,
type ListModelsOptions, ToolCallDetail,
type ToolCallDetail,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
@@ -739,6 +738,21 @@ function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersis
return {}; return {};
} }
function getStreamEventTurnId(event: AgentStreamEvent): string | undefined {
switch (event.type) {
case "turn_started":
case "turn_completed":
case "turn_failed":
case "turn_canceled":
case "timeline":
case "permission_requested":
case "permission_resolved":
return event.turnId;
default:
return undefined;
}
}
function isPiRequestAbortError(error: unknown): boolean { function isPiRequestAbortError(error: unknown): boolean {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
return true; return true;
@@ -1031,7 +1045,7 @@ export class PiDirectAgentSession implements AgentSession {
return; return;
} }
const eventTurnId = getAgentStreamEventTurnId(event); const eventTurnId = getStreamEventTurnId(event);
if (turnId && eventTurnId && eventTurnId !== turnId) { if (turnId && eventTurnId && eventTurnId !== turnId) {
return; return;
} }
@@ -1,10 +1,9 @@
import { import type {
getAgentStreamEventTurnId, AgentPromptInput,
type AgentPromptInput, AgentRunOptions,
type AgentRunOptions, AgentRunResult,
type AgentRunResult, AgentStreamEvent,
type AgentStreamEvent, AgentTimelineItem,
type AgentTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
export type ProviderFinalTextReducer = (params: { export type ProviderFinalTextReducer = (params: {
@@ -45,7 +44,7 @@ export async function runProviderTurn({
if (settled) { if (settled) {
return; return;
} }
const eventTurnId = getAgentStreamEventTurnId(event); const eventTurnId = "turnId" in event ? event.turnId : undefined;
if (turnId && eventTurnId && eventTurnId !== turnId) { if (turnId && eventTurnId && eventTurnId !== turnId) {
return; return;
} }
@@ -1,9 +1,8 @@
import { import type {
getAgentStreamEventTurnId, AgentPromptInput,
type AgentPromptInput, AgentRunOptions,
type AgentRunOptions, AgentSession,
type AgentSession, AgentStreamEvent,
type AgentStreamEvent,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
function isTerminalEvent(event: AgentStreamEvent): boolean { function isTerminalEvent(event: AgentStreamEvent): boolean {
@@ -30,7 +29,7 @@ export async function* streamSession(
}; };
const matchesTurn = (event: AgentStreamEvent): boolean => { const matchesTurn = (event: AgentStreamEvent): boolean => {
const eventTurnId = getAgentStreamEventTurnId(event); const eventTurnId = (event as { turnId?: string }).turnId;
return turnId == null || eventTurnId == null || eventTurnId === turnId; return turnId == null || eventTurnId == null || eventTurnId === turnId;
}; };

Some files were not shown because too many files have changed in this diff Show More