Compare commits

..
150 changed files with 4207 additions and 8515 deletions
-5
View File
@@ -5,13 +5,8 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
format:
runs-on: ubuntu-latest
+72
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
## 0.1.75 - 2026-05-12
### Added
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
- NixOS: `services.paseo.settings` renders declarative daemon config to disk, and typed `services.paseo.relay` options auto-wire the relay endpoint. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
- NixOS: Paseo desktop is now packaged in the flake — `nix run github:getpaseo/paseo#desktop` launches the Electron app. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
### Fixed
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
- Copilot's **Allow All** mode (previously misnamed Autopilot) now actually suppresses tool, path, and URL permission prompts. ([#935](https://github.com/getpaseo/paseo/pull/935))
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
- iPhone HEIC photos now attach correctly from the image picker. ([#934](https://github.com/getpaseo/paseo/pull/934))
- Scheduled agents now archive automatically after each run instead of piling up in the active list. ([#945](https://github.com/getpaseo/paseo/pull/945))
- Windows: Codex command summaries show the underlying command instead of `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
## 0.1.74 - 2026-05-11
### Fixed
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
## 0.1.73 - 2026-05-10
### Fixed
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
## 0.1.72 - 2026-05-10
### Fixed
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
## 0.1.71 - 2026-05-09
### Added
@@ -73,7 +23,6 @@
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
### Security
-24
View File
@@ -59,30 +59,6 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
---
## Z.AI (Zhipu) coding plan
+2 -2
View File
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
providers: Record<providerId, ProviderOverride>
},
features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
+1 -8
View File
@@ -39,14 +39,7 @@ In any worktree-style or portless setup, never assume default ports.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
Check `$PASEO_HOME/daemon.log` for trace-level logs.
## 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
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
];
in
{
default = paseo;
paseo = paseo;
}
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
}
);
nixosModules.default = self.nixosModules.paseo;
-157
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 {
type = lib.types.bool;
default = true;
description = ''
Whether to enable relay-based remote access. When false, the daemon
runs with `--no-relay` and only accepts direct (LAN/loopback)
connections.
'';
};
mode = lib.mkOption {
type = lib.types.enum [ "hosted" "remote" ];
default = "hosted";
description = ''
How the daemon reaches the relay when `relay.enable = true`:
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
Preserves the current behavior; no extra options needed.
- `"remote"`: connect to a self-hosted relay at
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
`PASEO_RELAY_USE_TLS` for the daemon.
A `"local"` mode (running a relay on the same host as a systemd
unit) is not yet implemented — the relay package currently only
ships a Cloudflare Workers adapter. Tracked separately.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "";
example = "relay.example.com";
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Relay port. Used when `relay.mode = \"remote\"`.";
};
useTls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
};
};
@@ -152,50 +111,9 @@ in
'';
description = "Extra environment variables for the Paseo daemon.";
};
settings = lib.mkOption {
type = (pkgs.formats.json { }).type;
default = { };
example = lib.literalExpression ''
{
daemon.mcp = { enabled = true; injectIntoAgents = false; };
agents.providers.myAcp = {
extends = "acp";
label = "My Agent";
command = { path = "/run/current-system/sw/bin/my-acp"; };
};
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
}
'';
description = ''
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
and installed on every service start.
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
or the mobile app toggling MCP injection / provider overrides) are
overwritten on the next restart. Pick one: manage via this option, or
manage via the CLI — not both.
The full schema is defined by `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`.
'';
};
};
config = lib.mkIf cfg.enable (
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".
'';
}
];
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
@@ -213,10 +131,6 @@ in
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = lib.mkIf (cfg.settings != { }) ''
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
'';
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
@@ -235,9 +149,6 @@ in
PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // cfg.environment;
serviceConfig = {
@@ -261,6 +172,5 @@ in
environment.systemPackages = [ cfg.package ];
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,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
# can override via `.override { npmDepsHash = "sha256-..."; }` without
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
#
# The default is read from a sidecar file so the CI auto-updater can replace
# the hash with a single file write instead of a sed against this source.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
@@ -49,9 +40,9 @@ buildNpmPackage rec {
nodejs = nodejs_22;
# Default hash lives in nix/npm-deps.hash (see arg default above).
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
# 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).
+20 -36
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.75",
"version": "0.1.71",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.75",
"version": "0.1.71",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -9397,6 +9397,12 @@
"node": ">=20.0"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -21769,18 +21775,6 @@
"expo": "*"
}
},
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
@@ -38860,7 +38854,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.75",
"version": "0.1.71",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38898,7 +38892,6 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
@@ -38987,10 +38980,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.75",
"version": "0.1.71",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.75",
"@getpaseo/server": "0.1.71",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39033,7 +39026,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.75",
"version": "0.1.71",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -39082,7 +39075,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.75",
"version": "0.1.71",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -39118,7 +39111,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.75",
"version": "0.1.71",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -39144,7 +39137,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.75",
"version": "0.1.71",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39159,18 +39152,18 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.75",
"version": "0.1.71",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.75",
"@getpaseo/relay": "0.1.75",
"@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.71",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -39351,15 +39344,6 @@
"url": "https://opencollective.com/express"
}
},
"packages/server/node_modules/@opencode-ai/sdk": {
"version": "1.14.46",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
"integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -39707,7 +39691,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.75",
"version": "0.1.71",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.75",
"version": "0.1.71",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"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> {
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.
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.
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.75",
"version": "0.1.71",
"private": true,
"main": "index.ts",
"scripts": {
@@ -63,7 +63,6 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
@@ -19,7 +19,7 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
if (isMobile && isNative) {
if (isMobile) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}
@@ -12,7 +12,7 @@ import {
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { 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 type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
@@ -443,7 +443,7 @@ function ProviderSearchInput({
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
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 desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
[resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
[resizeAnimatedStyle, insetsTop],
);
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[],
+2 -2
View File
@@ -38,7 +38,7 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
@@ -148,7 +148,7 @@ export function SearchInput({
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => {
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 type { PaseoConfigRaw } from "@server/shared/messages";
import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
} from "./worktree-setup-callout-policy";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorkspaceDescriptor,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection();
@@ -26,7 +58,7 @@ export function WorktreeSetupCalloutSource() {
if (!activeProject) {
return;
}
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute);
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
});
const readQuery = useQuery({
@@ -41,31 +73,29 @@ export function WorktreeSetupCalloutSource() {
retry: false,
});
const calloutPolicy = useMemo(
() =>
activeProject && shouldShowWorktreeSetupCallout(readQuery.data)
? buildWorktreeSetupCalloutPolicy(activeProject)
: null,
[activeProject, readQuery.data],
);
const shouldShow =
activeProject !== null &&
readQuery.data?.ok === true &&
!hasSetupCommands(readQuery.data.config ?? {});
useEffect(() => {
if (!calloutPolicy) {
if (!shouldShow || !activeProject) {
return;
}
return callouts.show({
id: calloutPolicy.id,
dismissalKey: calloutPolicy.dismissalKey,
priority: calloutPolicy.priority,
title: calloutPolicy.title,
description: calloutPolicy.description,
id: `worktree-setup-missing:${activeProject.projectKey}`,
dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
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;
}
@@ -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,
useState,
} from "react";
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
clearSidebarCallouts,
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
type SidebarCalloutEntry,
type SidebarCalloutOptions,
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
SidebarCallout,
type SidebarCalloutAction,
type SidebarCalloutProps,
type SidebarCalloutVariant,
} from "@/components/sidebar-callout";
import { useStableEvent } from "@/hooks/use-stable-event";
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 {
show: (callout: SidebarCalloutOptions) => () => void;
@@ -33,48 +36,96 @@ export interface SidebarCalloutsApi {
clear: () => void;
}
type SidebarCalloutEntry = SidebarCalloutOptions & {
order: number;
priority: number;
token: number;
};
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem(
DISMISSED_CALLOUTS_STORAGE_KEY,
serializeDismissedCalloutKeys(keys),
).catch((error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
(error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
},
);
}
function selectActiveCallout(input: {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
}): SidebarCalloutEntry | null {
const visibleCallouts = input.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
const stateRef = useRef<SidebarCalloutState>(state);
function commitState(next: SidebarCalloutState): void {
stateRef.current = next;
setState(next);
}
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
const dismissedKeysRef = useRef<Set<string>>(new Set());
const orderRef = useRef(0);
const tokenRef = useRef(0);
useEffect(() => {
let mounted = true;
async function loadDismissedKeys(): Promise<void> {
let dismissedKeys: ReadonlySet<string>;
try {
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
dismissedKeys = parseDismissedCalloutKeys(value);
} catch (error) {
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
.then((value) => {
if (!mounted) {
return;
}
const nextKeys = parseDismissedCalloutKeys(value);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
return;
})
.catch((error) => {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
dismissedKeys = stateRef.current.dismissedKeys;
}
if (mounted) {
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
}
}
void loadDismissedKeys();
})
.finally(() => {
if (mounted) {
setDismissalStorageLoaded(true);
}
});
return () => {
mounted = false;
@@ -82,33 +133,60 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
}, []);
const show = useStableEvent((callout: SidebarCalloutOptions) => {
const result = showSidebarCallout(stateRef.current, callout);
commitState(result.state);
tokenRef.current += 1;
const token = tokenRef.current;
const current = calloutsRef.current;
const existing = current.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? ++orderRef.current,
token,
};
const next = existing
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...current, nextEntry];
calloutsRef.current = next;
setCallouts(next);
return () => {
commitState(
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
const updated = calloutsRef.current.filter(
(entry) => entry.id !== callout.id || entry.token !== token,
);
calloutsRef.current = updated;
setCallouts(updated);
};
});
const dismiss = useStableEvent((id: string) => {
const result = dismissSidebarCallout(stateRef.current, id);
commitState(result.state);
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
const next = calloutsRef.current.filter((entry) => entry.id !== id);
calloutsRef.current = next;
setCallouts(next);
if (result.dismissalKey) {
persistDismissedCalloutKeys(result.state.dismissedKeys);
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
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(() => {
commitState(clearSidebarCallouts(stateRef.current));
calloutsRef.current = [];
setCallouts([]);
});
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 (
<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 },
},
{
name: "matches tab index jump on mac desktop via Cmd+Alt+digit",
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
context: { isMac: true, isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on non-mac desktop via Alt+digit",
name: "matches tab index jump on desktop via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true },
context: { isMac: false, isDesktop: true },
context: { isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
@@ -340,11 +333,6 @@ describe("keyboard-shortcuts", () => {
event: { key: "t", code: "KeyT", ctrlKey: true },
context: { isMac: true },
},
{
name: "keeps mac Option+digit available for international text input",
event: { key: "@", code: "Digit2", altKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
},
{
name: "does not match Ctrl+K for command center on non-mac in terminal",
event: { key: "k", code: "KeyK", ctrlKey: true },
@@ -489,17 +477,16 @@ describe("keyboard-shortcut help sections", () => {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"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 },
expectedKeys: {
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["ctrl", "W"],
},
},
@@ -292,24 +292,11 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Tab index jump ---
{
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
action: "workspace.tab.navigate.index",
combo: "Cmd+Alt+Digit",
when: { mac: true, desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
label: "Jump to tab",
keys: ["mod", "alt", "1-9"],
},
},
{
id: "workspace-tab-navigate-index-alt-digit-desktop",
action: "workspace.tab.navigate.index",
combo: "Alt+Digit",
when: { mac: false, desktop: true, commandCenter: false },
when: { desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
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;
}
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
const bytes = fillRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
}
export function polyfillCrypto(): void {
const g = globalThis as unknown as MutableGlobal;
const nativeGetRandomValues =
typeof g.crypto?.getRandomValues === "function"
? g.crypto.getRandomValues.bind(g.crypto)
: null;
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
// Hermes may not provide them in all configurations.
@@ -65,21 +47,17 @@ export function polyfillCrypto(): void {
g.crypto = {} as Crypto;
}
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
if (nativeGetRandomValues) {
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
}
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
if (typeof g.crypto.randomUUID !== "function") {
g.crypto.randomUUID = () => createUuidV4(fillRandomValues);
g.crypto.randomUUID = () =>
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
}
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(
latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[],
@@ -1743,41 +1734,6 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const oldPairingUrl = encodeOfferUrl({
v: 2,
serverId: "srv_offer",
daemonPublicKeyB64: "pk_test_offer",
relay: { endpoint: "relay.paseo.sh:443" },
});
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
{
id: "relay:wss:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_test_offer",
},
]);
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
+1 -3
View File
@@ -1506,12 +1506,10 @@ export class HostRuntimeStore {
}
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
useTls,
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
});
+1 -8
View File
@@ -720,16 +720,9 @@ function SettingsSidebar({
}, [hosts, localServerId]);
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
const padding = useWindowControlsPadding("sidebar");
const isDesktop = layout === "desktop";
const containerStyle = useMemo(
() => [
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
isDesktop ? { paddingTop: insets.top } : null,
],
[insets.top, isDesktop],
);
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
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 { useIsFocused } from "@react-navigation/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 * as Clipboard from "expo-clipboard";
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 type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -156,12 +158,10 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
@@ -1028,6 +1028,8 @@ function WorkspaceHeaderTitleBar({
);
}
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type PaneDirection = "left" | "right" | "up" | "down";
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 {
const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback;
@@ -1324,6 +1359,17 @@ function shouldShowWorkspaceExplorerSidebar(input: {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
}
function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
@@ -1331,108 +1377,6 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`;
}
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
toast: {
error: (message: string) => void;
show: (message: string) => void;
};
}
interface WorkspaceTerminalTabActions {
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
handleScriptTerminalSelected: (terminalId: string) => void;
handleWorkspacePathUnavailable: () => void;
handleTerminalCreateQueued: () => void;
}
function useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
if (!persistenceKey) {
return;
}
if (paneId) {
focusWorkspacePane(persistenceKey, paneId);
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error("Workspace path is not available yet");
}, [toast]);
const handleTerminalCreateQueued = useCallback(() => {
toast.show("Preparing workspace, opening terminal when ready...");
}, [toast]);
return {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
};
}
function useWorkspaceCheckoutStatus(input: {
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}) {
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
isRouteFocused: input.isRouteFocused,
client: input.client,
isConnected: input.isConnected,
workspaceDirectory: input.workspaceDirectory,
}),
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
input.normalizedServerId,
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error("Host is not connected");
}
return await input.client.getCheckoutStatus(input.workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
return { checkoutQuery, isCheckoutStatusLoading };
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -1461,6 +1405,7 @@ function WorkspaceScreenContent({
scopeKey: workspaceTerminalScopeKey,
});
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceAuthority = useMemo(
@@ -1485,19 +1430,12 @@ function WorkspaceScreenContent({
useProvidersSnapshot(normalizedServerId, {
enabled: isRouteFocused,
});
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
paneId?: string;
} | null>(null);
const canCreateTerminalNow = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const workspaceAgentVisibility = useStoreWithEqualityFn(
@@ -1511,56 +1449,167 @@ function WorkspaceScreenContent({
workspaceAgentVisibilityEqual,
);
const {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
} = useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
const terminalsQueryKey = useMemo(
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
[normalizedServerId, workspaceDirectory],
);
const terminalsQuery = useQuery({
queryKey: terminalsQueryKey,
enabled: canCreateTerminalNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
});
const {
createMutation: createTerminalMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation: killTerminalMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput: pendingTerminalCreateInput,
query: terminalsQuery,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
} = useWorkspaceTerminals({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreated: handleTerminalCreated,
onScriptTerminalSelected: handleScriptTerminalSelected,
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
onTerminalCreateQueued: handleTerminalCreateQueued,
const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
useEffect(() => {
setPendingScriptTerminalIds(
reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
);
}, [liveTerminalIds, terminalsDataUpdatedAt]);
const knownTerminalIds = useMemo(() => {
const terminalIds = new Set(liveTerminalIds);
for (const terminalId of pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}, [liveTerminalIds, pendingScriptTerminalIds]);
const scriptTerminalIds = useMemo(() => {
const terminalIds = new Set(pendingScriptTerminalIds.keys());
for (const script of workspaceDescriptor?.scripts ?? []) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
const standaloneTerminalIds = useMemo(
() =>
terminals
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id),
[scriptTerminalIds, terminals],
);
const createTerminalMutation = useMutation({
mutationFn: async (_input?: { paneId?: string }) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, input) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
const cwd = current?.cwd ?? workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
if (createdTerminal) {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
if (!workspaceKey) {
return;
}
if (input?.paneId) {
focusWorkspacePane(workspaceKey, input.paneId);
}
useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
}
},
});
const killTerminalMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
});
const { archiveAgent } = useArchiveAgent();
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
useEffect(() => {
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
return;
}
const unsubscribeChanged = client.on("terminals_changed", (message) => {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
const isCheckoutQueryEnabled = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
normalizedServerId,
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.getCheckoutStatus(workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
);
@@ -1569,6 +1618,30 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});
useEffect(() => {
if (!pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
const pendingInput = pendingTerminalCreateInput;
setPendingTerminalCreateInput(null);
createTerminalMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingTerminalCreateInput(null);
toast.error("Workspace path is not available yet");
}
}, [
canCreateTerminalNow,
createTerminalMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
pendingTerminalCreateInput,
toast,
]);
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading,
isError: checkoutQuery.isError,
@@ -1665,6 +1738,15 @@ function WorkspaceScreenContent({
return () => handler.remove();
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
);
@@ -1679,6 +1761,7 @@ function WorkspaceScreenContent({
[workspaceLayout],
);
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground,
);
@@ -1694,6 +1777,39 @@ function WorkspaceScreenContent({
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
return nextTerminalIds;
});
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
},
[
openWorkspaceTabFocused,
persistenceKey,
queryClient,
terminalsQuery.dataUpdatedAt,
terminalsQueryKey,
],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -2084,7 +2200,24 @@ function WorkspaceScreenContent({
[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(
(input?: { paneId?: string }) => {
@@ -2151,7 +2284,10 @@ function WorkspaceScreenContent({
return;
}
removeTerminalFromCache(terminalId);
queryClient.setQueryData<ListTerminalsPayload>(
terminalsQueryKey,
removeTerminalFromPayload(terminalId),
);
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
@@ -2161,16 +2297,18 @@ function WorkspaceScreenContent({
});
}
void killTerminalAsync(terminalId).catch(invalidateTerminals);
void killTerminalAsync(terminalId).catch(() => {
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
});
});
},
[
closeTab,
closeWorkspaceTabWithCleanup,
invalidateTerminals,
killTerminalAsync,
persistenceKey,
removeTerminalFromCache,
queryClient,
terminalsQueryKey,
],
);
@@ -1,7 +1,5 @@
import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
@@ -88,7 +86,7 @@ interface InsertSplitInternalInput {
targetPaneId: string;
tabId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
createNodeId: (prefix: "pane" | "group") => string;
}
interface InsertSplitInternalResult {
@@ -144,7 +142,7 @@ interface SplitPaneInLayoutInput {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number;
}
@@ -157,7 +155,7 @@ interface SplitPaneEmptyInLayoutInput {
layout: WorkspaceLayout;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number;
}
@@ -234,6 +232,14 @@ function normalizeTabIds(list: unknown): string[] {
return next;
}
function generateNodeId(prefix: "pane" | "group"): string {
const randomValue =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}_${randomValue}`;
}
function createPaneNode(input: {
id: string;
tabs?: WorkspaceTab[];
@@ -990,16 +996,13 @@ export function insertSplit(
targetPaneId: string,
tabId: string,
position: "left" | "right" | "top" | "bottom",
createNodeId: (
prefix: WorkspaceLayoutNodeIdPrefix,
) => string = defaultWorkspaceLayoutIds.createNodeId,
): SplitNode {
return insertSplitInternal({
root: asInternalNode(root),
targetPaneId,
tabId,
position,
createNodeId,
createNodeId: generateNodeId,
}).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,
collectAllPanes,
collectAllTabs,
createWorkspaceLayoutStore,
createDefaultLayout,
findPaneById,
findPaneContainingTab,
@@ -29,6 +28,7 @@ import {
insertSplit,
removePaneFromTree,
removeTabFromTree,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
} from "@/stores/workspace-layout-store";
@@ -36,40 +36,6 @@ import {
const SERVER_ID = "server-1";
const WORKSPACE_ID = "ws-main";
function createDeterministicWorkspaceLayoutIds() {
let values: string[] = [];
let fallbackIndex = 0;
function nextValue(): string {
const value = values.shift();
if (value) {
return value;
}
fallbackIndex += 1;
return `generated-${fallbackIndex}`;
}
return {
useValues: (nextValues: string[]) => {
values = nextValues.slice();
fallbackIndex = 0;
},
reset: () => {
values = [];
fallbackIndex = 0;
},
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
};
}
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
function useWorkspaceLayoutIds(...values: string[]) {
workspaceLayoutIds.useValues(values);
}
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
return {
tabId,
@@ -202,14 +168,13 @@ describe("workspace-layout-store helpers", () => {
describe("workspace-layout-store tree transforms", () => {
beforeEach(() => {
workspaceLayoutIds.reset();
vi.restoreAllMocks();
});
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
useWorkspaceLayoutIds(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
);
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
const root: SplitNode = {
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 nestedGroup = expectGroup(nextGroup.group.children[1]);
@@ -305,20 +270,22 @@ describe("workspace-layout-store tree transforms", () => {
describe("workspace-layout-store actions", () => {
beforeEach(() => {
workspaceLayoutIds.reset();
workspaceLayoutStore.setState({
useWorkspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
});
vi.restoreAllMocks();
});
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 store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -341,7 +308,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
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(secondTabId).toBe("file_/repo/worktree/b.ts");
@@ -355,14 +322,14 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground inserts a tab without stealing focus", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const setupTabId = store.openTabInBackground(workspaceKey, {
kind: "setup",
workspaceId: "ws-main",
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layout.root, "main")!;
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", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -388,7 +355,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/a.ts",
});
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layoutAfter.root, "main")!;
expect(duplicateTabId).toBe(firstTabId);
@@ -398,25 +365,27 @@ describe("workspace-layout-store actions", () => {
it("unfocuses and restores the previous focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const token = store.unfocusPane(workspaceKey);
expect(token).toBeTruthy();
expect(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull();
store.restorePaneFocus(workspaceKey, token!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main",
);
});
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 store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.splitPane(workspaceKey, {
@@ -430,14 +399,14 @@ describe("workspace-layout-store actions", () => {
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
store.restorePaneFocus(workspaceKey, token!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
});
it("waits for nested focus restorations before restoring", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const outerToken = store.unfocusPane(workspaceKey);
@@ -445,22 +414,22 @@ describe("workspace-layout-store actions", () => {
store.restorePaneFocus(workspaceKey, outerToken!);
expect(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull();
store.restorePaneFocus(workspaceKey, innerToken!);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main",
);
});
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
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(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", () => {
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777");
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"77777777-7777-7777-7777-777777777777",
);
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const newPaneId = store.splitPaneEmpty(workspaceKey, {
@@ -494,7 +465,7 @@ describe("workspace-layout-store actions", () => {
kind: "draft",
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(draftTabId).toBe("draft-split");
@@ -505,9 +476,11 @@ describe("workspace-layout-store actions", () => {
});
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 store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const fileTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -524,20 +497,22 @@ describe("workspace-layout-store actions", () => {
});
store.focusTab(workspaceKey, fileTabId!);
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main");
store.focusTab(workspaceKey, terminalTabId!);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
});
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212",
);
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
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 layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const splitPane = findPaneById(layout.root, splitPaneId);
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", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -575,7 +550,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/retargeted.ts",
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(draftTabId).toBe("draft-retarget");
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", () => {
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"55555555-5555-5555-5555-555555555555",
);
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const existingFileTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -613,7 +590,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
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(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", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const firstDraftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -648,7 +625,7 @@ describe("workspace-layout-store actions", () => {
kind: "agent",
agentId: "agent-1",
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstAgentTabId).toBe(firstDraftTabId);
expect(nextTabId).toBe(firstDraftTabId);
@@ -664,7 +641,7 @@ describe("workspace-layout-store actions", () => {
it("reorderTabs reorders tabs within the focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -680,7 +657,7 @@ describe("workspace-layout-store actions", () => {
});
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")).toEqual({
id: "main",
@@ -707,9 +684,11 @@ describe("workspace-layout-store actions", () => {
});
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 store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.openTabFocused(workspaceKey, {
@@ -733,7 +712,7 @@ describe("workspace-layout-store actions", () => {
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
store.focusPane(workspaceKey, "main");
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(layout.focusedPaneId).toBe("main");
@@ -757,9 +736,11 @@ describe("workspace-layout-store actions", () => {
});
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 store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -773,20 +754,22 @@ describe("workspace-layout-store actions", () => {
});
store.focusPane(workspaceKey, "main");
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main");
store.focusPane(workspaceKey, splitPaneId!);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
expect(layout.focusedPaneId).toBe(splitPaneId);
});
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"cccccccc-cccc-cccc-cccc-cccccccccccc",
);
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -800,7 +783,7 @@ describe("workspace-layout-store actions", () => {
});
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(layout.focusedPaneId).toBe("main");
@@ -808,19 +791,18 @@ describe("workspace-layout-store actions", () => {
});
it("splitPane enforces the maximum depth of four", () => {
useWorkspaceLayoutIds(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444",
"55555555-5555-5555-5555-555555555555",
"66666666-6666-6666-6666-666666666666",
"77777777-7777-7777-7777-777777777777",
"88888888-8888-8888-8888-888888888888",
);
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
@@ -849,7 +831,7 @@ describe("workspace-layout-store actions", () => {
position: "bottom",
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
@@ -858,9 +840,11 @@ describe("workspace-layout-store actions", () => {
});
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 store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const leftTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -877,7 +861,7 @@ describe("workspace-layout-store actions", () => {
});
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe(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", () => {
useWorkspaceLayoutIds(
"78787878-7878-7878-7878-787878787878",
"89898989-8989-8989-8989-898989898989",
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a",
);
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -918,7 +901,7 @@ describe("workspace-layout-store actions", () => {
});
store.closeTab(workspaceKey, secondTabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const rootGroup = expectGroup(layout.root);
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", () => {
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab");
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"abababab-abab-abab-abab-abababababab",
);
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -974,7 +959,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
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(duplicateTabId).toBe(secondTabId);
@@ -986,14 +971,13 @@ describe("workspace-layout-store actions", () => {
});
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
useWorkspaceLayoutIds(
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
"ffffffff-ffff-ffff-ffff-ffffffffffff",
"11111111-1111-1111-1111-111111111111",
);
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
@@ -1011,12 +995,12 @@ describe("workspace-layout-store actions", () => {
position: "right",
});
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitGroup = expectGroup(splitRoot);
const nestedGroup = expectGroup(splitGroup.group.children[1]);
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedGroup = expectGroup(resizedRoot);
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
@@ -1030,11 +1014,11 @@ describe("workspace-layout-store actions", () => {
it("closing the last tab keeps a single empty pane in the layout", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.closeTab(workspaceKey, tabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout).toEqual(createDefaultLayout());
});
@@ -1048,12 +1032,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(workspaceKey, "agent-1");
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[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
@@ -1061,13 +1045,13 @@ describe("workspace-layout-store actions", () => {
store.unpinAgent(workspaceKey, "agent-1");
state = workspaceLayoutStore.getState();
state = useWorkspaceLayoutStore.getState();
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
]);
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
@@ -1084,12 +1068,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(workspaceKey, "agent-1");
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[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
@@ -1097,13 +1081,13 @@ describe("workspace-layout-store actions", () => {
store.unhideAgent(workspaceKey, "agent-1");
state = workspaceLayoutStore.getState();
state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
]);
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
@@ -1112,9 +1096,11 @@ describe("workspace-layout-store actions", () => {
});
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 store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -1128,7 +1114,7 @@ describe("workspace-layout-store actions", () => {
});
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(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", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.setState((state) => ({
useWorkspaceLayoutStore.setState((state) => ({
...state,
layoutByWorkspace: {
...state.layoutByWorkspace,
@@ -1183,7 +1169,7 @@ describe("workspace-layout-store actions", () => {
},
}));
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
@@ -1193,7 +1179,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = collectAllTabs(layout.root);
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", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.setState((state) => ({
useWorkspaceLayoutStore.setState((state) => ({
...state,
hiddenAgentIdsByWorkspace: {
[workspaceKey]: new Set<string>(["agent-1"]),
},
}));
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
@@ -1231,13 +1217,13 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"],
@@ -1248,7 +1234,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
workspaceLayoutStore
useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1257,7 +1243,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1272,7 +1258,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
workspaceLayoutStore
useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1281,7 +1267,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1296,7 +1282,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
workspaceLayoutStore
useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1305,7 +1291,7 @@ describe("workspace-layout-store actions", () => {
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "child-agent");
store.reconcileTabs(workspaceKey, {
@@ -1318,11 +1304,11 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
const state = workspaceLayoutStore.getState();
const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"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", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
const scriptTabId = store.openTabFocused(workspaceKey, {
kind: "terminal",
@@ -1349,8 +1335,8 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
});
@@ -1358,7 +1344,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
@@ -1369,44 +1355,46 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
it("explicitly opening an agent tab clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "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.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
});
it("pinning an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
expect(
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
).toBeDefined();
store.pinAgent(workspaceKey, "agent-1");
const state = workspaceLayoutStore.getState();
const state = useWorkspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
});
it("retargeting a tab to an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const store = useWorkspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
const state = workspaceLayoutStore.getState();
const state = useWorkspaceLayoutStore.getState();
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 type { DaemonClientConfig } from "@server/client/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
import { describe, expect, it, vi, beforeEach } from "vitest";
class FakeDaemonClient implements DaemonProbeClient {
readonly lastError: string | null;
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
let nextConnectError: Error | null = null;
let nextLastError: string | null = null;
constructor(
private readonly probe: FakeDaemonProbe,
readonly config: DaemonClientConfig,
) {
this.lastError = probe.nextLastError;
}
class MockDaemonClient {
public lastError: string | null = nextLastError;
private lastServerInfo = {
status: "server_info" as const,
serverId: "srv_probe_test",
hostname: "probe-host" as string | null,
version: "0.0.0",
};
async connect(): Promise<void> {
if (this.probe.nextConnectError) {
throw this.probe.nextConnectError;
constructor(config: { clientId?: string; url?: string; password?: string }) {
createdConfigs.push(config);
}
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(): () => void {
return () => undefined;
}
async connect(): Promise<void> {
if (nextConnectError) {
throw nextConnectError;
}
return;
}
getLastServerInfoMessage() {
return this.lastServerInfo;
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
}
}
getLastServerInfoMessage() {
return {
serverId: "srv_probe_test",
hostname: "probe-host",
};
}
async close(): Promise<void> {
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
nextConnectError: Error | null = null;
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
return {
MockDaemonClient,
createdConfigs,
setNextConnectFailure: (error: Error, lastError: string | null) => {
nextConnectError = error;
nextLastError = lastError;
},
resolveAppVersion: () => null,
createLocalTransportFactory: () => null,
buildLocalTransportUrl: ({ transportType, transportPath }) =>
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
createClient: (config) => {
const client = new FakeDaemonClient(this, config);
this.createdClients.push(client);
return client;
reset: () => {
createdConfigs.length = 0;
nextConnectError = null;
nextLastError = null;
},
};
});
failNextConnection(error: Error, lastError: string | null): void {
this.nextConnectError = error;
this.nextLastError = lastError;
}
const clientIdMock = vi.hoisted(() => ({
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
}));
createdConfigs(): DaemonClientConfig[] {
return this.createdClients.map((client) => client.config);
}
}
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
buildLocalDaemonTransportUrl: vi.fn(
({
transportType,
transportPath,
}: {
transportType: "socket" | "pipe";
transportPath: string;
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
),
}));
describe("test-daemon-connection connectToDaemon", () => {
let probe: FakeDaemonProbe;
beforeEach(() => {
daemonClientMock.reset();
clientIdMock.getOrCreateClientId.mockClear();
vi.stubGlobal("__DEV__", false);
probe = new FakeDaemonProbe();
});
it("reuses the app clientId for direct connections", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const first = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
probe.deps,
);
const mod = await import("./test-daemon-connection");
const first = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
await first.client.close();
const second = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
probe.deps,
);
const second = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
await second.client.close();
const [firstConfig, secondConfig] = probe.createdConfigs();
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
expect(firstConfig?.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 () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
},
undefined,
probe.deps,
);
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
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 () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
probe.deps,
);
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
});
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 () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const tlsResult = await connectToDaemon(
const mod = await import("./test-daemon-connection");
const tlsResult = await mod.connectToDaemon(
{
id: "relay:wss:[::1]:443",
type: "relay",
@@ -145,11 +157,10 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey",
},
{ serverId: "srv_probe_test" },
probe.deps,
);
await tlsResult.client.close();
const plainResult = await connectToDaemon(
const plainResult = await mod.connectToDaemon(
{
id: "relay:relay.paseo.sh:443",
type: "relay",
@@ -158,52 +169,43 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey",
},
{ serverId: "srv_probe_test" },
probe.deps,
);
await plainResult.client.close();
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
});
it("surfaces auth rejection as an incorrect password", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(
new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)",
);
await expect(
connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
},
undefined,
probe.deps,
),
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
}),
).rejects.toMatchObject({
message: "Incorrect password",
});
});
it("keeps generic transport failures generic when a password was supplied", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(new Error("Transport error"), "Transport error");
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
await expect(
connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
probe.deps,
),
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
}),
).rejects.toMatchObject({
message: "Transport error",
});
@@ -13,34 +13,6 @@ import {
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient {
readonly lastError: string | null;
connect(): Promise<void>;
close(): Promise<void>;
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
}
interface LocalTransportUrlInput {
transportType: "socket" | "pipe";
transportPath: string;
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient;
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
getClientId: getOrCreateClientId,
resolveAppVersion,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config),
};
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -97,17 +69,13 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> {
const clientId = await deps.getClientId();
const localTransportFactory = deps.createLocalTransportFactory();
const clientId = await getOrCreateClientId();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,
appVersion: deps.resolveAppVersion() ?? undefined,
appVersion: resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...((connection.type === "directSocket" || connection.type === "directPipe") &&
@@ -119,7 +87,7 @@ export async function buildClientConfig(
if (connection.type === "directSocket" || connection.type === "directPipe") {
return {
...base,
url: deps.buildLocalTransportUrl({
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
@@ -152,23 +120,10 @@ export async function buildClientConfig(
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectAndProbe<TClient extends DaemonProbeClient>(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"createClient"
> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const client = deps.createClient(config);
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const client = new DaemonClient(config);
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
(resolve, reject) => {
const timer = setTimeout(() => {
void client.close().catch(() => undefined);
@@ -228,20 +183,10 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
return connection.type === "relay" ? 10_000 : 6_000;
}
export function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectToDaemon<TClient extends DaemonProbeClient>(
connection: HostConnection,
options: ProbeOptions | undefined,
deps: DaemonConnectionDependencies<TClient>,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export async function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId, deps);
return connectAndProbe(config, resolveTimeout(connection, options), deps);
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId);
return connectAndProbe(config, resolveTimeout(connection, options));
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.75",
"version": "0.1.71",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.75",
"@getpaseo/server": "0.1.71",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -1,67 +1,32 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
type DaemonLaunchRuntime,
type DetachedDaemonProcess,
startLocalDaemonDetached,
startLocalDaemonForeground,
} from "./local-daemon.js";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
type RecordedDaemonLaunch =
| {
mode: "detached";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
}
| {
mode: "foreground";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
};
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
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;
wasUnreferenced = false;
unref(): void {
this.wasUnreferenced = true;
}
}
class FakeDaemonRuntime implements DaemonLaunchRuntime {
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
readonly daemonProcess = new FakeDaemonProcess();
foregroundStatus = 0;
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
resolveRunnerEntry(): string {
return this.runnerEntry;
}
resolveHome(env: NodeJS.ProcessEnv): string {
return env.PASEO_HOME ?? "/tmp/paseo";
}
spawnDetached(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
): DetachedDaemonProcess {
this.recordedLaunches.push({ mode: "detached", command, args, options });
return this.daemonProcess;
}
spawnForeground(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
) {
this.recordedLaunches.push({ mode: "foreground", command, args, options });
return { status: this.foregroundStatus, error: undefined };
}
unref = vi.fn();
}
function expectSupervisorLaunch(argv: string[]): void {
@@ -76,59 +41,59 @@ function expectSupervisorLaunch(argv: string[]): void {
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
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(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-relay");
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const runtime = new FakeDaemonRuntime();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const resultPromise = startLocalDaemonDetached(
{ home: "/tmp/paseo-test", mcp: false },
runtime,
);
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("detached");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-mcp");
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
});
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(
{
home: "/tmp/paseo-test",
relayUseTls: true,
},
runtime,
);
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({
home: "/tmp/paseo-test",
relayUseTls: true,
});
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--relay-use-tls");
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
string,
string[],
{ env?: NodeJS.ProcessEnv },
];
expect(argv).toContain("--relay-use-tls");
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
});
});
@@ -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 { createRequire } from "node:module";
import path from "node:path";
@@ -68,28 +68,6 @@ interface ProcessExitDetails {
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
export interface ForegroundDaemonProcessResult {
status: number | null;
error?: Error;
}
export interface DaemonLaunchRuntime {
resolveRunnerEntry(): string;
resolveHome(env: NodeJS.ProcessEnv): string;
spawnDetached(
command: string,
args: string[],
options: Parameters<typeof spawnProcess>[2],
): DetachedDaemonProcess;
spawnForeground(
command: string,
args: string[],
options: Parameters<typeof spawnSync>[2],
): ForegroundDaemonProcessResult;
}
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log";
@@ -100,13 +78,6 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
spawnDetached: spawnProcess,
spawnForeground: spawnSync,
};
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
@@ -424,18 +395,17 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
export async function startLocalDaemonDetached(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): Promise<DetachedStartResult> {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const childEnv = buildChildEnv(options);
const paseoHome = runtime.resolveHome(childEnv);
const paseoHome = resolvePaseoHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const child = runtime.spawnDetached(
const child = spawnProcess(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
@@ -491,17 +461,14 @@ export async function startLocalDaemonDetached(
};
}
export function startLocalDaemonForeground(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): number {
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const childEnv = buildChildEnv(options);
const result = runtime.spawnForeground(
const result = spawnSync(
process.execPath,
[...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 { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
import {
buildCreateWorktreeInput,
toDaemonCreateInput,
type WorktreeCreateOptions,
} from "./create.js";
const REPO = "/tmp/repo";
function build(options: WorktreeCreateOptions): unknown {
try {
return buildCreateWorktreeRequest(options, REPO);
return buildCreateWorktreeInput(options, REPO);
} catch (err) {
return err;
}
}
describe("buildCreateWorktreeRequest", () => {
describe("buildCreateWorktreeInput", () => {
it("requires --mode", () => {
expect(build({})).toMatchObject({ code: "MISSING_MODE" });
});
@@ -24,20 +28,17 @@ describe("buildCreateWorktreeRequest", () => {
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({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
target: { mode: "branch-off", newBranch: "feature-x" },
});
});
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({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
});
});
@@ -45,11 +46,10 @@ describe("buildCreateWorktreeRequest", () => {
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({
cwd: REPO,
action: "checkout",
refName: "feat/x",
target: { mode: "checkout-branch", branch: "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({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
});
});
});
describe("toDaemonCreateInput", () => {
it("maps branch-off without base", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
});
});
it("maps branch-off with base ref", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
});
});
it("maps checkout-branch to action=checkout + refName", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-branch", branch: "feat/x" },
}),
).toEqual({
cwd: REPO,
action: "checkout",
refName: "feat/x",
});
});
it("maps checkout-pr to action=checkout + githubPrNumber", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
}),
).toEqual({
cwd: REPO,
action: "checkout",
githubPrNumber: 42,
+112 -4
View File
@@ -2,8 +2,12 @@ import path from "node:path";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
export interface WorktreeCreateResult {
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 {
return details ? { code, message, details } : { code, message };
}
@@ -29,7 +137,7 @@ export async function runCreateCommand(
_command: Command,
): Promise<SingleResult<WorktreeCreateResult>> {
const cwd = options.cwd ?? process.cwd();
const request = buildCreateWorktreeRequest(options, cwd);
const parsed = buildCreateWorktreeInput(options, cwd);
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
@@ -45,7 +153,7 @@ export async function runCreateCommand(
}
try {
const response = await client.createPaseoWorktree(request);
const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
const workspace = response.workspace;
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));
}
async function waitForLoopInList(
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
id: string,
) {
for (let attempt = 0; attempt < 20; attempt++) {
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
if (listedJson.some((item: { id: string }) => item.id === id)) {
return listedJson;
}
await sleep(250);
}
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
return JSON.parse(listed.stdout);
}
console.log("=== Loop And Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
@@ -163,10 +143,13 @@ try {
const runJson = JSON.parse(run.stdout);
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(
listedJson.some((item: { id: string }) => item.id === runJson.id),
JSON.stringify(listedJson),
listed.stdout,
);
async function pollStatus(attempt: number): Promise<string> {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.75",
"version": "0.1.71",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -1,4 +1,3 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
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", () => {
beforeEach(() => {
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
@@ -194,61 +165,4 @@ describe("daemon-manager commands", () => {
"--json",
]);
});
it("uses a reachable daemon when the PID file is stale", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 7675,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.2",
desktopManaged: true,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: "dev-host",
pid: null,
home: "/tmp/paseo-home",
version: "1.2.2",
desktopManaged: false,
error: null,
});
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("bounds captured daemon startup output", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
});
mocks.spawnProcess.mockImplementation(() => {
const child = createMockChildProcess();
scheduleFailedStartupOutput(child);
return child;
});
const handlers = createDaemonCommandHandlers();
let thrown: Error | null = null;
try {
await handlers.start_desktop_daemon();
} catch (error) {
thrown = error instanceof Error ? error : new Error(String(error));
}
expect(thrown).toBeInstanceOf(Error);
const message = thrown?.message ?? "";
expect(message).toContain("Daemon failed to start: exit code 1");
expect(message).toContain("output truncated to the last 65536 chars");
expect(message).toContain("stdout-tail");
expect(message).toContain("stderr-tail");
expect(message.length).toBeLessThan(150_000);
});
});
+15 -58
View File
@@ -43,7 +43,6 @@ const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150;
const DETACHED_STARTUP_GRACE_MS = 1200;
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -70,11 +69,6 @@ interface DesktopPairingOffer {
qr: string | null;
}
interface StartupOutputCapture {
text: string;
truncated: boolean;
}
function parseReleaseChannel(
args: Record<string, unknown> | 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 {
log.info("[desktop daemon]", message, {
pid: process.pid,
@@ -227,28 +197,17 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
unknown
>;
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
const connectedDaemon =
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
status = "running";
} else if (localDaemon === "unresponsive") {
status = "errored";
}
const running = localDaemon === "running";
return {
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
status,
status: running ? "running" : "stopped",
listen: typeof payload.listen === "string" ? payload.listen : null,
hostname:
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
pid: running && typeof payload.pid === "number" ? payload.pid : null,
home,
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
desktopManaged: payload.desktopManaged === true,
error: null,
};
} catch (error) {
@@ -289,17 +248,15 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error },
stdout: StartupOutputCapture,
stderr: StartupOutputCapture,
stdout: string,
stderr: string,
): Error {
const reason = result.error
? result.error.message
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
const parts = [`Daemon failed to start: ${reason}`];
const formattedStderr = formatStartupOutput(stderr).trim();
const formattedStdout = formatStartupOutput(stdout).trim();
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
const logs = tailFile(logFilePath(), 15);
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
return new Error(parts.join("\n\n"));
@@ -380,13 +337,13 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = createStartupOutputCapture();
let stderr = createStartupOutputCapture();
let stdout = "";
let stderr = "";
child.stdout!.on("data", (data: Buffer) => {
stdout = appendStartupOutput(stdout, data);
stdout += data.toString();
});
child.stderr!.on("data", (data: Buffer) => {
stderr = appendStartupOutput(stderr, data);
stderr += data.toString();
});
logDesktopDaemonLifecycle("detached spawn returned", {
@@ -424,8 +381,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
logDesktopDaemonLifecycle("detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly: result.exitedEarly,
stdout: formatStartupOutput(stdout).slice(0, 2000),
stderr: formatStartupOutput(stderr).slice(0, 2000),
stdout: stdout.slice(0, 2000),
stderr: stderr.slice(0, 2000),
...(result.exitedEarly
? {
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";
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
export function isAllowedExternalUrl(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
try {
const url = new URL(value);
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol);
} catch {
return false;
}
}
export function registerOpenerHandlers(): void {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => {
if (!isAllowedExternalUrl(url)) {
throw new Error("Unsupported external URL");
}
ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
await shell.openExternal(url);
});
}
-1
View File
@@ -62,7 +62,6 @@ function resolveShellEnv(): Record<string, string> | undefined {
const result = spawnSync(shell, [...shellArgs, command], {
encoding: "utf8",
timeout: RESOLVE_TIMEOUT_MS,
windowsHide: true,
env: {
...shellEnv,
ELECTRON_RUN_AS_NODE: "1",
-2
View File
@@ -191,7 +191,6 @@ if (forcedUserDataDir) {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim();
devWorktreeName = path.basename(topLevel);
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
@@ -201,7 +200,6 @@ if (forcedUserDataDir) {
cwd: topLevel,
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim(),
);
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.75",
"version": "0.1.71",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.75",
"version": "0.1.71",
"files": [
"dist"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.75",
"version": "0.1.71",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.75",
"version": "0.1.71",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -58,14 +58,14 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.75",
"@getpaseo/relay": "0.1.75",
"@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.71",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -10,9 +10,12 @@ import {
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
interface DaemonRunnerConfig {
devMode: boolean;
workerArgs: string[];
@@ -74,6 +77,28 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
return existsSync(runnerPath) ? runnerPath : null;
}
function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
@@ -88,7 +113,7 @@ async function main(): Promise<void> {
const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
try {
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 { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -88,57 +87,6 @@ async function runSupervisorFixture(options: {
}
describe("supervisor durable logging", () => {
test("resolves rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(paseoHome, {}, {});
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "10m", maxFiles: 3 },
});
});
test("lets persisted rotation override env rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{
log: {
file: {
path: "logs/daemon.log",
rotate: { maxSize: "25m", maxFiles: 4 },
},
},
},
{
PASEO_LOG_ROTATE_SIZE: "200m",
PASEO_LOG_ROTATE_COUNT: "12",
},
);
expect(logFile).toEqual({
path: path.resolve(paseoHome, "logs", "daemon.log"),
rotate: { maxSize: "25m", maxFiles: 4 },
});
});
test("uses env rotation when persisted rotation is absent", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{},
{
PASEO_LOG_ROTATE_SIZE: "50m",
PASEO_LOG_ROTATE_COUNT: "8",
},
);
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "50m", maxFiles: 8 },
});
});
test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({
workerSource: `
@@ -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",
});
expect(client.lastLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},
@@ -1180,7 +1179,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
},
});
expect(client.lastResumeLaunchContext).toEqual({
agentId: resumed.id,
env: {
PASEO_AGENT_ID: resumed.id,
},
@@ -1285,7 +1283,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
});
expect(client.lastCreateLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},
@@ -1296,7 +1293,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
});
expect(client.lastResumeLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},
+65 -218
View File
@@ -10,33 +10,31 @@ import type { Logger } from "pino";
import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
type AgentRunOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentSlashCommand,
AgentMode,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPersistenceHandle,
AgentPromptInput,
AgentProvider,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import {
InMemoryAgentTimelineStore,
@@ -68,6 +66,7 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
};
type TimeoutResult = "completed" | "timed_out";
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface TimeoutOptions {
operation: Promise<void>;
@@ -1006,28 +1005,18 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
},
"agent.manager.close.start",
"closeAgent: start",
);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
await agent.session.close();
this.timelineStore.delete(agentId);
await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
agentId,
provider: closedAgent.provider,
sessionId: closedAgent.persistence?.sessionId ?? undefined,
},
"agent.manager.close.complete",
);
this.logger.trace({ agentId }, "closeAgent: completed");
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
@@ -1082,7 +1071,19 @@ export class AgentManager {
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const registry = this.requireRegistry();
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);
@@ -1258,7 +1259,19 @@ export class AgentManager {
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 this.archiveNativeSessionBestEffort(record.provider, record.persistence);
@@ -1481,28 +1494,22 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options),
},
"agent.manager.stream.request",
"streamAgent: requested",
);
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
},
"agent.manager.stream.reject",
"streamAgent: rejected because a foreground run is already in flight",
);
throw new Error(`Agent ${agentId} already has an active run`);
}
@@ -1539,13 +1546,10 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
},
"agent.manager.stream.start",
"streamAgent: started",
);
turnStream = this.foregroundRuns.createTurnStream(turnId);
@@ -1597,14 +1601,11 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: mutableAgent.lifecycle,
terminalError,
pendingReplacement: mutableAgent.pendingReplacement,
},
"agent.manager.finalize",
"finalizeForegroundTurn: applying terminal state",
);
if (!shouldHoldBusyForReplacement) {
this.touchUpdatedAt(mutableAgent);
@@ -2394,16 +2395,6 @@ export class AgentManager {
}
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.enqueue",
);
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
@@ -2415,16 +2406,6 @@ export class AgentManager {
if (current.session == null) {
return;
}
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: current.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.dequeue",
);
await this.dispatchSessionEvent(current, event);
return;
})
@@ -2448,19 +2429,8 @@ export class AgentManager {
agent: ActiveManagedAgent,
event: AgentStreamEvent,
): Promise<void> {
const turnId = getAgentStreamEventTurnId(event);
const turnId = (event as { turnId?: string }).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);
@@ -2471,18 +2441,6 @@ export class AgentManager {
this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
terminal: isTurnTerminalEvent(event),
});
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
notifiedWaiterCount: matchingWaiters.length,
terminal: isTurnTerminalEvent(event),
event,
},
"agent.manager.notify_waiters",
);
}
private async resolveInitialPersistedTitle(
@@ -2595,7 +2553,7 @@ export class AgentManager {
}
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
const turnId = getAgentStreamEventTurnId(event);
const turnId = (event as { turnId?: string }).turnId;
if (turnId == null) {
return;
}
@@ -2606,16 +2564,6 @@ export class AgentManager {
}
this.foregroundRuns.notifyAgentWaiters(agent, event);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.notify_waiters.coalesced",
);
}
private async handleStreamEvent(
@@ -2623,9 +2571,8 @@ export class AgentManager {
event: AgentStreamEvent,
options?: HandleStreamEventOptions,
): Promise<boolean> {
const eventTurnId = getAgentStreamEventTurnId(event);
const eventTurnId = (event as { turnId?: string }).turnId;
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
if (
eventTurnId &&
isTurnTerminalEvent(event) &&
@@ -2638,7 +2585,6 @@ export class AgentManager {
if (!options?.fromHistory) {
this.touchUpdatedAt(agent);
if (this.agentStreamCoalescer.handle(agent.id, event)) {
this.traceCoalescerBuffered(agent, event, eventTurnId);
return false;
}
this.agentStreamCoalescer.flushFor(agent.id);
@@ -2666,71 +2612,9 @@ export class AgentManager {
this.dispatchStream(agent.id, event);
}
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
return flags.shouldNotifyWaiters;
}
private traceHandleStreamEventStart(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
isForegroundEvent: boolean,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
isForegroundEvent,
event,
},
"agent.manager.handle_stream_event.start",
);
}
private traceCoalescerBuffered(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.coalescer.buffer",
);
}
private traceHandleStreamEventEnd(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
flags: StreamEventFlags,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
shouldDispatchEvent: flags.shouldDispatchEvent,
shouldNotifyWaiters: flags.shouldNotifyWaiters,
event,
},
"agent.manager.handle_stream_event.end",
);
}
private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent;
event: AgentStreamEvent;
@@ -2887,13 +2771,11 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"agent.manager.turn.completed",
"handleStreamEvent: turn_completed",
);
agent.lastUsage = event.usage;
agent.lastError = undefined;
@@ -2920,9 +2802,6 @@ export class AgentManager {
this.logger.warn(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
@@ -2963,14 +2842,11 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"agent.manager.turn.canceled",
"handleStreamEvent: turn_canceled",
);
if (!isForegroundEvent && !agent.pendingReplacement) {
agent.lifecycle = "idle";
@@ -2991,13 +2867,11 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"agent.manager.turn.started",
"handleStreamEvent: turn_started",
);
if (!isForegroundEvent) {
agent.lifecycle = "running";
@@ -3143,20 +3017,6 @@ export class AgentManager {
this.syncFeaturesFromSession(agent);
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
persist: options?.persist !== false,
},
"agent.manager.emit_state",
);
this.dispatch({
type: "agent_state",
agent: { ...agent },
@@ -3284,18 +3144,6 @@ export class AgentManager {
event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string },
): void {
const agent = this.agents.get(agentId);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
metadata,
event,
},
"agent.manager.dispatch_stream",
);
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
}
@@ -3391,7 +3239,6 @@ export class AgentManager {
private buildLaunchContext(agentId: string): AgentLaunchContext {
return {
agentId,
env: {
PASEO_AGENT_ID: agentId,
},
@@ -17,19 +17,6 @@ export function startAgentRun(
logger: Logger,
options?: StartAgentRunOptions,
): { outOfBand: boolean } {
const snapshot = agentManager.getAgent(agentId);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
turnId: snapshot?.activeForegroundTurnId ?? undefined,
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options?.runOptions),
replaceRunning: Boolean(options?.replaceRunning),
},
"agent.session.start_stream.request",
);
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint.
@@ -41,38 +28,12 @@ export function startAgentRun(
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
: agentManager.streamAgent(agentId, prompt, runOptions);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
shouldReplace,
},
"agent.session.start_stream.iterator_returned",
);
void (async () => {
try {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
}
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
},
"agent.session.iterator.drained",
);
} catch (error) {
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
err: error,
},
"agent.session.iterator.error",
);
logger.error({ err: error, agentId }, "Agent stream failed");
}
})();
@@ -358,10 +358,6 @@ export type AgentStreamEvent =
timestamp: string;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
return "turnId" in event ? event.turnId : undefined;
}
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
export type AgentPermissionUpdate = AgentMetadata;
@@ -480,7 +476,6 @@ export interface AgentSessionConfig {
}
export interface AgentLaunchContext {
agentId?: string;
env?: Record<string, string>;
}
@@ -1,6 +1,6 @@
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 {
turnId: string;
@@ -105,7 +105,7 @@ export class ForegroundRunState {
event: AgentStreamEvent,
options?: { terminal?: boolean },
): void {
const waiters = this.getMatchingWaiters(agent, getAgentStreamEventTurnId(event));
const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
}
@@ -226,3 +226,7 @@ function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
pendingRun.settled = true;
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], {
encoding: "utf8",
env: createProviderEnv({ baseEnv: process.env }),
windowsHide: true,
}).trim();
const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
const candidate = lines.at(-1)?.trim() ?? null;
@@ -11,7 +11,6 @@ export interface AgentModeVisuals {
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
AgentModeVisuals & {
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
isUnattended?: boolean;
};
@@ -97,9 +96,9 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
colorTier: "planning",
},
{
id: "allow-all",
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,
@@ -67,21 +67,10 @@ export interface BuildProviderRegistryOptions {
isDev?: boolean;
}
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService"
> {
customProvider?: {
id: string;
label: string;
extends: string;
};
}
type ProviderClientFactory = (
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
options?: ProviderClientFactoryOptions,
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
) => AgentClient;
interface ResolvedProvider {
@@ -103,7 +92,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
codex: (logger, runtimeSettings, options) =>
new CodexAppServerAgentClient(logger, runtimeSettings, {
workspaceGitService: options?.workspaceGitService,
customProvider: options?.customProvider,
}),
copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({
@@ -522,11 +510,10 @@ function addDerivedProviders(
continue;
}
const baseProviderId = override.extends;
const baseProvider = resolvedProviders.get(baseProviderId);
const baseProvider = resolvedProviders.get(override.extends);
if (!baseProvider) {
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),
);
const baseDefinition = baseProvider.definition;
const baseFactory = getProviderClientFactory(baseProviderId);
const baseFactory = getProviderClientFactory(override.extends);
resolvedProviders.set(providerId, {
definition: createDerivedDefinition(providerId, baseDefinition, override),
@@ -543,15 +530,8 @@ function addDerivedProviders(
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
derivedFromProviderId: baseProviderId,
createBaseClient: (logger) =>
baseFactory(logger, mergedRuntimeSettings, {
customProvider: {
id: providerId,
label: override.label ?? providerId,
extends: baseProviderId,
},
}),
derivedFromProviderId: override.extends,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
});
}
}
@@ -21,15 +21,6 @@ import {
resolveACPModeSelection,
resolveACPModelSelection,
} from "./acp-agent.js";
import {
COPILOT_ALLOW_ALL_MODE_ID,
COPILOT_MODES,
beforeCopilotModeWriter,
transformCopilotConfigOptions,
transformCopilotModeId,
transformCopilotSessionResponse,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -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 {
if (category === "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 () => {
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));
const session = createSessionWithConfig({}, logger);
const setSessionConfigOption = vi.fn(async () => ({
@@ -653,7 +577,7 @@ describe("ACPAgentSession Zed parity", () => {
expect(await validSession.getCurrentMode()).toBe("default");
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));
const invalidSession = createSessionWithConfig(
{ 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({
provider: "cursor-acp",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
provider: "copilot",
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
});
const events: Array<{ type: string; request?: { id: string } }> = [];
const permissionOptions: PermissionOption[] = [
@@ -909,126 +835,6 @@ describe("ACPAgentSession Zed parity", () => {
outcome: { outcome: "selected", optionId: "allow-once" },
});
});
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
const { internals } = prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
await internals.applyConfiguredOverrides();
unsubscribe();
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
});
test("accepts Copilot's legacy autopilot mode ID as Allow All", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig();
prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#autopilot");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
test("switching Copilot away from Allow All turns allow_all off before setting the ACP mode", async () => {
const setSessionConfigOption = vi.fn(async (input: { value: string }) => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption(input.value === "on" ? "on" : "off"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
prepareConfiguredOverrideSession(session, {
currentMode: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption(COPILOT_ALLOW_ALL_MODE_ID),
copilotAllowAllConfigOption("on"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#agent");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "off",
});
expect(setSessionMode).toHaveBeenCalledWith({
sessionId: "session-1",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
});
test("trusts Copilot allow_all config updates as the current mode source", async () => {
const session = createCopilotSessionWithConfig();
const internals = asInternals<ACPSessionInternals>(session);
const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
});
expect(events).toMatchObject([
{
type: "mode_changed",
provider: "copilot",
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: expect.arrayContaining([
expect.objectContaining({ id: COPILOT_ALLOW_ALL_MODE_ID, label: "Allow All" }),
]),
},
]);
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
});
describe("deriveModelDefinitionsFromACP", () => {
@@ -54,36 +54,35 @@ import {
} from "@agentclientprotocol/sdk";
import type { Logger } from "pino";
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModesOptions,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
type ToolCallDetail,
type ToolCallTimelineItem,
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModesOptions,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallDetail,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
createProviderEnvSpec,
@@ -227,13 +226,7 @@ interface ACPAgentClientOptions {
defaultModes?: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -252,13 +245,7 @@ interface ACPAgentSessionOptions {
defaultModes: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -266,7 +253,6 @@ interface ACPAgentSessionOptions {
) => Promise<void>;
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
@@ -351,26 +337,6 @@ interface ACPModelSelection {
hasAvailableModels: boolean;
}
export interface ACPProviderModeWriterContext {
connection: ClientSideConnection;
sessionId: string;
requestedModeId: string;
currentModeId: string | null;
selection: ACPModeSelection;
configOptions: SessionConfigOption[];
logger: Logger;
}
export interface ACPProviderModeWriteResult {
handled: boolean;
currentModeId?: string;
configOptions?: SessionConfigOption[];
}
export interface ACPBeforeModeWriteResult {
configOptions?: SessionConfigOption[];
}
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
if (!usage) {
return undefined;
@@ -499,17 +465,7 @@ export class ACPAgentClient implements AgentClient {
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -521,20 +477,13 @@ export class ACPAgentClient implements AgentClient {
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
this.logger = options.logger.child({
module: "agent",
provider: options.provider,
});
this.logger = options.logger.child({ module: "agent", provider: options.provider });
this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes ?? [];
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
@@ -555,14 +504,9 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -601,15 +545,10 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -808,16 +747,7 @@ export class ACPAgentClient implements AgentClient {
}
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
const transformed = this.sessionResponseTransformer
? this.sessionResponseTransformer(response)
: response;
if (!this.configOptionsTransformer || !transformed.configOptions) {
return transformed;
}
return {
...transformed,
configOptions: this.configOptionsTransformer(transformed.configOptions),
};
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
}
}
@@ -833,23 +763,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
private readonly agentId?: string;
private readonly launchEnv?: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
@@ -895,14 +814,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.defaultModes = options.defaultModes;
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.availableModes = options.defaultModes;
this.agentId = options.agentId;
this.launchEnv = options.launchEnv;
this.initialHandle = options.handle;
this.config = { ...config, provider: options.provider };
@@ -1154,25 +1068,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
const context = this.createProviderModeWriterContext(modeId, selection);
const providerResult = this.providerModeWriter
? await this.providerModeWriter(context)
: { handled: false };
if (providerResult.handled) {
this.currentMode = providerResult.currentModeId ?? modeId;
if (providerResult.configOptions) {
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
}
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
});
return;
}
if (selection.hasAvailableModes) {
if (!selection.availableMode) {
this.warnInvalidSelection(
@@ -1183,32 +1078,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
);
return;
}
} else {
const modeOption = selection.configOption;
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
}
if (this.beforeModeWriter) {
const beforeResult = await this.beforeModeWriter(context);
if (beforeResult?.configOptions) {
this.configOptions = this.transformConfigOptions(beforeResult.configOptions);
}
}
if (selection.hasAvailableModes) {
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
this.currentMode = modeId;
this.pushEvent({
@@ -1224,6 +1094,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId,
@@ -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> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
@@ -1418,9 +1281,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: string;
label: string;
}): string {
this.configOptions = this.transformConfigOptions(response.configOptions);
this.configOptions = response.configOptions;
const responseOption = findSelectConfigOption({
configOptions: this.configOptions,
configOptions: response.configOptions,
category,
id: configId,
});
@@ -1546,7 +1409,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
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();
let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ??
@@ -1576,31 +1439,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async sessionUpdate(params: SessionNotification): Promise<void> {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: params.sessionId,
rawEvent: params,
},
"provider.acp.raw_event",
);
if (params.sessionId !== this.sessionId) {
return;
}
const events = this.translateSessionUpdate(params.update);
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: this.activeForegroundTurnId ?? undefined,
rawEvent: params,
events,
},
"provider.acp.parsed_event",
);
if (this.replayingHistory) {
for (const event of events) {
if (event.type === "timeline") {
@@ -1779,7 +1622,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
? this.sessionResponseTransformer(response)
: response;
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
this.configOptions = transformed.configOptions ?? [];
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
@@ -1792,16 +1635,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
}
private transformConfigOptions(configOptions: SessionConfigOption[]): SessionConfigOption[] {
return this.configOptionsTransformer
? this.configOptionsTransformer(configOptions)
: configOptions;
}
private transformModeId(modeId: string): string | null {
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
}
private async applyConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) {
@@ -1939,11 +1772,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
this.currentMode = this.transformModeId(update.currentModeId);
this.currentMode = update.currentModeId;
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
this.configOptions = this.transformConfigOptions(update.configOptions);
this.configOptions = update.configOptions;
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const nextMode = modeInfo.currentModeId;
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
@@ -2031,16 +1864,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private pushEvent(event: AgentStreamEvent): void {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined,
event,
},
"provider.acp.event_emit",
);
for (const subscriber of this.subscribers) {
subscriber(event);
}
@@ -14,11 +14,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import {
getAgentStreamEventTurnId,
type AgentSession,
type AgentStreamEvent,
} from "../../agent-sdk-types.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.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 };
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[] {
return events.filter((e) => getAgentStreamEventTurnId(e) === turnId);
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
}
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
const turnStartedIds = events
.filter((e) => e.type === "turn_started" && hasTurnId(e))
.map((e) => e.turnId);
.map((e) => (e as EventWithTurnId).turnId);
for (const turnId of turnStartedIds) {
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
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]);
@@ -316,9 +313,8 @@ test("Test 4: Autonomous run", async () => {
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e): e is EventWithTurnId =>
e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
);
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
@@ -42,35 +42,34 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../prov
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
type AgentPermissionUpdate,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPermissionUpdate,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import {
createProviderEnv,
@@ -251,7 +250,6 @@ interface ClaudeAgentSessionOptions {
defaults?: { agents?: Record<string, AgentDefinition> };
runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
persistSession?: boolean;
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 {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1181,7 +1181,6 @@ export class ClaudeAgentClient implements AgentClient {
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
persistSession: options?.persistSession,
logger: this.logger,
@@ -1210,7 +1209,6 @@ export class ClaudeAgentClient implements AgentClient {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
logger: this.logger,
queryFactory: this.queryFactory,
@@ -1479,7 +1477,6 @@ class ClaudeAgentSession implements AgentSession {
private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean;
@@ -1529,11 +1526,10 @@ class ClaudeAgentSession implements AgentSession {
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
this.launchEnv = options.launchEnv;
this.agentId = options.agentId;
this.defaults = options.defaults;
this.runtimeSettings = options.runtimeSettings;
this.persistSession = options.persistSession;
this.logger = options.logger.child({ agentId: this.agentId });
this.logger = options.logger;
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
const handle = options.handle;
@@ -1873,16 +1869,13 @@ class ClaudeAgentSession implements AgentSession {
async close(): Promise<void> {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
claudeSessionId: this.claudeSessionId,
turnState: this.turnState,
hasQuery: Boolean(this.query),
hasInput: Boolean(this.input),
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
},
"provider.claude.session_close.start",
"Claude session close: start",
);
this.closed = true;
this.rejectAllPendingPermissions(new Error("Claude session closed"));
@@ -1917,13 +1910,8 @@ class ClaudeAgentSession implements AgentSession {
}
}
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnState: this.turnState,
},
"provider.claude.session_close.complete",
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
"Claude session close: completed",
);
}
@@ -2200,41 +2188,16 @@ class ClaudeAgentSession implements AgentSession {
label: string,
): Promise<void> {
if (!promise) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.skip",
);
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
return;
}
const startedAt = Date.now();
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.start",
);
this.logger.trace({ label }, "Claude query operation wait start");
try {
await withTimeout(promise, 3_000, "timeout");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
durationMs: Date.now() - startedAt,
},
"provider.claude.query_operation.settled",
{ label, durationMs: Date.now() - startedAt },
"Claude query operation settled",
);
} catch (error) {
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) => {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.exit_unexpected",
);
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
});
this.queryPumpPromise = pump;
@@ -2655,34 +2609,24 @@ class ClaudeAgentSession implements AgentSession {
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
if (!claudeDebug) {
return;
}
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
claudeSessionId: this.claudeSessionId,
messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : 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> => {
@@ -2795,18 +2739,16 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message);
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: turnId ?? undefined,
messageType: message.type,
identifiers,
rawEvent: message,
},
"provider.claude.parsed_event",
);
if (claudeDebug) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
turnId,
},
"Claude query pump: SDK message",
);
}
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
@@ -2874,6 +2816,7 @@ class ClaudeAgentSession implements AgentSession {
this.logger.warn(
{
claudeSessionId: this.claudeSessionId,
error: staleResumeError,
},
"Claude resumed session no longer exists; invalidating persisted session",
@@ -2903,15 +2846,7 @@ class ClaudeAgentSession implements AgentSession {
private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
},
"provider.claude.interrupt.no_query",
);
this.logger.trace("interruptActiveTurn: no query to interrupt");
return;
}
this.pendingInterruptAbort = true;
@@ -3520,16 +3455,6 @@ class ClaudeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.claude.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);
@@ -1,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 { __codexAppServerInternals } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex";
@@ -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 {
return {
@@ -43,36 +71,28 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
};
}
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): {
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
} {
function createSession(
configOverrides: Partial<AgentSessionConfig> = {},
): CodexFeaturesTestSession {
const config = createConfig(configOverrides);
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER },
null,
createTestLogger(),
async () => appServer.child,
) as CodexFeaturesTestSession;
return { session, appServer };
}
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
}> {
const harness = createSessionHarness(configOverrides);
await harness.session.connect();
harness.appServer.assertNoErrors();
return harness;
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as unknown as CodexFeaturesTestSession;
session.connected = true;
session.currentThreadId = "test-thread";
session.collaborationModes = TEST_COLLABORATION_MODES;
session.refreshResolvedCollaborationMode();
return session;
}
describe("Codex app-server provider features", () => {
test("features returns fast and plan toggles when supported", async () => {
const { session } = await createConnectedSession();
const session = createSession();
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 () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
test("features returns only plan toggle when model does not support fast mode", () => {
const session = createSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([
{
@@ -137,56 +157,49 @@ describe("Codex app-server provider features", () => {
});
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.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
});
expect(asInternals(session).serviceTier).toBe("fast");
});
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
const { session, appServer } = await createConnectedSession({
const session = createSession({
featureValues: { fast_mode: true },
});
await session.setFeature?.("fast_mode", false);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
expect(asInternals(session).serviceTier).toBeNull();
});
test("setFeature invalidates runtime info", async () => {
const { session } = await createConnectedSession();
test("setFeature invalidates cachedRuntimeInfo", async () => {
const session = createSession();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
extra: { collaborationMode: "Plan" },
});
await session.getRuntimeInfo();
expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
await session.setFeature?.("plan_mode", true);
await session.setFeature?.("fast_mode", true);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
extra: { collaborationMode: "Plan" },
});
expect(asInternals(session).cachedRuntimeInfo).toBeNull();
});
test("setFeature throws for unknown feature ids", async () => {
const { session } = createSessionHarness();
const session = createSession();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature",
);
});
test("constructor restores feature flags from config.featureValues", async () => {
const { session, appServer } = await createConnectedSession({
test("constructor restores feature flags from config.featureValues", () => {
const session = createSession({
featureValues: { fast_mode: true, plan_mode: true },
});
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(true);
expect(session.features).toEqual([
{
type: "toggle",
@@ -207,29 +220,41 @@ describe("Codex app-server provider features", () => {
value: true,
},
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
collaborationMode: expect.objectContaining({
mode: "plan",
}),
});
});
test("startTurn includes serviceTier when fast mode is enabled", async () => {
const { session, 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.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
});
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
serviceTier: "fast",
}),
expect.any(Number),
);
});
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.setModel("gpt-3.5-turbo");
@@ -245,23 +270,41 @@ describe("Codex app-server provider features", () => {
value: false,
},
]);
expect(asInternals(session).serviceTier).toBeNull();
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.not.objectContaining({
serviceTier: expect.anything(),
}),
expect.any(Number),
);
});
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.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
collaborationMode: expect.objectContaining({
mode: "plan",
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
}),
});
expect.any(Number),
);
});
});
@@ -1,11 +1,9 @@
import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import type {
AgentLaunchContext,
@@ -19,12 +17,11 @@ import {
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
waitForNextPermission,
} from "./codex/test-utils/fake-app-server.js";
createCodexAppServerChildProcessStub,
TestCodexAppServerPeer,
} from "./codex/test-utils/test-app-server-peer.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord {
name: string;
@@ -35,7 +32,6 @@ interface CollaborationModeRecord {
}
interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
loadPersistedHistory(): Promise<void>;
@@ -104,98 +100,32 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")");
}
type CapturedFakeCodexRecord = Record<string, unknown>;
async function runCustomCodexProviderTurn(
providerId: string,
baseUrl: string,
): Promise<CapturedFakeCodexRecord[]> {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-"));
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
writeFileSync(
fakeAppServerPath,
`
const fs = require("node:fs");
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
let buffer = "";
fs.appendFileSync(capturePath, JSON.stringify({
kind: "env",
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
}) + "\\n");
function record(method, params) {
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n");
}
function resultFor(method) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "skills/list") return { data: [] };
if (method === "config/read") return { config: {} };
if (method === "getUserSavedConfig") return { config: {} };
if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] };
if (method === "thread/start") return { thread: { id: "thread-1" } };
if (method === "turn/start") return {};
return {};
}
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
const message = JSON.parse(line);
record(message.method, message.params);
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
}
});
`,
function waitForNextPermission(
session: AgentSession,
events: AgentStreamEvent[],
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
const existing = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const registry = buildProviderRegistry(createTestLogger(), {
providerOverrides: {
[providerId]: {
extends: "codex",
label: "Custom Codex",
command: [process.execPath, fakeAppServerPath],
env: {
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: baseUrl,
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
},
},
},
});
const session = await registry[providerId].createClient(createTestLogger()).createSession({
provider: providerId,
cwd: "/workspace/project",
modeId: "auto",
model: "custom-model",
});
try {
await session.startTurn("use the custom endpoint");
return readFileSync(capturedRequestsPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord);
} finally {
await session.close();
rmSync(tempDir, { recursive: true, force: true });
if (existing) {
return Promise.resolve(existing);
}
}
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
const threadStart = records.find((record) => record.method === "thread/start");
const params = threadStart?.params as Record<string, unknown> | undefined;
return params?.config;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}
describe("Codex app-server provider", () => {
@@ -261,21 +191,15 @@ describe("Codex app-server provider", () => {
test("disposes an unresponsive app-server child with SIGKILL", async () => {
vi.useFakeTimers();
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
const child = createCodexAppServerChildProcessStub({ exitOnKill: false });
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
try {
const disposePromise = client.dispose();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(child.killSignals).toEqual(["SIGTERM"]);
await vi.advanceTimersByTimeAsync(2_000);
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
expect(child.killSignals).toEqual(["SIGTERM", "SIGKILL"]);
await vi.advanceTimersByTimeAsync(1_000);
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 () => {
const appServer = createFakeCodexAppServer({
const peer = new TestCodexAppServerPeer({
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
@@ -294,21 +218,28 @@ describe("Codex app-server provider", () => {
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
async () => peer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
appServer.assertNoErrors();
peer.assertNoErrors();
const permissionRequested = waitForNextPermission(session);
appServer.requestCommandApproval({
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
});
const permissionRequested = waitForNextPermission(session, events);
peer.writeRequest(
"item/commandExecution/requestApproval",
{
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
},
41,
);
const permissionEvent = await permissionRequested;
expect(permissionEvent.request).toMatchObject({
@@ -331,128 +262,14 @@ describe("Codex app-server provider", () => {
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({
decision: "accept",
await expect(peer.waitForResponse(41, { decision: "accept" })).resolves.toMatchObject({
id: 41,
result: { decision: "accept" },
});
appServer.assertNoErrors();
peer.assertNoErrors();
await session.close();
});
test("configures Codex app-server to use a custom provider base URL", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-iisb",
"https://custom-relay.example.com",
);
expect(capturedRequests[0]).toEqual({
kind: "env",
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: "https://custom-relay.example.com",
});
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-iisb",
model_providers: {
"codex-iisb": {
name: "Custom Codex",
base_url: "https://custom-relay.example.com/v1",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
wire_api: "responses",
},
},
});
});
test("does not append v1 twice for custom Codex provider base URLs", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-custom",
"https://custom-relay.example.com/v1/",
);
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-custom",
model_providers: {
"codex-custom": expect.objectContaining({
base_url: "https://custom-relay.example.com/v1",
}),
},
});
});
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
},
"thread/start": () => {
threadRequests.push("thread/start");
return { thread: { id: "replacement-empty-thread-id" } };
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
getUserSavedConfig: () => {
threadRequests.push("getUserSavedConfig");
return { config: {} };
},
"config/read": () => {
threadRequests.push("config/read");
return { config: {} };
},
"model/list": () => {
threadRequests.push("model/list");
return {
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
};
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
Promise.resolve(false);
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const outcome = await Promise.race([
provider
.resumeSession({
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
})
.then(
() => "resolved" as const,
(error) => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"no rollout found for thread id archived-thread-id",
);
return "rejected" as const;
},
),
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
]);
if (outcome === "timed_out") {
appServer.child.kill("SIGTERM");
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume"]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
const cwd = path.join(tempDir, "repo", "packages", "app");
@@ -463,8 +280,12 @@ describe("Codex app-server provider", () => {
path.join(repoSkillDir, "SKILL.md"),
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
);
const resolvedRepoRoots: string[] = [];
const workspaceGitService = {
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")),
resolveRepoRoot: async (pathToResolve: string) => {
resolvedRepoRoots.push(pathToResolve);
return path.join(tempDir, "repo");
},
};
try {
@@ -475,7 +296,7 @@ describe("Codex app-server provider", () => {
description: "Ship changes carefully.",
argumentHint: "",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
expect(resolvedRepoRoots).toEqual([cwd]);
} finally {
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 () => {
const session = createSession();
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 () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession({}, { goalsEnabled: true });
@@ -2046,14 +1790,7 @@ describe("Codex persisted sessions", () => {
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
return createCodexAppServerChildProcessStub();
};
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });
@@ -1,33 +1,32 @@
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type McpServerConfig,
type AgentPersistenceHandle,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type ToolCallTimelineItem,
type AgentUsage,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
McpServerConfig,
AgentPersistenceHandle,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
ToolCallTimelineItem,
AgentUsage,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import type { Logger } from "pino";
import { homedir } from "node:os";
@@ -56,10 +55,7 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import {
CodexAppServerClient,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
@@ -178,16 +174,9 @@ interface CodexAppServerClientLike {
interface CodexAppServerAgentDeps {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
id: string;
label: string;
extends: string;
};
customCodexConfig?: Record<string, unknown> | null;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
getTraceContext: () => CodexAppServerTraceContext,
) => CodexAppServerClientLike;
}
@@ -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 {
callId: string;
toolCall: ToolCallTimelineItem;
@@ -2658,13 +2603,8 @@ class CodexAppServerAgentSession implements AgentSession {
private readonly deps: CodexAppServerAgentDeps = {},
private readonly ephemeral: boolean = false,
private readonly goalsEnabled: boolean = false,
private readonly agentId?: string,
) {
this.logger = logger.child({
module: "agent",
provider: CODEX_PROVIDER,
agentId: this.agentId,
});
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
if (config.modeId === undefined) {
throw new Error("Codex agent requires modeId to be specified");
}
@@ -2701,7 +2641,7 @@ class CodexAppServerAgentSession implements AgentSession {
async connect(): Promise<void> {
if (this.connected) return;
const child = await this.spawnAppServer();
this.client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
this.client = new CodexAppServerClient(child, this.logger);
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
@@ -2719,14 +2659,6 @@ class CodexAppServerAgentSession implements AgentSession {
this.connected = true;
}
private traceContext(): CodexAppServerTraceContext {
return {
agentId: this.agentId,
sessionId: this.currentThreadId ?? undefined,
turnId: this.activeForegroundTurnId ?? undefined,
};
}
private async loadCollaborationModes(): Promise<void> {
if (!this.client) return;
try {
@@ -2747,16 +2679,7 @@ class CodexAppServerAgentSession implements AgentSession {
};
});
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.collaboration_modes_failed",
);
this.logger.trace({ error }, "Failed to load collaboration modes");
this.collaborationModes = [];
}
this.refreshResolvedCollaborationMode();
@@ -2788,16 +2711,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
this.cachedSkills = skills;
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.skills_failed",
);
this.logger.trace({ error }, "Failed to load skills list");
this.cachedSkills = [];
}
}
@@ -2987,10 +2901,9 @@ class CodexAppServerAgentSession implements AgentSession {
}
await this.client.request("thread/resume", params);
} catch (error) {
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
this.currentThreadId = null;
await this.ensureThread();
}
}
@@ -3674,9 +3587,6 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.extra?.codex) {
Object.assign(innerConfig, this.config.extra.codex);
}
if (this.deps.customCodexConfig) {
Object.assign(innerConfig, this.deps.customCodexConfig);
}
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
}
@@ -3694,16 +3604,6 @@ class CodexAppServerAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.codex.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);
@@ -3719,7 +3619,6 @@ class CodexAppServerAgentSession implements AgentSession {
private handleNotification(method: string, params: unknown): void {
const parsed = CodexNotificationSchema.parse({ method, params });
this.traceParsedNotification(method, params, parsed);
switch (parsed.kind) {
case "thread_started":
this.handleThreadStartedNotification(parsed);
@@ -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 {
if (!threadId || threadId === this.currentThreadId) {
return null;
@@ -3906,7 +3786,10 @@ class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(callId, "running");
this.emitSubAgentActivityUpdate(
callId,
timelineItem.type === "tool_call" && timelineItem.status === "failed" ? "failed" : "running",
);
}
private shouldSkipCompletedThreadItem(
@@ -4387,17 +4270,7 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
this.warnedUnknownNotificationMethods.add(method);
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
},
"provider.codex.event_unhandled",
);
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
}
private warnInvalidNotificationPayload(method: string, params: unknown): void {
@@ -4658,16 +4531,6 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {},
) {}
private sessionDeps(): CodexAppServerAgentDeps {
return {
...this.deps,
customCodexConfig: buildCodexCustomProviderConfig(
this.runtimeSettings,
this.deps.customProvider,
),
};
}
private resolveGoalsEnabled(): Promise<boolean> {
if (!this.goalsEnabledPromise) {
this.goalsEnabledPromise = (async () => {
@@ -4675,14 +4538,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
this.logger.trace(
{
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.goals_resolved",
);
this.logger.trace({ versionOutput, enabled }, "Resolved codex goals feature gate");
return enabled;
} catch (error) {
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(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string },
options?: { goalsEnabled?: boolean },
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const args = [...launchPrefix.args, "app-server"];
@@ -4704,12 +4560,10 @@ export class CodexAppServerAgentClient implements AgentClient {
}
this.logger.trace(
{
agentId: options?.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options?.goalsEnabled === true,
},
"provider.codex.spawn",
"Spawning Codex app server",
);
const child = spawnProcess(launchPrefix.command, args, {
detached: process.platform !== "win32",
@@ -4741,12 +4595,10 @@ export class CodexAppServerAgentClient implements AgentClient {
sessionConfig,
null,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.deps,
options?.persistSession === false,
goalsEnabled,
launchContext?.agentId,
);
await session.connect();
return session;
@@ -4769,12 +4621,10 @@ export class CodexAppServerAgentClient implements AgentClient {
merged,
handle,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.deps,
false,
goalsEnabled,
launchContext?.agentId,
);
await session.connect();
return session;
@@ -4785,7 +4635,7 @@ export class CodexAppServerAgentClient implements AgentClient {
): Promise<PersistedAgentDescriptor[]> {
const child = await this.spawnAppServer();
const client =
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
this.deps._createCodexClient?.(child, this.logger) ??
new CodexAppServerClient(child, this.logger);
try {
@@ -1,22 +1,20 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
import { TestCodexAppServerPeer } from "./test-utils/test-app-server-peer.js";
describe("Codex app-server transport", () => {
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(peer.child, createTestLogger());
const request = client.request("model/list", {});
child.stdout.write("Codex ha iniciado en modo localizado\n");
child.stdout.write('{"id":1,"result":{"data":[]}}\n');
peer.writeNonJsonStdout("Codex ha iniciado en modo localizado");
peer.writeResponse(1, { data: [] });
await expect(request).resolves.toEqual({ data: [] });
child.stdout.end();
child.stderr.end();
child.stdin.end();
peer.close();
});
test.each([
@@ -25,23 +23,19 @@ describe("Codex app-server transport", () => {
"item/tool/requestUserInput",
"tool/requestUserInput",
])("answers server-initiated %s requests through registered handlers", async (method) => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(peer.child, createTestLogger());
const handlerCalls: unknown[] = [];
client.setRequestHandler(method, async (params) => {
handlerCalls.push(params);
return { ok: true };
});
const response = new Promise<string>((resolve) => {
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
const response = peer.nextPaseoOutputLine();
peer.writeRequest(method);
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
expect(handlerCalls).toEqual([{}]);
child.stdout.end();
child.stderr.end();
child.stdin.end();
peer.close();
});
});
@@ -35,12 +35,6 @@ interface PendingRequest {
type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
export interface CodexAppServerTraceContext {
agentId?: string;
sessionId?: string;
turnId?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -60,24 +54,6 @@ function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
return typeof msg.method === "string" && msg.id === undefined;
}
function readProviderSessionId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
return typeof params.threadId === "string" ? params.threadId : undefined;
}
function readProviderTurnId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
if (typeof params.turnId === "string") {
return params.turnId;
}
const turn = params.turn;
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
export class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
@@ -90,7 +66,6 @@ export class CodexAppServerClient {
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => {
@@ -249,19 +224,6 @@ export class CodexAppServerClient {
}
if (isJsonRpcNotification(raw)) {
const traceContext = this.getTraceContext();
this.logger.trace(
{
provider: "codex",
agentId: traceContext.agentId,
sessionId: traceContext.sessionId ?? readProviderSessionId(raw.params),
turnId: traceContext.turnId ?? readProviderTurnId(raw.params),
method: raw.method,
params: raw.params,
rawEvent: raw,
},
"provider.codex.raw_event",
);
this.notificationHandler?.(raw.method, raw.params);
}
}
@@ -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", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
@@ -200,48 +200,21 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (unixWrapperMatch) {
const candidate = unixWrapperMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
return stripMatchingEdgeQuotes(candidate);
}
const windowsWrapperMatch = trimmed.match(
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i,
);
if (!windowsWrapperMatch) {
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (!wrapperMatch) {
return trimmed;
}
const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? "";
if (!wrappedCommand) {
return trimmed;
}
const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i);
if (!commandMatch) {
return trimmed;
}
const candidate = commandMatch[1]?.trim() ?? "";
const candidate = wrapperMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
return stripMatchingEdgeQuotes(candidate);
}
function stripMatchingEdgeQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
(candidate.startsWith('"') && candidate.endsWith('"')) ||
(candidate.startsWith("'") && candidate.endsWith("'"))
) {
return value.slice(1, -1);
return candidate.slice(1, -1);
}
return value;
}
function isWindowsShellCommand(command: string): boolean {
const normalized = command.replace(/^["']|["']$/g, "");
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
return candidate;
}
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
@@ -263,14 +236,6 @@ function normalizeCommandExecutionCommand(value: unknown): string | undefined {
const unwrapped = parts[2]?.trim();
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
}
if (
parts.length >= 3 &&
isWindowsShellCommand(parts[0] ?? "") &&
/^(-command|-c|\/c)$/i.test(parts[1] ?? "")
) {
const unwrapped = parts.slice(2).join(" ").trim();
return unwrapped.length > 0 ? stripMatchingEdgeQuotes(unwrapped) : undefined;
}
return parts.join(" ");
}
@@ -1,17 +1,10 @@
import type { Logger } from "pino";
import { homedir } from "node:os";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import {
ACPAgentClient,
type ACPBeforeModeWriteResult,
type ACPProviderModeWriteResult,
type ACPProviderModeWriterContext,
type SessionStateResponse,
} from "./acp-agent.js";
import { ACPAgentClient } from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -29,31 +22,21 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
supportsToolInvocations: true,
};
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
const COPILOT_AUTOPILOT_MODE_ID =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
export const COPILOT_ALLOW_ALL_MODE_ID = "allow-all";
const COPILOT_ALLOW_ALL_CONFIG_ID = "allow_all";
const COPILOT_ALLOW_ALL_ON = "on";
const COPILOT_ALLOW_ALL_OFF = "off";
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
export const COPILOT_MODES: AgentMode[] = [
const COPILOT_MODES: AgentMode[] = [
{
id: COPILOT_AGENT_MODE_ID,
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
label: "Agent",
description: "Default agent mode for conversational interactions",
},
{
id: COPILOT_PLAN_MODE_ID,
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
},
{
id: COPILOT_ALLOW_ALL_MODE_ID,
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
},
];
@@ -70,11 +53,6 @@ export class CopilotACPAgentClient extends ACPAgentClient {
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
capabilities: COPILOT_CAPABILITIES,
});
}
@@ -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;
}
function createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
function isBinaryInstalled(binary: string): boolean {
try {
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
@@ -574,160 +566,6 @@ describe("OpenCode adapter context-window normalization", () => {
});
describe("OpenCode adapter startTurn error handling", () => {
test("unwraps OpenCode global event payloads during a turn", async () => {
const globalEvents = [
{
payload: {
type: "server.connected",
properties: {},
},
},
{
directory: "/tmp/other",
payload: {
type: "message.part.delta",
properties: {
sessionID: "other-session",
messageID: "msg_other",
partID: "prt_other",
field: "text",
delta: "ignore me",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.updated",
properties: {
info: {
id: "msg_assistant",
sessionID: "ses_unit_test",
role: "assistant",
},
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.part.delta",
properties: {
sessionID: "ses_unit_test",
messageID: "msg_assistant",
partID: "prt_text",
field: "text",
delta: "Hello from global",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: { type: "idle" },
},
},
},
];
const fakeClient = {
event: {
subscribe: vi.fn(),
},
global: {
event: vi.fn().mockResolvedValue({ stream: createAsyncIterable(globalEvents) }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const turn = await collectTurnEvents(streamSession(session, "hello"));
expect(fakeClient.global.event).toHaveBeenCalledWith({
signal: expect.any(AbortSignal),
sseMaxRetryAttempts: 0,
});
expect(fakeClient.event.subscribe).not.toHaveBeenCalled();
expect(turn.turnCompleted).toBe(true);
expect(turn.turnFailed).toBe(false);
expect(turn.assistantMessages.map((message) => message.text).join("")).toBe(
"Hello from global",
);
});
test("fails a turn when OpenCode retry status does not recover", async () => {
vi.useFakeTimers();
const retryStream: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let emitted = false;
return {
next: async () => {
if (!emitted) {
emitted = true;
return {
done: false,
value: {
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: {
type: "retry",
attempt: 1,
message: "model does not exist",
},
},
},
},
};
}
return new Promise(() => {});
},
};
},
};
const fakeClient = {
global: {
event: vi.fn().mockResolvedValue({ stream: retryStream }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.startTurn("hello");
await vi.advanceTimersByTimeAsync(10_000);
const failed = events.find((event) => event.type === "turn_failed");
expect(failed).toMatchObject({
type: "turn_failed",
error: expect.stringContaining("model does not exist"),
});
vi.useRealTimers();
});
test("deletes provider session on close when persistence is disabled", async () => {
const fakeClient = {
session: {
@@ -742,7 +580,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
new Map(),
undefined,
false,
@@ -770,7 +607,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
await session.close();
@@ -779,29 +615,19 @@ describe("OpenCode adapter startTurn error handling", () => {
});
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
// Yield the server-connected event, then park forever. The adapter waits
// for that first event before sending the prompt.
// Async iterable that never yields and never resolves. The IIFE in
// startTurn synchronously hits the promptAsync throw and finishes the
// turn before this iterator is ever pulled, so the never-resolving
// promise inside next() is fine and gets garbage-collected.
const neverYieldingStream: AsyncIterable<OpenCodeEvent> = {
[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(() => {});
},
};
},
[Symbol.asyncIterator]: () => ({
next: () => new Promise(() => {}),
}),
};
const fakeClient = {
global: {
event: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
event: {
subscribe: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
},
session: {
promptAsync: vi.fn(() => {
@@ -815,7 +641,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const events: AgentStreamEvent[] = [];
@@ -830,58 +655,6 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(failed.error).toContain("boom: synchronous throw");
}
});
test("delays the next prompt until a slow interrupt abort settles", async () => {
vi.useFakeTimers();
const abortDeferred = createTestDeferred<{ data: boolean; error: undefined }>();
const promptAsync = vi.fn().mockResolvedValue({ data: {}, error: undefined });
const abort = vi
.fn()
.mockReturnValueOnce(abortDeferred.promise)
.mockResolvedValue({ data: true, error: undefined });
const fakeClient = {
global: {
event: vi.fn().mockImplementation(
async (options: {
signal: AbortSignal;
}): Promise<{ stream: AsyncIterable<OpenCodeEvent> }> => ({
stream: abortableOpenCodeStream(options.signal),
}),
),
},
session: {
promptAsync,
abort,
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
await session.startTurn("first");
expect(promptAsync).toHaveBeenCalledTimes(1);
const interruptPromise = session.interrupt();
await vi.advanceTimersByTimeAsync(2_000);
await interruptPromise;
expect(abort).toHaveBeenCalledTimes(1);
const secondTurnPromise = session.startTurn("second");
await vi.advanceTimersByTimeAsync(1_000);
expect(promptAsync).toHaveBeenCalledTimes(1);
abortDeferred.resolve({ data: true, error: undefined });
await secondTurnPromise;
expect(promptAsync).toHaveBeenCalledTimes(2);
await session.interrupt();
vi.useRealTimers();
});
});
describe("OpenCode persisted sessions", () => {
@@ -967,45 +740,3 @@ function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unk
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value), "utf8");
}
function createTestDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
return {
[Symbol.asyncIterator]: () => {
let emittedConnected = false;
return {
next: () => {
if (!emittedConnected) {
emittedConnected = true;
return Promise.resolve({
done: false,
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
});
}
return new Promise<IteratorResult<OpenCodeEvent>>((resolve) => {
if (signal.aborted) {
resolve({ done: true, value: undefined });
return;
}
signal.addEventListener("abort", () => resolve({ done: true, value: undefined }), {
once: true,
});
});
},
};
},
};
}
@@ -13,34 +13,33 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
import type { Logger } from "pino";
import { z } from "zod";
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModelsOptions,
type ListModesOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
type ToolCallDetail,
type ToolCallTimelineItem,
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModelsOptions,
ListModesOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallDetail,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
@@ -75,8 +74,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
const OPENCODE_RETRY_STATUS_FAILURE_MS = 10_000;
const DEFAULT_MODES: AgentMode[] = [
{
@@ -821,32 +818,18 @@ async function readOpenCodeSessionTimeline(
}
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
const parts = await readOpenCodeStoredParts(storageRoot, messageId);
return readOpenCodeTextFromParts(parts);
}
async function readOpenCodeStoredParts(
storageRoot: string,
messageId: string,
): Promise<OpenCodeStoredPart[]> {
const partRoot = path.join(storageRoot, "part", messageId);
const partFiles = await findJsonFiles(partRoot);
const parts: OpenCodeStoredPart[] = [];
for (const file of partFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
if (parsed) {
if (parsed?.type === "text" && typeof parsed.text === "string") {
parts.push(parsed);
}
}
return parts.sort(
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
);
}
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
return parts
.filter((part) => part.type === "text" && typeof part.text === "string")
.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n");
@@ -967,7 +950,7 @@ export class OpenCodeAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
_launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
@@ -1001,11 +984,9 @@ export class OpenCodeAgentClient implements AgentClient {
client,
session.id,
this.logger,
this.storageRoot,
new Map(this.modelContextWindows),
acquisition.release,
options?.persistSession,
launchContext?.agentId,
);
} catch (error) {
acquisition.release();
@@ -1016,7 +997,7 @@ export class OpenCodeAgentClient implements AgentClient {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
if (!cwd) {
@@ -1044,11 +1025,8 @@ export class OpenCodeAgentClient implements AgentClient {
client,
handle.sessionId,
this.logger,
this.storageRoot,
new Map(this.modelContextWindows),
acquisition.release,
undefined,
launchContext?.agentId,
);
} catch (error) {
acquisition.release();
@@ -1280,28 +1258,6 @@ export interface OpenCodeEventTranslationState {
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
}
interface OpenCodeTraceData {
turnId?: string;
[key: string]: unknown;
}
type OpenCodeTraceMessage =
| "provider.opencode.prompt_async.start"
| "provider.opencode.prompt_async.response"
| "provider.opencode.prompt_async.throw"
| "provider.opencode.subscribe.start"
| "provider.opencode.subscribe.ready"
| "provider.opencode.stream.eof"
| "provider.opencode.turn.fail_eof"
| "provider.opencode.subscribe.error"
| "provider.opencode.raw_event"
| "provider.opencode.event.skip"
| "provider.opencode.parsed_event"
| "provider.opencode.parsed_event.skip_active"
| "provider.opencode.event.terminal"
| "provider.opencode.finish_foreground_turn"
| "provider.opencode.event_emit";
type OpenCodeToolPartEventPart = Extract<
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
{ type: "tool" }
@@ -2166,24 +2122,6 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null {
const record = readOpenCodeRecord(event);
if (!record) {
return null;
}
const payload = readOpenCodeRecord(record.payload);
if (typeof payload?.type === "string") {
return payload as unknown as OpenCodeEvent;
}
if (typeof record.type === "string") {
return record as unknown as OpenCodeEvent;
}
return null;
}
class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
@@ -2196,7 +2134,6 @@ class OpenCodeAgentSession implements AgentSession {
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null;
private pendingAbortPromise: Promise<void> | null = null;
private accumulatedUsage: AgentUsage = {};
private mcpConfigured = false;
private mcpSetupPromise: Promise<void> | null = null;
@@ -2220,22 +2157,19 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null;
private readonly persistSession: boolean;
private deletedFromProvider = false;
private retryFailureTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
_storageRoot: string,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void,
persistSession = true,
private readonly agentId?: string,
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.logger = logger.child({ agentId: this.agentId });
this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.releaseServer = releaseServer ?? null;
@@ -2289,18 +2223,9 @@ class OpenCodeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId;
const turnAbortController = this.abortController;
turnAbortController?.abort();
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
// the running tool actually stops, which can be tens of seconds for
// long-running tools. Cap the wait so the user-visible cancel lands
// quickly while still giving OpenCode a chance to confirm the abort
// cleanly. Drop the timeout once upstream returns abort acknowledgement
// before tool teardown.
const abortPromise = this.beginSessionAbort(turnId, "interrupt");
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId },
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
);
await this.client.session.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
});
if (turnId) {
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(
prompt: AgentPromptInput,
options?: AgentRunOptions,
@@ -2357,13 +2242,11 @@ class OpenCodeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
await this.awaitPendingAbortBeforeStartingTurn();
this.runningToolCalls.clear();
this.subAgentsByCallId.clear();
this.subAgentCallIdByChildSessionId.clear();
this.pendingChildToolPartsBySessionId.clear();
this.clearRetryFailureTimer();
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
@@ -2497,14 +2380,6 @@ class OpenCodeAgentSession implements AgentSession {
// SDK input validation) is caught alongside async rejections. A plain
// `.then().catch()` chain would let a sync throw escape unhandled.
void (async () => {
this.traceOpenCode("provider.opencode.prompt_async.start", {
turnId,
sessionId: this.sessionId,
model,
effectiveMode,
effectiveVariant,
partTypes: parts.map((p) => p.type),
});
try {
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
@@ -2523,12 +2398,6 @@ class OpenCodeAgentSession implements AgentSession {
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
this.traceOpenCode("provider.opencode.prompt_async.response", {
turnId,
hasError: promptResponse.error !== undefined,
error: promptResponse.error,
data: promptResponse.data,
});
if (promptResponse.error) {
this.finishForegroundTurn(
{
@@ -2540,13 +2409,6 @@ class OpenCodeAgentSession implements AgentSession {
);
}
} catch (error) {
this.traceOpenCode("provider.opencode.prompt_async.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2574,51 +2436,36 @@ class OpenCodeAgentSession implements AgentSession {
turnAbortController: AbortController,
subscriptionReady: Deferred<void>,
): Promise<void> {
this.traceOpenCode("provider.opencode.subscribe.start", {
turnId,
sessionId: this.sessionId,
cwd: this.config.cwd,
});
try {
const result = await this.client.global.event({
signal: turnAbortController.signal,
sseMaxRetryAttempts: 0,
});
let eventCount = 0;
let subscriptionReadyResolved = false;
for await (const rawEvent of result.stream) {
eventCount += 1;
if (!subscriptionReadyResolved) {
subscriptionReadyResolved = true;
this.traceOpenCode("provider.opencode.subscribe.ready", {
turnId,
sessionId: this.sessionId,
});
subscriptionReady.resolve();
const result = await this.client.event.subscribe(
{ directory: this.config.cwd },
{ signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
);
subscriptionReady.resolve();
for await (const event of result.stream) {
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
break;
}
const shouldContinue = await this.consumeOpenCodeStreamEvent({
rawEvent,
eventCount,
turnId,
turnAbortController,
});
if (!shouldContinue) {
return;
const translated = await this.translateEvent(event);
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
return;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
this.finishForegroundTurn(terminalEvent, turnId);
return;
}
this.notifySubscribers(e, turnId);
}
}
this.traceOpenCode("provider.opencode.stream.eof", {
turnId,
eventCount,
aborted: turnAbortController.signal.aborted,
stillActive: this.activeForegroundTurnId === turnId,
});
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.traceOpenCode("provider.opencode.turn.fail_eof", { turnId, eventCount });
if (!subscriptionReadyResolved) {
subscriptionReady.reject(new Error("OpenCode event stream ended before it became ready"));
}
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2629,11 +2476,6 @@ class OpenCodeAgentSession implements AgentSession {
);
}
} catch (error) {
this.traceOpenCode("provider.opencode.subscribe.error", {
turnId,
error:
error instanceof Error ? { name: error.name, message: error.message } : String(error),
});
subscriptionReady.reject(error);
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
@@ -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(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string,
): void {
this.traceOpenCode("provider.opencode.finish_foreground_turn", {
turnId,
activeTurnId: this.activeForegroundTurnId,
type: event.type,
error: event.type === "turn_failed" ? event.error : undefined,
reason: event.type === "turn_canceled" ? event.reason : undefined,
});
if (this.activeForegroundTurnId !== turnId) {
return;
}
@@ -2744,7 +2516,6 @@ class OpenCodeAgentSession implements AgentSession {
} else {
this.runningToolCalls.clear();
}
this.clearRetryFailureTimer();
this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort();
@@ -2760,44 +2531,6 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.delete(item.callId);
}
private armRetryFailureTimerForStatus(event: OpenCodeEvent, turnId: string): void {
if (this.retryFailureTimer || event.type !== "session.status") {
return;
}
if (event.properties.sessionID !== this.sessionId || event.properties.status.type !== "retry") {
return;
}
const retry = event.properties.status;
const message = typeof retry.message === "string" ? retry.message.trim() : "";
const error = message
? `OpenCode provider retry did not recover: ${message}`
: "OpenCode provider retry did not recover";
this.retryFailureTimer = setTimeout(() => {
this.retryFailureTimer = null;
if (this.activeForegroundTurnId !== turnId) {
return;
}
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error,
},
turnId,
);
}, OPENCODE_RETRY_STATUS_FAILURE_MS);
}
private clearRetryFailureTimer(): void {
if (!this.retryFailureTimer) {
return;
}
clearTimeout(this.retryFailureTimer);
this.retryFailureTimer = null;
}
private synthesizeInterruptedToolCalls(turnId: string): void {
for (const item of this.runningToolCalls.values()) {
const error = { message: "Tool execution aborted" };
@@ -2829,10 +2562,6 @@ class OpenCodeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.traceOpenCode("provider.opencode.event_emit", {
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
});
for (const callback of this.subscribers) {
try {
callback(tagged);
@@ -2846,19 +2575,6 @@ class OpenCodeAgentSession implements AgentSession {
return `opencode-turn-${this.nextTurnOrdinal++}`;
}
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
this.logger.trace(
{
agentId: this.agentId,
provider: "opencode",
sessionId: this.sessionId,
turnId: data.turnId ?? this.activeForegroundTurnId ?? undefined,
...data,
},
msg,
);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
const response = await this.client.session.messages({
sessionID: this.sessionId,
@@ -47,7 +47,6 @@ export class TestOpenCodeClient {
appAgents: [] as unknown[],
commandList: [] as unknown[],
eventSubscribe: [] as unknown[],
globalEvent: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
questionReject: [] as unknown[],
@@ -100,12 +99,6 @@ export class TestOpenCodeClient {
return { stream: this.eventStream };
},
},
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
return { stream: this.eventStream };
},
},
mcp: {
add: async () => ({}),
connect: async () => ({}),
@@ -30,30 +30,29 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
import { z } from "zod";
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModesOptions,
type ListModelsOptions,
type ToolCallDetail,
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModesOptions,
ListModelsOptions,
ToolCallDetail,
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
@@ -739,6 +738,21 @@ function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersis
return {};
}
function getStreamEventTurnId(event: AgentStreamEvent): string | undefined {
switch (event.type) {
case "turn_started":
case "turn_completed":
case "turn_failed":
case "turn_canceled":
case "timeline":
case "permission_requested":
case "permission_resolved":
return event.turnId;
default:
return undefined;
}
}
function isPiRequestAbortError(error: unknown): boolean {
if (error instanceof Error && error.name === "AbortError") {
return true;
@@ -1031,7 +1045,7 @@ export class PiDirectAgentSession implements AgentSession {
return;
}
const eventTurnId = getAgentStreamEventTurnId(event);
const eventTurnId = getStreamEventTurnId(event);
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
@@ -1,10 +1,9 @@
import {
getAgentStreamEventTurnId,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentStreamEvent,
type AgentTimelineItem,
import type {
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentStreamEvent,
AgentTimelineItem,
} from "../agent-sdk-types.js";
export type ProviderFinalTextReducer = (params: {
@@ -45,7 +44,7 @@ export async function runProviderTurn({
if (settled) {
return;
}
const eventTurnId = getAgentStreamEventTurnId(event);
const eventTurnId = "turnId" in event ? event.turnId : undefined;
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
@@ -1,9 +1,8 @@
import {
getAgentStreamEventTurnId,
type AgentPromptInput,
type AgentRunOptions,
type AgentSession,
type AgentStreamEvent,
import type {
AgentPromptInput,
AgentRunOptions,
AgentSession,
AgentStreamEvent,
} from "../../agent-sdk-types.js";
function isTerminalEvent(event: AgentStreamEvent): boolean {
@@ -30,7 +29,7 @@ export async function* streamSession(
};
const matchesTurn = (event: AgentStreamEvent): boolean => {
const eventTurnId = getAgentStreamEventTurnId(event);
const eventTurnId = (event as { turnId?: string }).turnId;
return turnId == null || eventTurnId == null || eventTurnId === turnId;
};

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