Compare commits

..

60 Commits

Author SHA1 Message Date
Mohamed Boudra
51d9563352 chore(release): cut 0.1.75 2026-05-12 17:35:22 +07:00
Mohamed Boudra
045b373168 docs(changelog): draft 0.1.75 entry 2026-05-12 17:34:18 +07:00
Mohamed Boudra
985ad52cce fix(copilot): preserve legacy autopilot mode alias 2026-05-12 17:34:09 +07:00
Mohamed Boudra
d198c68b9e Fail Codex resume requests explicitly (#947) 2026-05-12 17:50:31 +08:00
Mohamed Boudra
751a07124f Fix scheduled agent cleanup (#945) 2026-05-12 17:05:40 +08:00
Mohamed Boudra
defb4f82f7 ci(nix): smoke test daemon boot (#939) 2026-05-12 16:05:15 +08:00
Bolun Zhang
4570e65ce8 fix(app): respect iPad safe area in settings sidebar (#922)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 07:27:49 +00:00
ezra
db0d63dd90 Handle Windows shell wrappers in Codex command summaries (#931) 2026-05-12 15:07:37 +08:00
Mohamed Boudra
1a8fdcd388 Configure STT language from settings (#941)
* Configure STT language from settings

* Update websocket speech mock for language config
2026-05-12 15:03:53 +08:00
Biao Ma
417abed6a5 Fix desktop daemon stale PID startup (#913) 2026-05-12 14:33:18 +08:00
Bolun Zhang
6afdeef84a Fix iPad sidebar safe area background (#937)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 14:24:20 +08:00
Mohamed Boudra
77c82dfdbd ci: split nix into two workflows
nix.yml: PR-only build check.
nix-update-hash.yml: push-to-main hash update via paseo-ai App token.
2026-05-12 12:57:38 +07:00
Mohamed Boudra
29277c900d ci: split nix workflow into build (anywhere) + update-hash (main only)
build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.

update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.

Rename file to nix.yml since it's no longer just a build job.
2026-05-12 12:54:22 +07:00
Mohamed Boudra
ed1943058a ci: gate nix-build on same-repo PRs only
scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.

Closes #365.
2026-05-12 12:37:01 +07:00
Mohamed Boudra
e0361ddd22 ci: drop unused contents: write override in nix-build
GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.

Addresses #365.
2026-05-12 12:35:05 +07:00
Mohamed Boudra
ce9474055e ci: skip CI on nix-build auto hash commits
The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.
2026-05-12 12:28:34 +07:00
Mohamed Boudra
8f9b4c8828 ci: skip App token on PRs in nix-build
Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.
2026-05-12 12:26:22 +07:00
paseo-ai[bot]
af4e0de9ab fix: update lockfile signatures and Nix hash 2026-05-12 05:21:32 +00:00
Mohamed Boudra
3acc71b8ad Normalize HEIC image attachments on the client (#934)
* Normalize HEIC attachments before persistence

* Preserve native JPEG and PNG picks
2026-05-12 13:14:25 +08:00
Matan Bendix Shenhav
0759932dad nix: declarative config, typed relay options, desktop packaging (#923)
* nix: expose npmDepsHash as a callPackage arg

Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.

Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nix: move npmDepsHash default to a sidecar file

Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.

No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nix: declarative config via services.paseo.settings

Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.

Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.

`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.

The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nix: typed services.paseo.relay options with auto-wired endpoint

Addresses #224 (option surface only).

Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.

Add a typed relay subtree:

- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
  the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
  when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
  the new options answer "how is it configured?".

The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.

No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nix: package paseo desktop app for Linux

NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.

Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.

The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.

`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.

No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nix: copy full packages/ tree in desktop derivation

The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:

- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
  (the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
  (the CLI launcher script lives under bin/, not dist/)

npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nix: route desktop renderer through paseo:// protocol handler

When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.

main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: track nix/npm-deps.hash in nix-build commit step

The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.

* ci: push nix-build hash commits via paseo-ai[bot] App token

The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-12 13:11:42 +08:00
Mohamed Boudra
95f45e4e2b Add trace logging and tighten daemon log defaults (#933)
* Add trace logging and tighten daemon log defaults

* Clean up daemon trace logging shape

* Clean up daemon trace logging

* Route provider turn-id checks through shared helper

* Fix supervisor log config test paths on Windows

* Expect resolved supervisor log path on Windows
2026-05-12 12:58:46 +08:00
Mohamed Boudra
a5c2b97e1d Wire Copilot Allow All mode to ACP permissions (#935) 2026-05-12 12:00:20 +08:00
Mohamed Boudra
d32462e9ee Fix custom Codex provider base URL routing (#915) 2026-05-12 11:54:15 +08:00
Mohamed Boudra
33262843a5 chore(release): cut 0.1.74 2026-05-11 18:17:50 +07:00
Mohamed Boudra
1cd02a0e1a chore: changelog for 0.1.74 2026-05-11 18:16:46 +07:00
Mohamed Boudra
40ab9e3f20 Use OpenCode global event stream (#916)
* Use OpenCode global event stream

* Use stable OpenCode model in initial prompt e2e

* Clean up OpenCode verification notes
2026-05-11 09:17:00 +00:00
Mohamed Boudra
4141c76258 chore(release): cut 0.1.73 2026-05-10 22:58:07 +07:00
Mohamed Boudra
7f44323686 chore: changelog for 0.1.73 2026-05-10 22:56:55 +07:00
Mohamed Boudra
152b07b599 fix(server): address OpenCode recovery review findings (#904)
* fix(server): pin OpenCode SDK version

* fix(server): tolerate transient OpenCode recovery poll errors

* fix(server): address OpenCode recovery findings 2 and 4

* fix(server): address OpenCode recovery finding 1

* fix(server): address OpenCode recovery finding 3
2026-05-10 23:40:34 +08:00
Mohamed Boudra
84f36d2e20 fix(server): recover OpenCode turns when 1.14.42+ SSE drops early (#902)
OpenCode 1.14.42+ closes the /event SSE stream cleanly right after
server.connected, breaking the entire turn lifecycle: prompts get
queued and run, but Paseo never sees session.idle / message deltas /
tool calls / questions. Every turn either fails with a generic stream
EOF or hangs waiting for events that never arrive.

Switch the post-EOF recovery to the canonical messages REST endpoint
(via the upgraded SDK) and poll incrementally so tool calls and
clarifying questions surface live during the SSE gap. Bound the wait
with a completion cap and a separate liveness cap so silent rejections
fail fast instead of hanging until the cap. Cap session.abort similarly
so explicit cancels land within seconds.

The recovery path is gated on the for-await loop exiting without a
terminal event, so healthy turns never enter it - if upstream restores
SSE delivery, this code becomes dead and is mechanically removable via
the COMPAT(opencodeEofRecovery) and COMPAT(opencodeSlowAbort) tags.

Also tighten the type of projectSettingsRoute on WorktreeSetupCalloutPolicy
so router.navigate accepts it (was widened to string, broke app
typecheck on main).

Refs: getpaseo/paseo#861, anomalyco/opencode#26697,
anomalyco/opencode#26635
2026-05-10 22:43:25 +08:00
João Sousa Andrade
25d4c5023a Harden file explorer symlink handling (#847) 2026-05-10 13:14:50 +00:00
João Sousa Andrade
3f5acfff31 Restrict desktop external URL schemes (#845) 2026-05-10 21:02:47 +08:00
Mohamed Boudra
b9940e285c Fix Codex sub-agent child tool failure status (#899) 2026-05-10 20:27:23 +08:00
nikuscs
9993c6c6c3 fix(app): avoid bottom sheet text input on web (#898) 2026-05-10 12:18:27 +00:00
Mohamed Boudra
3b7971a463 Fix Windows git command console flashing (#897) 2026-05-10 19:46:25 +08:00
nikuscs
d75d2d857d Fix macOS tab jump shortcut conflict (#859) 2026-05-10 11:34:14 +00:00
Mohamed Boudra
cab42985a5 Fix old relay pairing URL TLS compat (#896) 2026-05-10 19:29:56 +08:00
Mohamed Boudra
ef892bd27d Revert "fix(server): wait for opencode completion after EOF"
This reverts commit 2a84b08129.
2026-05-10 18:28:53 +07:00
Link
3014576c4c fix(server): recover completed opencode turns after SSE EOF (#895)
* fix(server): recover completed opencode turns after SSE EOF (#861)

* fix(server): wait for opencode completion after EOF

The original EOF recovery only checked OpenCode storage once after the SSE stream ended. That missed the real failure mode from #861: OpenCode can drop /event while the turn continues behind the scenes, then persist the assistant completion a little later.

Poll the persisted session for the active turn before failing EOF, while still requiring strong completion evidence and ignoring messages that predate the turn. This preserves failure behavior when there is no persisted completion.

Add behavioral coverage for delayed completion after EOF, partial streamed text plus persisted completion without duplication, stale old completions, and the no-evidence failure path.

---------

Co-authored-by: pluto <plutofog@proton.me>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-10 11:27:14 +00:00
João Sousa Andrade
b8c77bf0e3 Redact MCP debug request logs (#842) 2026-05-10 09:33:29 +00:00
Mohamed Boudra
17073fe8ff refactor(server): exercise codex features through fake app-server (#887) 2026-05-10 09:27:53 +00:00
Mohamed Boudra
6220b47073 refactor(server): extract codex app-server fake (#873) 2026-05-10 17:01:50 +08:00
Mohamed Boudra
bf7f8f686b refactor(cli): inject local daemon launch runtime (#874)
* refactor(cli): inject local daemon launch runtime

* test(app/e2e): target mobile sidebar toggle state
2026-05-10 08:38:21 +00:00
Mohamed Boudra
93cd4734ce refactor(app): extract workspace terminal lifecycle (#881) 2026-05-10 08:24:19 +00:00
Mohamed Boudra
e4acd6cb7a refactor(server): extract task document persistence (#883)
* refactor(server): extract task document persistence

* test(cli): wait for loop list visibility
2026-05-10 08:23:38 +00:00
Mohamed Boudra
ca11fc667b Refactor daemon connection probe tests (#886) 2026-05-10 08:20:16 +00:00
Mohamed Boudra
36e54a097e Refactor worktree create request parsing (#885) 2026-05-10 08:18:00 +00:00
Mohamed Boudra
ecd3137d34 Extract sidebar callout state (#884) 2026-05-10 08:09:17 +00:00
Mohamed Boudra
f881f9ae32 Refactor relay transport socket tests (#882) 2026-05-10 07:53:54 +00:00
Mohamed Boudra
3f6b84899a Extract worktree setup callout policy (#878) 2026-05-10 07:44:44 +00:00
Mohamed Boudra
5e64a1340c Unslop workspace git watch tests (#880) 2026-05-10 07:42:49 +00:00
Mohamed Boudra
2d0ed004e2 Refactor workspace layout id generation (#876) 2026-05-10 07:34:11 +00:00
Mohamed Boudra
183cda2b66 Extract agent archive projection (#877) 2026-05-10 07:32:14 +00:00
Mohamed Boudra
ed2a97fda8 Extract websocket runtime metrics (#875) 2026-05-10 07:28:48 +00:00
可乐小猫
2fed0f09bb Fix infinite recursion in web crypto randomUUID polyfill (#858)
On web (browser/Electron), expo-crypto's randomUUID() and getRandomValues()
just forward to globalThis.crypto.* (see expo-crypto/src/ExpoCrypto.web.ts).
The previous polyfill installed `g.crypto.randomUUID = () => ExpoCrypto.randomUUID()`,
which on web reads back through the same `globalThis.crypto.randomUUID` it
just installed, recursing until the stack overflows. The same trap exists
for getRandomValues, but it rarely triggers because the native version is
almost always present.

Capture a bound reference to the native getRandomValues *before* installing
the polyfill, and generate UUID v4 in JS from 16 random bytes (RFC 4122
version + variant bits). The fallback path stays on ExpoCrypto.getRandomValues
(non-recursive on native), and ExpoCrypto.randomUUID is no longer used.
2026-05-10 07:07:59 +00:00
Mohamed Boudra
478aa4b70e ci: cancel superseded PR runs to free runner capacity
cancel-in-progress is gated to pull_request events so merge_group and
push runs always complete — only redundant CI from rapid PR pushes
gets cancelled.
2026-05-10 14:12:56 +07:00
Mohamed Boudra
fd74abcdca ci: trigger required checks on merge_group events (#879)
Without merge_group on the workflow, PRs entering the merge queue would
fail because no required check ever reports a status against the
merge_group ref.
2026-05-10 15:07:39 +08:00
Mohamed Boudra
444e265275 refactor(server): inject push notification sender (#872) 2026-05-10 06:49:38 +00:00
Mohamed Boudra
2ee9329663 chore(release): cut 0.1.72 2026-05-10 13:28:02 +07:00
Mohamed Boudra
b30aafc2bd docs(changelog): draft 0.1.72 entry 2026-05-10 13:27:08 +07:00
150 changed files with 8513 additions and 4205 deletions

View File

@@ -5,8 +5,13 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
format:
runs-on: ubuntu-latest

View File

@@ -1,72 +0,0 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates (main push only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push

60
.github/workflows/nix-update-hash.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Nix Update Hash
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-update-hash.yml"
permissions:
contents: read
jobs:
update-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates
run: |
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
git config user.name "paseo-ai[bot]"
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
git add package-lock.json nix/npm-deps.hash
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
git push

99
.github/workflows/nix.yml vendored Normal file
View File

@@ -0,0 +1,99 @@
name: Nix
on:
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Smoke Nix daemon
run: |
set -euo pipefail
export PASEO_HOME
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
kill "$DAEMON_PID"
wait "$DAEMON_PID" || true
fi
rm -rf "$PASEO_HOME"
}
trap cleanup EXIT
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
echo "$STATUS_JSON"
exit 0
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "Nix daemon exited before becoming reachable."
break
fi
sleep 1
done
echo "::group::daemon.log"
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo daemon status"
./result/bin/paseo daemon status || true
echo "::endgroup::"
exit 1
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop

View File

@@ -1,5 +1,55 @@
# Changelog
## 0.1.75 - 2026-05-12
### Added
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
- NixOS: `services.paseo.settings` renders declarative daemon config to disk, and typed `services.paseo.relay` options auto-wire the relay endpoint. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
- NixOS: Paseo desktop is now packaged in the flake — `nix run github:getpaseo/paseo#desktop` launches the Electron app. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
### Fixed
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
- Copilot's **Allow All** mode (previously misnamed Autopilot) now actually suppresses tool, path, and URL permission prompts. ([#935](https://github.com/getpaseo/paseo/pull/935))
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
- iPhone HEIC photos now attach correctly from the image picker. ([#934](https://github.com/getpaseo/paseo/pull/934))
- Scheduled agents now archive automatically after each run instead of piling up in the active list. ([#945](https://github.com/getpaseo/paseo/pull/945))
- Windows: Codex command summaries show the underlying command instead of `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
## 0.1.74 - 2026-05-11
### Fixed
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
## 0.1.73 - 2026-05-10
### Fixed
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
## 0.1.72 - 2026-05-10
### Fixed
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
## 0.1.71 - 2026-05-09
### Added
@@ -23,6 +73,7 @@
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
### Security

View File

@@ -59,6 +59,30 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
---
## Z.AI (Zhipu) coding plan

View File

@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
providers: Record<providerId, ProviderOverride>
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,

View File

@@ -39,7 +39,14 @@ In any worktree-style or portless setup, never assume default ports.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for trace-level logs.
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
## paseo.json service scripts

View File

@@ -0,0 +1,49 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`

View File

@@ -26,11 +26,18 @@
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
];
in
{
default = paseo;
paseo = paseo;
}
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
}
);
nixosModules.default = self.nixosModules.paseo;

157
nix/desktop-package.nix Normal file
View File

@@ -0,0 +1,157 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
# different value.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
pname = "paseo-desktop";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store"
&& baseName != "release";
};
nodejs = nodejs_22;
inherit npmDepsHash;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
EXPO_NO_TELEMETRY = "1";
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
};
buildPhase = ''
runHook preBuild
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# App workspace deps not covered by build:daemon
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only NOT electron-builder)
npm run build:main --workspace=@getpaseo/desktop
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
copyDesktopItems
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
})
];
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
};
}

View File

@@ -81,7 +81,48 @@ in
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
description = ''
Whether to enable relay-based remote access. When false, the daemon
runs with `--no-relay` and only accepts direct (LAN/loopback)
connections.
'';
};
mode = lib.mkOption {
type = lib.types.enum [ "hosted" "remote" ];
default = "hosted";
description = ''
How the daemon reaches the relay when `relay.enable = true`:
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
Preserves the current behavior; no extra options needed.
- `"remote"`: connect to a self-hosted relay at
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
`PASEO_RELAY_USE_TLS` for the daemon.
A `"local"` mode (running a relay on the same host as a systemd
unit) is not yet implemented the relay package currently only
ships a Cloudflare Workers adapter. Tracked separately.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "";
example = "relay.example.com";
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Relay port. Used when `relay.mode = \"remote\"`.";
};
useTls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
};
};
@@ -111,9 +152,50 @@ in
'';
description = "Extra environment variables for the Paseo daemon.";
};
settings = lib.mkOption {
type = (pkgs.formats.json { }).type;
default = { };
example = lib.literalExpression ''
{
daemon.mcp = { enabled = true; injectIntoAgents = false; };
agents.providers.myAcp = {
extends = "acp";
label = "My Agent";
command = { path = "/run/current-system/sw/bin/my-acp"; };
};
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
}
'';
description = ''
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
and installed on every service start.
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
or the mobile app toggling MCP injection / provider overrides) are
overwritten on the next restart. Pick one: manage via this option, or
manage via the CLI not both.
The full schema is defined by `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`.
'';
};
};
config = lib.mkIf cfg.enable {
config = lib.mkIf cfg.enable (
let
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
in
{
assertions = [
{
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
message = ''
services.paseo.relay.host must be set when relay.mode = "remote".
'';
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
@@ -131,6 +213,10 @@ in
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = lib.mkIf (cfg.settings != { }) ''
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
'';
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
@@ -149,6 +235,9 @@ in
PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // cfg.environment;
serviceConfig = {
@@ -172,5 +261,6 @@ in
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
);
}

1
nix/npm-deps.hash Normal file
View File

@@ -0,0 +1 @@
sha256-LczD9EmK6LuaJuZQu1v/q8zBE92LVynRR85dp6IdfCo=

View File

@@ -7,6 +7,15 @@
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
# can override via `.override { npmDepsHash = "sha256-..."; }` without
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
#
# The default is read from a sidecar file so the CI auto-updater can replace
# the hash with a single file write instead of a sed against this source.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
@@ -40,9 +49,9 @@ buildNpmPackage rec {
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
# Default hash lives in nix/npm-deps.hash (see arg default above).
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

56
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.71",
"version": "0.1.75",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.71",
"version": "0.1.75",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -9397,12 +9397,6 @@
"node": ">=20.0"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -21775,6 +21769,18 @@
"expo": "*"
}
},
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
@@ -38854,7 +38860,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.71",
"version": "0.1.75",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38892,6 +38898,7 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
@@ -38980,10 +38987,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.71",
"version": "0.1.75",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.71",
"@getpaseo/server": "0.1.75",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39026,7 +39033,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.71",
"version": "0.1.75",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -39075,7 +39082,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.71",
"version": "0.1.75",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -39111,7 +39118,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.71",
"version": "0.1.75",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -39137,7 +39144,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.71",
"version": "0.1.75",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39152,18 +39159,18 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.71",
"version": "0.1.75",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.71",
"@getpaseo/highlight": "0.1.75",
"@getpaseo/relay": "0.1.75",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@opencode-ai/sdk": "1.14.46",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -39344,6 +39351,15 @@
"url": "https://opencollective.com/express"
}
},
"packages/server/node_modules/@opencode-ai/sdk": {
"version": "1.14.46",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
"integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -39691,7 +39707,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.71",
"version": "0.1.75",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.71",
"version": "0.1.75",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

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.getByTestId("menu-button").click();
await page.getByRole("button", { name: "Open menu" }).click();
}
// force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click({ force: true });
await page.getByRole("button", { name: "Close menu" }).click({ force: true });
}
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.71",
"version": "0.1.75",
"private": true,
"main": "index.ts",
"scripts": {
@@ -63,6 +63,7 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",

View File

@@ -19,7 +19,7 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
if (isMobile) {
if (isMobile && isNative) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}

View File

@@ -12,7 +12,7 @@ import {
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
@@ -443,7 +443,7 @@ function ProviderSearchInput({
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};

View File

@@ -791,10 +791,13 @@ function DesktopSidebar({
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
const desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
[resizeAnimatedStyle, insetsTop],
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
[resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
);
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[],

View File

@@ -38,7 +38,7 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
@@ -148,7 +148,7 @@ export function SearchInput({
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
});
});
});

View File

@@ -0,0 +1,85 @@
import type { PaseoConfigRaw } from "@server/shared/messages";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
interface ReadProjectConfigResult {
ok: boolean;
config?: PaseoConfigRaw | null;
}
export interface WorktreeSetupCalloutPolicy {
id: string;
dismissalKey: string;
priority: number;
title: string;
description: string;
actionLabel: string;
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
testID: string;
}
export function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorktreeSetupWorkspaceInput,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
}
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}

View File

@@ -1,279 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
const activeSelection = vi.hoisted(() => ({
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
serverId: string;
workspaceId: string;
} | null,
}));
const activeWorkspace = vi.hoisted(() => ({
value: {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
} as Record<string, unknown> | null,
}));
const client = vi.hoisted(() => ({
readProjectConfig: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceFields: (
serverId: string | null,
workspaceId: string | null,
project: (workspace: Record<string, unknown>) => unknown,
) => {
if (
!activeWorkspace.value ||
serverId !== activeSelection.value?.serverId ||
workspaceId !== activeWorkspace.value.id
) {
return null;
}
return project(activeWorkspace.value);
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
function readOk(config: Record<string, unknown>) {
return {
ok: true,
config,
revision: { exists: true, mtimeMs: 1, size: 2 },
};
}
function readError() {
return {
ok: false,
error: { code: "project_not_found", message: "Project not found" },
};
}
function Harness({ queryClient }: { queryClient: QueryClient }) {
return (
<QueryClientProvider client={queryClient}>
<SidebarCalloutProvider>
<WorktreeSetupCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
</QueryClientProvider>
);
}
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
await act(async () => {
root.render(<Harness queryClient={queryClient} />);
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
async function findByTestId(testID: string): Promise<HTMLElement | null> {
let element: HTMLElement | null = null;
for (let index = 0; index < 10 && !element; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
}
return element;
}
describe("WorktreeSetupCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
};
client.readProjectConfig.mockReset();
client.readProjectConfig.mockResolvedValue(readOk({}));
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
}
queryClient?.clear();
queryClient = null;
root = null;
container?.remove();
container = null;
});
it("registers a callout for an active git workspace with missing setup", async () => {
await renderHarness(root!, queryClient!);
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
expect(container?.textContent).toContain("Set up worktree scripts");
expect(container?.textContent).toContain("Open project settings");
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
});
it("does not register a callout for a non-git workspace", async () => {
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "local",
projectRootPath: "/repo/project-1",
};
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when setup is present", async () => {
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("does not register a callout without an active workspace", async () => {
activeSelection.value = null;
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when reading paseo.json fails", async () => {
client.readProjectConfig.mockResolvedValue(readError());
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("opens project settings from the callout action", async () => {
await renderHarness(root!, queryClient!);
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
});
it("persists dismissal for the project", async () => {
await renderHarness(root!, queryClient!);
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["worktree-setup-missing:project-1"]),
);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
});

View File

@@ -1,48 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { useRouter } from "expo-router";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorkspaceDescriptor,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
} from "./worktree-setup-callout-policy";
export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection();
@@ -58,7 +26,7 @@ export function WorktreeSetupCalloutSource() {
if (!activeProject) {
return;
}
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute);
});
const readQuery = useQuery({
@@ -73,29 +41,31 @@ export function WorktreeSetupCalloutSource() {
retry: false,
});
const shouldShow =
activeProject !== null &&
readQuery.data?.ok === true &&
!hasSetupCommands(readQuery.data.config ?? {});
const calloutPolicy = useMemo(
() =>
activeProject && shouldShowWorktreeSetupCallout(readQuery.data)
? buildWorktreeSetupCalloutPolicy(activeProject)
: null,
[activeProject, readQuery.data],
);
useEffect(() => {
if (!shouldShow || !activeProject) {
if (!calloutPolicy) {
return;
}
return callouts.show({
id: `worktree-setup-missing:${activeProject.projectKey}`,
dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
id: calloutPolicy.id,
dismissalKey: calloutPolicy.dismissalKey,
priority: calloutPolicy.priority,
title: calloutPolicy.title,
description: calloutPolicy.description,
actions: [
{ label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" },
],
testID: `worktree-setup-callout-${activeProject.projectKey}`,
testID: calloutPolicy.testID,
});
}, [activeProject, callouts, openProjectSettings, shouldShow]);
}, [calloutPolicy, callouts, openProjectSettings]);
return null;
}

View File

@@ -1,224 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import {
SidebarCalloutProvider,
type SidebarCalloutsApi,
SidebarCalloutViewport,
useSidebarCallouts,
} from "./sidebar-callout-context";
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
function handleApi(nextApi: SidebarCalloutsApi): void {
apiSink.current = nextApi;
}
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
const api = useSidebarCallouts();
onApi(api);
return null;
}
describe("SidebarCalloutProvider", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let api: SidebarCalloutsApi | null = null;
beforeEach(async () => {
api = null;
apiSink.current = null;
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
api = apiSink.current;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
api = null;
});
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
act(() => {
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
api?.show({ id: "update", priority: 200, title: "Update available" });
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).not.toContain("Set up scripts");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).toContain("Set up scripts");
expect(container?.textContent).not.toContain("Update available");
});
it("replaces a callout by id without duplicating the queue item", () => {
act(() => {
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
});
expect(container?.textContent).toContain("New daemon");
expect(container?.textContent).toContain("v2");
expect(container?.textContent).not.toContain("Old daemon");
});
it("keeps API consumers from rerendering when callout state changes", () => {
const renders = vi.fn();
function Producer() {
const callouts = useSidebarCallouts();
renders(callouts);
useEffect(() => {
callouts.show({ id: "initial", title: "Initial" });
}, [callouts]);
return null;
}
act(() => {
root?.render(
<SidebarCalloutProvider>
<Producer />
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
});
api = apiSink.current;
const firstApi = renders.mock.calls[0]?.[0];
act(() => {
api?.show({ id: "later", priority: 10, title: "Later" });
});
expect(renders).toHaveBeenCalledTimes(1);
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
});
it("unregisters only the registration returned by show", () => {
let unregisterOld: (() => void) | null = null;
act(() => {
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
api?.show({ id: "update", title: "New" });
});
act(() => {
unregisterOld?.();
});
expect(container?.textContent).toContain("New");
});
it("persists dismissals by dismissal key", () => {
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
});
});
expect(container?.textContent).toContain("Update available");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).not.toContain("Update available");
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["desktop-update:available:1.2.3"]),
);
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
});
});
expect(container?.textContent).not.toContain("Dismissed update");
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
});
});
expect(container?.textContent).toContain("New update");
});
});

View File

@@ -8,27 +8,24 @@ import {
useRef,
useState,
} from "react";
import {
SidebarCallout,
type SidebarCalloutAction,
type SidebarCalloutProps,
type SidebarCalloutVariant,
} from "@/components/sidebar-callout";
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
clearSidebarCallouts,
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
type SidebarCalloutEntry,
type SidebarCalloutOptions,
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export type { SidebarCalloutOptions } from "./sidebar-callout-state";
export interface SidebarCalloutsApi {
show: (callout: SidebarCalloutOptions) => () => void;
@@ -36,96 +33,48 @@ export interface SidebarCalloutsApi {
clear: () => void;
}
type SidebarCalloutEntry = SidebarCalloutOptions & {
order: number;
priority: number;
token: number;
};
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
(error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
},
);
}
function selectActiveCallout(input: {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
}): SidebarCalloutEntry | null {
const visibleCallouts = input.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
void AsyncStorage.setItem(
DISMISSED_CALLOUTS_STORAGE_KEY,
serializeDismissedCalloutKeys(keys),
).catch((error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
const dismissedKeysRef = useRef<Set<string>>(new Set());
const orderRef = useRef(0);
const tokenRef = useRef(0);
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
const stateRef = useRef<SidebarCalloutState>(state);
function commitState(next: SidebarCalloutState): void {
stateRef.current = next;
setState(next);
}
useEffect(() => {
let mounted = true;
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
.then((value) => {
if (!mounted) {
return;
}
const nextKeys = parseDismissedCalloutKeys(value);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
return;
})
.catch((error) => {
async function loadDismissedKeys(): Promise<void> {
let dismissedKeys: ReadonlySet<string>;
try {
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
dismissedKeys = parseDismissedCalloutKeys(value);
} catch (error) {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
})
.finally(() => {
if (mounted) {
setDismissalStorageLoaded(true);
}
});
dismissedKeys = stateRef.current.dismissedKeys;
}
if (mounted) {
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
}
}
void loadDismissedKeys();
return () => {
mounted = false;
@@ -133,60 +82,33 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
}, []);
const show = useStableEvent((callout: SidebarCalloutOptions) => {
tokenRef.current += 1;
const token = tokenRef.current;
const current = calloutsRef.current;
const existing = current.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? ++orderRef.current,
token,
};
const next = existing
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...current, nextEntry];
calloutsRef.current = next;
setCallouts(next);
const result = showSidebarCallout(stateRef.current, callout);
commitState(result.state);
return () => {
const updated = calloutsRef.current.filter(
(entry) => entry.id !== callout.id || entry.token !== token,
commitState(
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
);
calloutsRef.current = updated;
setCallouts(updated);
};
});
const dismiss = useStableEvent((id: string) => {
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
const next = calloutsRef.current.filter((entry) => entry.id !== id);
calloutsRef.current = next;
setCallouts(next);
const result = dismissSidebarCallout(stateRef.current, id);
commitState(result.state);
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
if (dismissalKey) {
const nextKeys = new Set(dismissedKeysRef.current);
nextKeys.add(dismissalKey);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
persistDismissedCalloutKeys(nextKeys);
if (result.dismissalKey) {
persistDismissedCalloutKeys(result.state.dismissedKeys);
}
dismissed?.onDismiss?.();
result.dismissedCallout?.onDismiss?.();
});
const clear = useStableEvent(() => {
calloutsRef.current = [];
setCallouts([]);
commitState(clearSidebarCallouts(stateRef.current));
});
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
[callouts, dismissedKeys, dismissalStorageLoaded],
);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
return (
<SidebarCalloutApiContext.Provider value={api}>

View File

@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import {
clearSidebarCallouts,
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
describe("sidebar callout state", () => {
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "onboarding",
priority: 10,
title: "Set up scripts",
}).state;
state = showSidebarCallout(state, {
id: "update",
priority: 200,
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
state = dismissSidebarCallout(state, "update").state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
});
it("replaces a callout by id without duplicating the queue item", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "daemon",
title: "Old daemon",
description: "v1",
}).state;
state = showSidebarCallout(state, {
id: "daemon",
title: "New daemon",
description: "v2",
}).state;
expect(state.callouts).toMatchObject([
{
id: "daemon",
title: "New daemon",
description: "v2",
},
]);
});
it("unregisters only the registration returned by show", () => {
let state = createSidebarCalloutState();
const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" });
state = oldRegistration.state;
state = showSidebarCallout(state, { id: "update", title: "New" }).state;
state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token });
expect(selectActiveSidebarCallout(state)?.title).toBe("New");
});
it("persists dismissals by dismissal key and hides matching future callouts", () => {
const onDismiss = vi.fn();
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set());
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
onDismiss,
}).state;
const result = dismissSidebarCallout(state, "update");
state = result.state;
expect(result.dismissalKey).toBe("desktop-update:available:1.2.3");
expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe(
JSON.stringify(["desktop-update:available:1.2.3"]),
);
expect(onDismiss).not.toHaveBeenCalled();
result.dismissedCallout?.onDismiss?.();
expect(onDismiss).toHaveBeenCalledOnce();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("New update");
});
it("waits for dismissal storage before showing dismissible callouts", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = loadDismissedCalloutKeys(state, new Set());
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
});
it("parses stored dismissal keys defensively", () => {
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
});
it("clears visible callouts without dropping dismissal state", () => {
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
state = clearSidebarCallouts(state);
expect(state.callouts).toEqual([]);
expect(state.dismissedKeys).toEqual(new Set(["dismissed"]));
});
});

View File

@@ -0,0 +1,162 @@
import type { ReactNode } from "react";
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
order: number;
priority: number;
token: number;
}
export interface SidebarCalloutState {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
nextOrder: number;
nextToken: number;
}
export function createSidebarCalloutState(): SidebarCalloutState {
return {
callouts: [],
dismissedKeys: new Set(),
dismissalStorageLoaded: false,
nextOrder: 0,
nextToken: 0,
};
}
export function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
return JSON.stringify([...keys]);
}
export function loadDismissedCalloutKeys(
state: SidebarCalloutState,
dismissedKeys: ReadonlySet<string>,
): SidebarCalloutState {
return {
...state,
dismissedKeys: new Set(dismissedKeys),
dismissalStorageLoaded: true,
};
}
export function showSidebarCallout(
state: SidebarCalloutState,
callout: SidebarCalloutOptions,
): { state: SidebarCalloutState; token: number } {
const token = state.nextToken + 1;
const existing = state.callouts.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? state.nextOrder + 1,
token,
};
const callouts = existing
? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...state.callouts, nextEntry];
return {
state: {
...state,
callouts,
nextOrder: existing ? state.nextOrder : state.nextOrder + 1,
nextToken: token,
},
token,
};
}
export function unregisterSidebarCallout(
state: SidebarCalloutState,
input: { id: string; token: number },
): SidebarCalloutState {
const callouts = state.callouts.filter(
(entry) => entry.id !== input.id || entry.token !== input.token,
);
return callouts.length === state.callouts.length ? state : { ...state, callouts };
}
export function dismissSidebarCallout(
state: SidebarCalloutState,
id: string,
): {
state: SidebarCalloutState;
dismissedCallout: SidebarCalloutEntry | null;
dismissalKey: string | null;
} {
const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null;
const callouts = state.callouts.filter((entry) => entry.id !== id);
const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey);
const dismissedKeys = dismissalKey
? new Set([...state.dismissedKeys, dismissalKey])
: state.dismissedKeys;
return {
state: {
...state,
callouts,
dismissedKeys,
},
dismissedCallout,
dismissalKey,
};
}
export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState {
return { ...state, callouts: [] };
}
export function selectActiveSidebarCallout(
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("expo-image-manipulator", () => ({
SaveFormat: { PNG: "png" },
ImageManipulator: {
manipulate: (_source: string) => ({
async renderAsync() {
return {
release() {},
async saveAsync(options: { format?: string }) {
return {
uri:
options.format === "png"
? "file:///cache/ImageManipulator/safe-picked.png"
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
width: 100,
height: 100,
};
},
};
},
release() {},
}),
},
}));
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
describe("native image attachment picker", () => {
it("preserves native picked JPEG and PNG attachment inputs", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.JPG",
mimeType: "image/jpeg",
fileName: "picked.jpeg",
},
{
uri: "file:///photos/screenshot.png",
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
mimeType: "image/jpeg",
fileName: "picked.jpg",
},
{
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
});
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.HEIC",
mimeType: "image/png",
fileName: "picked.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
});

View File

@@ -0,0 +1,129 @@
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
mimeType?: string | null;
fileName?: string | null;
}
export interface ExpoImagePickerAssetLike {
uri: string;
mimeType?: string | null;
fileName?: string | null;
file?: File | null;
}
interface SupportedPickedImageFormat {
mimeType: "image/jpeg" | "image/png";
extension: "jpg" | "png";
}
const JPEG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/jpeg",
extension: "jpg",
};
const PNG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/png",
extension: "png",
};
function extensionFromPath(path: string | null | undefined): string | null {
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
return match?.[1]?.toLowerCase() ?? null;
}
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
if (extension === "jpg" || extension === "jpeg") {
return JPEG_FORMAT;
}
if (extension === "png") {
return PNG_FORMAT;
}
return null;
}
function supportedFormatForMimeType(
mimeType: string | null | undefined,
): SupportedPickedImageFormat | null {
const normalizedMimeType = mimeType?.toLowerCase();
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
return JPEG_FORMAT;
}
if (normalizedMimeType === "image/png") {
return PNG_FORMAT;
}
return null;
}
function pickedAssetSupportedFormat(
asset: ExpoImagePickerAssetLike,
): SupportedPickedImageFormat | null {
const uriExtension = extensionFromPath(asset.uri);
if (uriExtension) {
return supportedFormatForExtension(uriExtension);
}
return (
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
supportedFormatForMimeType(asset.mimeType)
);
}
function replaceFileExtension(
fileName: string | null | undefined,
extension: SupportedPickedImageFormat["extension"],
): string | null {
if (!fileName) {
return null;
}
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
}
async function exportPickedImageAsPng(uri: string): Promise<string> {
const context = ImageManipulator.manipulate(uri);
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
try {
image = await context.renderAsync();
const result = await image.saveAsync({
format: SaveFormat.PNG,
});
return result.uri;
} finally {
image?.release();
context.release();
}
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
const supportedFormat = pickedAssetSupportedFormat(asset);
if (supportedFormat) {
return {
source: { kind: "file_uri", uri: asset.uri },
mimeType: supportedFormat.mimeType,
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
};
}
const convertedUri = await exportPickedImageAsPng(asset.uri);
return {
source: { kind: "file_uri", uri: convertedUri },
mimeType: PNG_FORMAT.mimeType,
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
};
}),
);
}
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
throw new Error("Desktop dialog API is not available on native.");
}

View File

@@ -148,9 +148,16 @@ describe("keyboard-shortcuts", () => {
payload: { index: 2 },
},
{
name: "matches tab index jump on desktop via Alt+digit",
name: "matches tab index jump on mac desktop via Cmd+Alt+digit",
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
context: { isMac: true, isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on non-mac desktop via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true },
context: { isDesktop: true },
context: { isMac: false, isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
@@ -333,6 +340,11 @@ describe("keyboard-shortcuts", () => {
event: { key: "t", code: "KeyT", ctrlKey: true },
context: { isMac: true },
},
{
name: "keeps mac Option+digit available for international text input",
event: { key: "@", code: "Digit2", altKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
},
{
name: "does not match Ctrl+K for command center on non-mac in terminal",
event: { key: "k", code: "KeyK", ctrlKey: true },
@@ -477,16 +489,17 @@ describe("keyboard-shortcut help sections", () => {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
},
{
name: "shows Ctrl+W close tab for non-mac desktop",
name: "uses non-mac desktop defaults for tab jump and close tab",
context: { isMac: false, isDesktop: true },
expectedKeys: {
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["ctrl", "W"],
},
},

View File

@@ -292,11 +292,24 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Tab index jump ---
{
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
action: "workspace.tab.navigate.index",
combo: "Cmd+Alt+Digit",
when: { mac: true, desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
label: "Jump to tab",
keys: ["mod", "alt", "1-9"],
},
},
{
id: "workspace-tab-navigate-index-alt-digit-desktop",
action: "workspace.tab.navigate.index",
combo: "Alt+Digit",
when: { desktop: true, commandCenter: false },
when: { mac: false, desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",

View File

@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const expoCryptoMock = vi.hoisted(() => ({
getRandomValues: vi.fn(<T extends ArrayBufferView>(array: T): T => array),
randomUUID: vi.fn(() => {
throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback");
}),
}));
vi.mock("expo-crypto", () => expoCryptoMock);
describe("polyfillCrypto", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
expoCryptoMock.getRandomValues.mockClear();
expoCryptoMock.randomUUID.mockClear();
});
it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => {
const sourceBytes = Uint8Array.from([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
0xff,
]);
const getRandomValues = vi.fn(<T extends ArrayBufferView | null>(array: T): T => {
if (array && ArrayBuffer.isView(array)) {
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes);
}
return array;
});
vi.stubGlobal("crypto", { getRandomValues });
const { polyfillCrypto } = await import("./crypto");
polyfillCrypto();
expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff");
expect(getRandomValues).toHaveBeenCalledTimes(1);
expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled();
});
});

View File

@@ -13,8 +13,26 @@ interface MutableGlobal {
crypto?: Crypto;
}
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
const bytes = fillRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
}
export function polyfillCrypto(): void {
const g = globalThis as unknown as MutableGlobal;
const nativeGetRandomValues =
typeof g.crypto?.getRandomValues === "function"
? g.crypto.getRandomValues.bind(g.crypto)
: null;
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
// Hermes may not provide them in all configurations.
@@ -47,17 +65,21 @@ export function polyfillCrypto(): void {
g.crypto = {} as Crypto;
}
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
if (nativeGetRandomValues) {
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
}
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
if (typeof g.crypto.randomUUID !== "function") {
g.crypto.randomUUID = () =>
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
g.crypto.randomUUID = () => createUuidV4(fillRandomValues);
}
if (typeof g.crypto.getRandomValues !== "function") {
g.crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
g.crypto.getRandomValues = fillRandomValues;
}
}

View File

@@ -203,6 +203,15 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
};
}
function encodeOfferUrl(payload: unknown): string {
const encoded = Buffer.from(JSON.stringify(payload), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
return `https://app.paseo.sh/#offer=${encoded}`;
}
function makeDeps(
latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[],
@@ -1734,6 +1743,41 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const oldPairingUrl = encodeOfferUrl({
v: 2,
serverId: "srv_offer",
daemonPublicKeyB64: "pk_test_offer",
relay: { endpoint: "relay.paseo.sh:443" },
});
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
{
id: "relay:wss:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_test_offer",
},
]);
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {

View File

@@ -1506,10 +1506,12 @@ export class HostRuntimeStore {
}
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
useTls: offer.relay.useTls,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
});

View File

@@ -720,9 +720,16 @@ function SettingsSidebar({
}, [hosts, localServerId]);
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
const padding = useWindowControlsPadding("sidebar");
const isDesktop = layout === "desktop";
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
const containerStyle = useMemo(
() => [
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
isDesktop ? { paddingTop: insets.top } : null,
],
[insets.top, isDesktop],
);
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
upsertCreatedTerminalPayload,
type ListTerminalsPayload,
} from "@/screens/workspace/terminals/state";
import type { CreateTerminalResponse } from "@server/shared/messages";
function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] {
return { id, name: id, title: id };
}
function createdTerminal(id: string): NonNullable<CreateTerminalResponse["payload"]["terminal"]> {
return { id, name: id, cwd: "/repo", title: id };
}
describe("workspace terminal state", () => {
it("keeps pending script terminals until they appear or a fresher list arrives", () => {
const pending = new Map([
["older-than-list", 10],
["now-live", 20],
["still-pending", 30],
]);
const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending);
expect(reconciled).toEqual(new Map([["still-pending", 30]]));
});
it("returns the same pending map when reconciliation changes nothing", () => {
const pending = new Map([["still-pending", 30]]);
const reconciled = reconcilePendingScriptTerminals([], 20)(pending);
expect(reconciled).toBe(pending);
});
it("combines live and pending terminal ids without duplicating script terminals", () => {
const pendingScriptTerminalIds = new Map([
["script-pending", 10],
["terminal-1", 10],
]);
expect(
collectKnownTerminalIds({
liveTerminalIds: ["terminal-1", "terminal-2"],
pendingScriptTerminalIds,
}),
).toEqual(["terminal-1", "terminal-2", "script-pending"]);
expect(
collectScriptTerminalIds({
pendingScriptTerminalIds,
scripts: [{ terminalId: "script-live" }, { terminalId: null }],
}),
).toEqual(new Set(["script-pending", "terminal-1", "script-live"]));
expect(
collectStandaloneTerminalIds({
terminals: [
listedTerminal("terminal-1"),
listedTerminal("terminal-2"),
listedTerminal("script-live"),
],
scriptTerminalIds: new Set(["terminal-1", "script-live"]),
}),
).toEqual(["terminal-2"]);
});
it("updates terminal cache entries for created and closed terminals", () => {
const current: ListTerminalsPayload = {
cwd: "/repo",
requestId: "existing",
terminals: [listedTerminal("terminal-1")],
};
expect(
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal("terminal-2"),
workspaceDirectory: "/repo",
}),
).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [
listedTerminal("terminal-1"),
{ id: "terminal-2", name: "terminal-2", title: "terminal-2" },
],
});
expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [],
});
});
});

View File

@@ -0,0 +1,106 @@
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
export const TERMINALS_QUERY_STALE_TIME = 5_000;
export type ListTerminalsPayload = ListTerminalsResponse["payload"];
type TerminalEntry = ListTerminalsPayload["terminals"][number];
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
return ["terminals", serverId, workspaceDirectory] as const;
}
export function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
export function collectKnownTerminalIds(input: {
liveTerminalIds: string[];
pendingScriptTerminalIds: Map<string, number>;
}): string[] {
const terminalIds = new Set(input.liveTerminalIds);
for (const terminalId of input.pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}
export function collectScriptTerminalIds(input: {
pendingScriptTerminalIds: Map<string, number>;
scripts: Array<{ terminalId?: string | null }>;
}): Set<string> {
const terminalIds = new Set(input.pendingScriptTerminalIds.keys());
for (const script of input.scripts) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}
export function collectStandaloneTerminalIds(input: {
terminals: TerminalEntry[];
scriptTerminalIds: Set<string>;
}): string[] {
return input.terminals
.filter((terminal) => !input.scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id);
}
export function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
export function upsertCreatedTerminalPayload(input: {
current: ListTerminalsPayload | undefined;
terminal: CreatedTerminal;
workspaceDirectory: string | null;
}): ListTerminalsPayload {
const nextTerminals = upsertTerminalListEntry({
terminals: input.current?.terminals ?? [],
terminal: input.terminal,
});
const cwd = input.current?.cwd ?? input.workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`,
};
}

View File

@@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildTerminalsQueryKey,
canCreateWorkspaceTerminal,
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
TERMINALS_QUERY_STALE_TIME,
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state";
interface PendingTerminalCreateInput {
paneId?: string;
}
interface UseWorkspaceTerminalsInput {
client: DaemonClient | null;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
workspaceScripts: WorkspaceDescriptor["scripts"];
hasHydratedWorkspaces: boolean;
isMissingWorkspaceExecutionAuthority: boolean;
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
onScriptTerminalSelected: (terminalId: string) => void;
onWorkspacePathUnavailable: () => void;
onTerminalCreateQueued: () => void;
}
export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
const {
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreated,
onScriptTerminalSelected,
onWorkspacePathUnavailable,
onTerminalCreateQueued,
} = input;
const queryClient = useQueryClient();
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
null,
);
const canCreateNow = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const queryKey = useMemo(
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
[normalizedServerId, workspaceDirectory],
);
const query = useQuery({
queryKey,
enabled: canCreateNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
});
const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]);
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
const dataUpdatedAt = query.dataUpdatedAt;
useEffect(() => {
setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt));
}, [liveTerminalIds, dataUpdatedAt]);
const knownTerminalIds = useMemo(
() => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }),
[liveTerminalIds, pendingScriptTerminalIds],
);
const scriptTerminalIds = useMemo(
() => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }),
[pendingScriptTerminalIds, workspaceScripts],
);
const standaloneTerminalIds = useMemo(
() => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }),
[scriptTerminalIds, terminals],
);
const createMutation = useMutation({
mutationFn: async (_input?: PendingTerminalCreateInput) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, createInput) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) =>
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal,
workspaceDirectory,
}),
);
}
void queryClient.invalidateQueries({ queryKey });
if (createdTerminal) {
onTerminalCreated({
terminalId: createdTerminal.id,
paneId: createInput?.paneId,
});
}
},
});
const killMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
});
useEffect(() => {
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
return;
}
const unsubscribeChanged = client.on("terminals_changed", (message) => {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
useEffect(() => {
if (!pendingCreateInput) {
return;
}
if (canCreateNow && !createMutation.isPending) {
const pendingInput = pendingCreateInput;
setPendingCreateInput(null);
createMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingCreateInput(null);
onWorkspacePathUnavailable();
}
}, [
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onWorkspacePathUnavailable,
pendingCreateInput,
]);
const createTerminal = useCallback(
(createInput?: PendingTerminalCreateInput) => {
if (createMutation.isPending || pendingCreateInput) {
return;
}
if (canCreateNow) {
createMutation.mutate(createInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
onWorkspacePathUnavailable();
return;
}
setPendingCreateInput(createInput ?? {});
onTerminalCreateQueued();
},
[
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreateQueued,
onWorkspacePathUnavailable,
pendingCreateInput,
],
);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, query.dataUpdatedAt);
return nextTerminalIds;
});
onScriptTerminalSelected(terminalId);
void queryClient.invalidateQueries({ queryKey });
},
[onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
onScriptTerminalSelected(terminalId);
},
[onScriptTerminalSelected],
);
const removeTerminalFromCache = useCallback(
(terminalId: string) => {
queryClient.setQueryData<ListTerminalsPayload>(
queryKey,
removeTerminalFromPayload(terminalId),
);
},
[queryClient, queryKey],
);
const invalidateTerminals = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return {
canCreateNow,
createMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput,
query,
queryKey,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
};
}

View File

@@ -11,7 +11,7 @@ import {
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
@@ -94,8 +94,6 @@ import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -158,10 +156,12 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
@@ -1028,8 +1028,6 @@ function WorkspaceHeaderTitleBar({
);
}
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type PaneDirection = "left" | "right" | "up" | "down";
function parsePaneDirection(actionId: string): PaneDirection | null {
@@ -1180,39 +1178,6 @@ function resolveWorkspaceAuthorityState(
};
}
function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback;
@@ -1359,17 +1324,6 @@ function shouldShowWorkspaceExplorerSidebar(input: {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
}
function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
@@ -1377,6 +1331,108 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`;
}
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
toast: {
error: (message: string) => void;
show: (message: string) => void;
};
}
interface WorkspaceTerminalTabActions {
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
handleScriptTerminalSelected: (terminalId: string) => void;
handleWorkspacePathUnavailable: () => void;
handleTerminalCreateQueued: () => void;
}
function useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
if (!persistenceKey) {
return;
}
if (paneId) {
focusWorkspacePane(persistenceKey, paneId);
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error("Workspace path is not available yet");
}, [toast]);
const handleTerminalCreateQueued = useCallback(() => {
toast.show("Preparing workspace, opening terminal when ready...");
}, [toast]);
return {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
};
}
function useWorkspaceCheckoutStatus(input: {
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}) {
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
isRouteFocused: input.isRouteFocused,
client: input.client,
isConnected: input.isConnected,
workspaceDirectory: input.workspaceDirectory,
}),
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
input.normalizedServerId,
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error("Host is not connected");
}
return await input.client.getCheckoutStatus(input.workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
return { checkoutQuery, isCheckoutStatusLoading };
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -1405,7 +1461,6 @@ function WorkspaceScreenContent({
scopeKey: workspaceTerminalScopeKey,
});
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceAuthority = useMemo(
@@ -1430,12 +1485,19 @@ function WorkspaceScreenContent({
useProvidersSnapshot(normalizedServerId, {
enabled: isRouteFocused,
});
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
paneId?: string;
} | null>(null);
const canCreateTerminalNow = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const workspaceAgentVisibility = useStoreWithEqualityFn(
@@ -1449,167 +1511,56 @@ function WorkspaceScreenContent({
workspaceAgentVisibilityEqual,
);
const terminalsQueryKey = useMemo(
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
[normalizedServerId, workspaceDirectory],
);
const terminalsQuery = useQuery({
queryKey: terminalsQueryKey,
enabled: canCreateTerminalNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
const {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
} = useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
});
const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
useEffect(() => {
setPendingScriptTerminalIds(
reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
);
}, [liveTerminalIds, terminalsDataUpdatedAt]);
const knownTerminalIds = useMemo(() => {
const terminalIds = new Set(liveTerminalIds);
for (const terminalId of pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}, [liveTerminalIds, pendingScriptTerminalIds]);
const scriptTerminalIds = useMemo(() => {
const terminalIds = new Set(pendingScriptTerminalIds.keys());
for (const script of workspaceDescriptor?.scripts ?? []) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
const standaloneTerminalIds = useMemo(
() =>
terminals
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id),
[scriptTerminalIds, terminals],
);
const createTerminalMutation = useMutation({
mutationFn: async (_input?: { paneId?: string }) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, input) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
const cwd = current?.cwd ?? workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
if (createdTerminal) {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
if (!workspaceKey) {
return;
}
if (input?.paneId) {
focusWorkspacePane(workspaceKey, input.paneId);
}
useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
}
},
});
const killTerminalMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
const {
createMutation: createTerminalMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation: killTerminalMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput: pendingTerminalCreateInput,
query: terminalsQuery,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
} = useWorkspaceTerminals({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreated: handleTerminalCreated,
onScriptTerminalSelected: handleScriptTerminalSelected,
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
onTerminalCreateQueued: handleTerminalCreateQueued,
});
const { archiveAgent } = useArchiveAgent();
useEffect(() => {
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
return;
}
const unsubscribeChanged = client.on("terminals_changed", (message) => {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
const isCheckoutQueryEnabled = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
normalizedServerId,
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.getCheckoutStatus(workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
);
@@ -1618,30 +1569,6 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});
useEffect(() => {
if (!pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
const pendingInput = pendingTerminalCreateInput;
setPendingTerminalCreateInput(null);
createTerminalMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingTerminalCreateInput(null);
toast.error("Workspace path is not available yet");
}
}, [
canCreateTerminalNow,
createTerminalMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
pendingTerminalCreateInput,
toast,
]);
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading,
isError: checkoutQuery.isError,
@@ -1738,15 +1665,6 @@ function WorkspaceScreenContent({
return () => handler.remove();
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
);
@@ -1761,7 +1679,6 @@ function WorkspaceScreenContent({
[workspaceLayout],
);
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground,
);
@@ -1777,39 +1694,6 @@ function WorkspaceScreenContent({
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
return nextTerminalIds;
});
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
},
[
openWorkspaceTabFocused,
persistenceKey,
queryClient,
terminalsQuery.dataUpdatedAt,
terminalsQueryKey,
],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -2200,24 +2084,7 @@ function WorkspaceScreenContent({
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
);
const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow) {
createTerminalMutation.mutate(input);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
toast.error("Workspace path is not available yet");
return;
}
setPendingTerminalCreateInput(input ?? {});
toast.show("Preparing workspace, opening terminal when ready...");
});
const handleCreateTerminal = useStableEvent(createTerminal);
const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => {
@@ -2284,10 +2151,7 @@ function WorkspaceScreenContent({
return;
}
queryClient.setQueryData<ListTerminalsPayload>(
terminalsQueryKey,
removeTerminalFromPayload(terminalId),
);
removeTerminalFromCache(terminalId);
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
@@ -2297,18 +2161,16 @@ function WorkspaceScreenContent({
});
}
void killTerminalAsync(terminalId).catch(() => {
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
});
void killTerminalAsync(terminalId).catch(invalidateTerminals);
});
},
[
closeTab,
closeWorkspaceTabWithCleanup,
invalidateTerminals,
killTerminalAsync,
persistenceKey,
queryClient,
terminalsQueryKey,
removeTerminalFromCache,
],
);

View File

@@ -1,5 +1,7 @@
import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
@@ -86,7 +88,7 @@ interface InsertSplitInternalInput {
targetPaneId: string;
tabId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
}
interface InsertSplitInternalResult {
@@ -142,7 +144,7 @@ interface SplitPaneInLayoutInput {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
maxTreeDepth: number;
}
@@ -155,7 +157,7 @@ interface SplitPaneEmptyInLayoutInput {
layout: WorkspaceLayout;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
maxTreeDepth: number;
}
@@ -232,14 +234,6 @@ function normalizeTabIds(list: unknown): string[] {
return next;
}
function generateNodeId(prefix: "pane" | "group"): string {
const randomValue =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}_${randomValue}`;
}
function createPaneNode(input: {
id: string;
tabs?: WorkspaceTab[];
@@ -996,13 +990,16 @@ export function insertSplit(
targetPaneId: string,
tabId: string,
position: "left" | "right" | "top" | "bottom",
createNodeId: (
prefix: WorkspaceLayoutNodeIdPrefix,
) => string = defaultWorkspaceLayoutIds.createNodeId,
): SplitNode {
return insertSplitInternal({
root: asInternalNode(root),
targetPaneId,
tabId,
position,
createNodeId: generateNodeId,
createNodeId,
}).root;
}

View File

@@ -0,0 +1,17 @@
export type WorkspaceLayoutNodeIdPrefix = "pane" | "group";
export interface WorkspaceLayoutIdSource {
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
createFocusRestorationToken: () => string;
}
function createRandomIdValue(): string {
return typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const defaultWorkspaceLayoutIds: WorkspaceLayoutIdSource = {
createNodeId: (prefix) => `${prefix}_${createRandomIdValue()}`,
createFocusRestorationToken: () => `workspace-focus-${createRandomIdValue()}`,
};

View File

@@ -20,6 +20,7 @@ import {
buildWorkspaceTabPersistenceKey,
collectAllPanes,
collectAllTabs,
createWorkspaceLayoutStore,
createDefaultLayout,
findPaneById,
findPaneContainingTab,
@@ -28,7 +29,6 @@ import {
insertSplit,
removePaneFromTree,
removeTabFromTree,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
} from "@/stores/workspace-layout-store";
@@ -36,6 +36,40 @@ import {
const SERVER_ID = "server-1";
const WORKSPACE_ID = "ws-main";
function createDeterministicWorkspaceLayoutIds() {
let values: string[] = [];
let fallbackIndex = 0;
function nextValue(): string {
const value = values.shift();
if (value) {
return value;
}
fallbackIndex += 1;
return `generated-${fallbackIndex}`;
}
return {
useValues: (nextValues: string[]) => {
values = nextValues.slice();
fallbackIndex = 0;
},
reset: () => {
values = [];
fallbackIndex = 0;
},
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
};
}
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
function useWorkspaceLayoutIds(...values: string[]) {
workspaceLayoutIds.useValues(values);
}
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
return {
tabId,
@@ -168,13 +202,14 @@ describe("workspace-layout-store helpers", () => {
describe("workspace-layout-store tree transforms", () => {
beforeEach(() => {
vi.restoreAllMocks();
workspaceLayoutIds.reset();
});
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
useWorkspaceLayoutIds(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
);
const root: SplitNode = {
kind: "group",
@@ -189,7 +224,7 @@ describe("workspace-layout-store tree transforms", () => {
},
};
const nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId);
const nextGroup = expectGroup(nextRoot);
const nestedGroup = expectGroup(nextGroup.group.children[1]);
@@ -270,22 +305,20 @@ describe("workspace-layout-store tree transforms", () => {
describe("workspace-layout-store actions", () => {
beforeEach(() => {
useWorkspaceLayoutStore.setState({
workspaceLayoutIds.reset();
workspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
});
vi.restoreAllMocks();
});
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
);
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -308,7 +341,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/b.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
@@ -322,14 +355,14 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground inserts a tab without stealing focus", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const setupTabId = store.openTabInBackground(workspaceKey, {
kind: "setup",
workspaceId: "ws-main",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layout.root, "main")!;
expect(agentTabId).toBe("agent_agent-1");
@@ -341,7 +374,7 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground on an existing target is a no-op", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -355,7 +388,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/a.ts",
});
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layoutAfter.root, "main")!;
expect(duplicateTabId).toBe(firstTabId);
@@ -365,27 +398,25 @@ describe("workspace-layout-store actions", () => {
it("unfocuses and restores the previous focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const token = store.unfocusPane(workspaceKey);
expect(token).toBeTruthy();
expect(
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull();
store.restorePaneFocus(workspaceKey, token!);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main",
);
});
it("does not restore stale focus after another pane is focused", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.splitPane(workspaceKey, {
@@ -399,14 +430,14 @@ describe("workspace-layout-store actions", () => {
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
store.restorePaneFocus(workspaceKey, token!);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
});
it("waits for nested focus restorations before restoring", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const outerToken = store.unfocusPane(workspaceKey);
@@ -414,22 +445,22 @@ describe("workspace-layout-store actions", () => {
store.restorePaneFocus(workspaceKey, outerToken!);
expect(
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull();
store.restorePaneFocus(workspaceKey, innerToken!);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main",
);
});
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("draft-1");
expect(secondTabId).toBe("draft-2");
@@ -450,11 +481,9 @@ describe("workspace-layout-store actions", () => {
});
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"77777777-7777-7777-7777-777777777777",
);
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const newPaneId = store.splitPaneEmpty(workspaceKey, {
@@ -465,7 +494,7 @@ describe("workspace-layout-store actions", () => {
kind: "draft",
draftId: "draft-split",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
expect(draftTabId).toBe("draft-split");
@@ -476,11 +505,9 @@ describe("workspace-layout-store actions", () => {
});
it("focusTab moves workspace focus to the pane containing the tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const fileTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -497,22 +524,20 @@ describe("workspace-layout-store actions", () => {
});
store.focusTab(workspaceKey, fileTabId!);
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main");
store.focusTab(workspaceKey, terminalTabId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
});
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212",
);
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
@@ -523,7 +548,7 @@ describe("workspace-layout-store actions", () => {
});
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const splitPane = findPaneById(layout.root, splitPaneId);
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
@@ -540,7 +565,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab keeps a draft tab in place while updating its target", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -550,7 +575,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/retargeted.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(draftTabId).toBe("draft-retarget");
expect(nextTabId).toBe(draftTabId);
@@ -565,11 +590,9 @@ describe("workspace-layout-store actions", () => {
});
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"55555555-5555-5555-5555-555555555555",
);
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const existingFileTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -590,7 +613,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/existing.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
expect(draftTabId).toBe("draft-dup");
@@ -606,7 +629,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab closes a draft tab and focuses an existing matching target tab", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstDraftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -625,7 +648,7 @@ describe("workspace-layout-store actions", () => {
kind: "agent",
agentId: "agent-1",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstAgentTabId).toBe(firstDraftTabId);
expect(nextTabId).toBe(firstDraftTabId);
@@ -641,7 +664,7 @@ describe("workspace-layout-store actions", () => {
it("reorderTabs reorders tabs within the focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -657,7 +680,7 @@ describe("workspace-layout-store actions", () => {
});
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")).toEqual({
id: "main",
@@ -684,11 +707,9 @@ describe("workspace-layout-store actions", () => {
});
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"34343434-3434-3434-3434-343434343434",
);
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.openTabFocused(workspaceKey, {
@@ -712,7 +733,7 @@ describe("workspace-layout-store actions", () => {
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
store.focusPane(workspaceKey, "main");
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
expect(layout.focusedPaneId).toBe("main");
@@ -736,11 +757,9 @@ describe("workspace-layout-store actions", () => {
});
it("focusPane switches workspace focus to a different pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"56565656-5656-5656-5656-565656565656",
);
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -754,22 +773,20 @@ describe("workspace-layout-store actions", () => {
});
store.focusPane(workspaceKey, "main");
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main");
store.focusPane(workspaceKey, splitPaneId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
expect(layout.focusedPaneId).toBe(splitPaneId);
});
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"cccccccc-cccc-cccc-cccc-cccccccccccc",
);
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -783,7 +800,7 @@ describe("workspace-layout-store actions", () => {
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
expect(layout.focusedPaneId).toBe("main");
@@ -791,18 +808,19 @@ describe("workspace-layout-store actions", () => {
});
it("splitPane enforces the maximum depth of four", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
useWorkspaceLayoutIds(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444",
"55555555-5555-5555-5555-555555555555",
"66666666-6666-6666-6666-666666666666",
"77777777-7777-7777-7777-777777777777",
"88888888-8888-8888-8888-888888888888",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
@@ -831,7 +849,7 @@ describe("workspace-layout-store actions", () => {
position: "bottom",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
@@ -840,11 +858,9 @@ describe("workspace-layout-store actions", () => {
});
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"dddddddd-dddd-dddd-dddd-dddddddddddd",
);
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const leftTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -861,7 +877,7 @@ describe("workspace-layout-store actions", () => {
});
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
@@ -872,13 +888,14 @@ describe("workspace-layout-store actions", () => {
});
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
useWorkspaceLayoutIds(
"78787878-7878-7878-7878-787878787878",
"89898989-8989-8989-8989-898989898989",
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -901,7 +918,7 @@ describe("workspace-layout-store actions", () => {
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
@@ -937,11 +954,9 @@ describe("workspace-layout-store actions", () => {
});
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"abababab-abab-abab-abab-abababababab",
);
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -959,7 +974,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/b.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
expect(duplicateTabId).toBe(secondTabId);
@@ -971,13 +986,14 @@ describe("workspace-layout-store actions", () => {
});
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
useWorkspaceLayoutIds(
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
"ffffffff-ffff-ffff-ffff-ffffffffffff",
"11111111-1111-1111-1111-111111111111",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
@@ -995,12 +1011,12 @@ describe("workspace-layout-store actions", () => {
position: "right",
});
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitGroup = expectGroup(splitRoot);
const nestedGroup = expectGroup(splitGroup.group.children[1]);
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedGroup = expectGroup(resizedRoot);
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
@@ -1014,11 +1030,11 @@ describe("workspace-layout-store actions", () => {
it("closing the last tab keeps a single empty pane in the layout", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.closeTab(workspaceKey, tabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout).toEqual(createDefaultLayout());
});
@@ -1032,12 +1048,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(otherWorkspaceKey as string, "agent-2");
let state = useWorkspaceLayoutStore.getState();
let state = workspaceLayoutStore.getState();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
@@ -1045,13 +1061,13 @@ describe("workspace-layout-store actions", () => {
store.unpinAgent(workspaceKey, "agent-1");
state = useWorkspaceLayoutStore.getState();
state = workspaceLayoutStore.getState();
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
]);
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
@@ -1068,12 +1084,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(otherWorkspaceKey as string, "agent-2");
let state = useWorkspaceLayoutStore.getState();
let state = workspaceLayoutStore.getState();
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
@@ -1081,13 +1097,13 @@ describe("workspace-layout-store actions", () => {
store.unhideAgent(workspaceKey, "agent-1");
state = useWorkspaceLayoutStore.getState();
state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
]);
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
@@ -1096,11 +1112,9 @@ describe("workspace-layout-store actions", () => {
});
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"67676767-6767-6767-6767-676767676767",
);
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -1114,7 +1128,7 @@ describe("workspace-layout-store actions", () => {
});
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
expect(nextTabId).toBe("agent_agent-1");
@@ -1126,7 +1140,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.setState((state) => ({
workspaceLayoutStore.setState((state) => ({
...state,
layoutByWorkspace: {
...state.layoutByWorkspace,
@@ -1169,7 +1183,7 @@ describe("workspace-layout-store actions", () => {
},
}));
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
@@ -1179,7 +1193,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = collectAllTabs(layout.root);
expect(tabs.map((tab) => tab.tabId)).toEqual([
@@ -1200,14 +1214,14 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.setState((state) => ({
workspaceLayoutStore.setState((state) => ({
...state,
hiddenAgentIdsByWorkspace: {
[workspaceKey]: new Set<string>(["agent-1"]),
},
}));
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
@@ -1217,13 +1231,13 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"],
@@ -1234,7 +1248,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
useWorkspaceLayoutStore
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1243,7 +1257,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1258,7 +1272,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
useWorkspaceLayoutStore
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1267,7 +1281,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1282,7 +1296,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
useWorkspaceLayoutStore
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1291,7 +1305,7 @@ describe("workspace-layout-store actions", () => {
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "child-agent");
store.reconcileTabs(workspaceKey, {
@@ -1304,11 +1318,11 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_child-agent",
@@ -1317,7 +1331,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const scriptTabId = store.openTabFocused(workspaceKey, {
kind: "terminal",
@@ -1335,8 +1349,8 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
});
@@ -1344,7 +1358,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
@@ -1355,46 +1369,44 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
it("explicitly opening an agent tab clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
});
it("pinning an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
expect(
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
).toBeDefined();
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
store.pinAgent(workspaceKey, "agent-1");
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
});
it("retargeting a tab to an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,142 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
let nextConnectError: Error | null = null;
let nextLastError: string | null = null;
class FakeDaemonClient implements DaemonProbeClient {
readonly lastError: string | null;
class MockDaemonClient {
public lastError: string | null = nextLastError;
private lastServerInfo = {
status: "server_info" as const,
serverId: "srv_probe_test",
hostname: "probe-host" as string | null,
version: "0.0.0",
};
constructor(
private readonly probe: FakeDaemonProbe,
readonly config: DaemonClientConfig,
) {
this.lastError = probe.nextLastError;
}
constructor(config: { clientId?: string; url?: string; password?: string }) {
createdConfigs.push(config);
}
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(): () => void {
return () => undefined;
}
async connect(): Promise<void> {
if (nextConnectError) {
throw nextConnectError;
}
return;
}
getLastServerInfoMessage() {
return this.lastServerInfo;
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
async connect(): Promise<void> {
if (this.probe.nextConnectError) {
throw this.probe.nextConnectError;
}
}
return {
MockDaemonClient,
createdConfigs,
setNextConnectFailure: (error: Error, lastError: string | null) => {
nextConnectError = error;
nextLastError = lastError;
getLastServerInfoMessage() {
return {
serverId: "srv_probe_test",
hostname: "probe-host",
};
}
async close(): Promise<void> {
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
nextConnectError: Error | null = null;
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
},
reset: () => {
createdConfigs.length = 0;
nextConnectError = null;
nextLastError = null;
resolveAppVersion: () => null,
createLocalTransportFactory: () => null,
buildLocalTransportUrl: ({ transportType, transportPath }) =>
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
createClient: (config) => {
const client = new FakeDaemonClient(this, config);
this.createdClients.push(client);
return client;
},
};
});
const clientIdMock = vi.hoisted(() => ({
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
}));
failNextConnection(error: Error, lastError: string | null): void {
this.nextConnectError = error;
this.nextLastError = lastError;
}
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
buildLocalDaemonTransportUrl: vi.fn(
({
transportType,
transportPath,
}: {
transportType: "socket" | "pipe";
transportPath: string;
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
),
}));
createdConfigs(): DaemonClientConfig[] {
return this.createdClients.map((client) => client.config);
}
}
describe("test-daemon-connection connectToDaemon", () => {
let probe: FakeDaemonProbe;
beforeEach(() => {
daemonClientMock.reset();
clientIdMock.getOrCreateClientId.mockClear();
vi.stubGlobal("__DEV__", false);
probe = new FakeDaemonProbe();
});
it("reuses the app clientId for direct connections", async () => {
const mod = await import("./test-daemon-connection");
const first = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
const { connectToDaemon } = await import("./test-daemon-connection");
const first = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
probe.deps,
);
await first.client.close();
const second = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
const second = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
probe.deps,
);
await second.client.close();
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
const [firstConfig, secondConfig] = probe.createdConfigs();
expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
expect(probe.clientIdsRequested).toBe(2);
});
it("encodes the local socket target into the client config", async () => {
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
},
undefined,
probe.deps,
);
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
);
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock");
});
it("passes direct TCP connection passwords into the client config", async () => {
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
});
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
probe.deps,
);
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
});
it("uses relay TLS from the stored connection", async () => {
const mod = await import("./test-daemon-connection");
const tlsResult = await mod.connectToDaemon(
const { connectToDaemon } = await import("./test-daemon-connection");
const tlsResult = await connectToDaemon(
{
id: "relay:wss:[::1]:443",
type: "relay",
@@ -157,10 +145,11 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey",
},
{ serverId: "srv_probe_test" },
probe.deps,
);
await tlsResult.client.close();
const plainResult = await mod.connectToDaemon(
const plainResult = await connectToDaemon(
{
id: "relay:relay.paseo.sh:443",
type: "relay",
@@ -169,43 +158,52 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey",
},
{ serverId: "srv_probe_test" },
probe.deps,
);
await plainResult.client.close();
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
});
it("surfaces auth rejection as an incorrect password", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(
new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)",
);
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
}),
connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
},
undefined,
probe.deps,
),
).rejects.toMatchObject({
message: "Incorrect password",
});
});
it("keeps generic transport failures generic when a password was supplied", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(new Error("Transport error"), "Transport error");
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
}),
connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
probe.deps,
),
).rejects.toMatchObject({
message: "Transport error",
});

View File

@@ -13,6 +13,34 @@ import {
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient {
readonly lastError: string | null;
connect(): Promise<void>;
close(): Promise<void>;
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
}
interface LocalTransportUrlInput {
transportType: "socket" | "pipe";
transportPath: string;
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient;
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
getClientId: getOrCreateClientId,
resolveAppVersion,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config),
};
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -69,13 +97,17 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const clientId = await deps.getClientId();
const localTransportFactory = deps.createLocalTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
appVersion: deps.resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...((connection.type === "directSocket" || connection.type === "directPipe") &&
@@ -87,7 +119,7 @@ export async function buildClientConfig(
if (connection.type === "directSocket" || connection.type === "directPipe") {
return {
...base,
url: buildLocalDaemonTransportUrl({
url: deps.buildLocalTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
@@ -120,10 +152,23 @@ export async function buildClientConfig(
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const client = new DaemonClient(config);
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectAndProbe<TClient extends DaemonProbeClient>(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"createClient"
> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const client = deps.createClient(config);
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
(resolve, reject) => {
const timer = setTimeout(() => {
void client.close().catch(() => undefined);
@@ -183,10 +228,20 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
return connection.type === "relay" ? 10_000 : 6_000;
}
export function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectToDaemon<TClient extends DaemonProbeClient>(
connection: HostConnection,
options: ProbeOptions | undefined,
deps: DaemonConnectionDependencies<TClient>,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export async function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId);
return connectAndProbe(config, resolveTimeout(connection, options));
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId, deps);
return connectAndProbe(config, resolveTimeout(connection, options), deps);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.71",
"version": "0.1.75",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.71",
"@getpaseo/server": "0.1.75",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,32 +1,67 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
import {
type DaemonLaunchRuntime,
type DetachedDaemonProcess,
startLocalDaemonDetached,
startLocalDaemonForeground,
} from "./local-daemon.js";
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
type RecordedDaemonLaunch =
| {
mode: "detached";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
}
| {
mode: "foreground";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
};
vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
pid = 4242;
unref = vi.fn();
wasUnreferenced = false;
unref(): void {
this.wasUnreferenced = true;
}
}
class FakeDaemonRuntime implements DaemonLaunchRuntime {
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
readonly daemonProcess = new FakeDaemonProcess();
foregroundStatus = 0;
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
resolveRunnerEntry(): string {
return this.runnerEntry;
}
resolveHome(env: NodeJS.ProcessEnv): string {
return env.PASEO_HOME ?? "/tmp/paseo";
}
spawnDetached(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
): DetachedDaemonProcess {
this.recordedLaunches.push({ mode: "detached", command, args, options });
return this.daemonProcess;
}
spawnForeground(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
) {
this.recordedLaunches.push({ mode: "foreground", command, args, options });
return { status: this.foregroundStatus, error: undefined };
}
}
function expectSupervisorLaunch(argv: string[]): void {
@@ -41,59 +76,59 @@ function expectSupervisorLaunch(argv: string[]): void {
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const runtime = new FakeDaemonRuntime();
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
expect(status).toBe(0);
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const runtime = new FakeDaemonRuntime();
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
const resultPromise = startLocalDaemonDetached(
{ home: "/tmp/paseo-test", mcp: false },
runtime,
);
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("detached");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-mcp");
});
test("relay TLS flag is passed to the supervised daemon", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const runtime = new FakeDaemonRuntime();
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({
home: "/tmp/paseo-test",
relayUseTls: true,
});
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
relayUseTls: true,
},
runtime,
);
expect(status).toBe(0);
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
string,
string[],
{ env?: NodeJS.ProcessEnv },
];
expect(argv).toContain("--relay-use-tls");
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--relay-use-tls");
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
});
});

View File

@@ -1,4 +1,4 @@
import { spawnSync } from "node:child_process";
import { spawnSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
@@ -68,6 +68,28 @@ interface ProcessExitDetails {
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
export interface ForegroundDaemonProcessResult {
status: number | null;
error?: Error;
}
export interface DaemonLaunchRuntime {
resolveRunnerEntry(): string;
resolveHome(env: NodeJS.ProcessEnv): string;
spawnDetached(
command: string,
args: string[],
options: Parameters<typeof spawnProcess>[2],
): DetachedDaemonProcess;
spawnForeground(
command: string,
args: string[],
options: Parameters<typeof spawnSync>[2],
): ForegroundDaemonProcessResult;
}
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log";
@@ -78,6 +100,13 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
spawnDetached: spawnProcess,
spawnForeground: spawnSync,
};
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
@@ -395,17 +424,18 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
export async function startLocalDaemonDetached(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): Promise<DetachedStartResult> {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const paseoHome = resolvePaseoHome(childEnv);
const paseoHome = runtime.resolveHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const child = spawnProcess(
const child = runtime.spawnDetached(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
@@ -461,14 +491,17 @@ export async function startLocalDaemonDetached(
};
}
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
export function startLocalDaemonForeground(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): number {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const result = spawnSync(
const result = runtime.spawnForeground(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{

View File

@@ -1,21 +1,17 @@
import { describe, expect, it } from "vitest";
import {
buildCreateWorktreeInput,
toDaemonCreateInput,
type WorktreeCreateOptions,
} from "./create.js";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
const REPO = "/tmp/repo";
function build(options: WorktreeCreateOptions): unknown {
try {
return buildCreateWorktreeInput(options, REPO);
return buildCreateWorktreeRequest(options, REPO);
} catch (err) {
return err;
}
}
describe("buildCreateWorktreeInput", () => {
describe("buildCreateWorktreeRequest", () => {
it("requires --mode", () => {
expect(build({})).toMatchObject({ code: "MISSING_MODE" });
});
@@ -28,17 +24,20 @@ describe("buildCreateWorktreeInput", () => {
expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" });
});
it("branch-off parses with new branch only", () => {
it("branch-off builds a daemon request with a new branch", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x" },
worktreeSlug: "feature-x",
action: "branch-off",
});
});
it("branch-off parses with base ref", () => {
it("branch-off includes the base ref when provided", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
});
});
@@ -46,10 +45,11 @@ describe("buildCreateWorktreeInput", () => {
expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" });
});
it("checkout-branch parses with branch", () => {
it("checkout-branch builds a checkout request for the branch", () => {
expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({
cwd: REPO,
target: { mode: "checkout-branch", branch: "feat/x" },
action: "checkout",
refName: "feat/x",
});
});
@@ -69,62 +69,8 @@ describe("buildCreateWorktreeInput", () => {
});
});
it("checkout-pr parses positive integers", () => {
it("checkout-pr builds a checkout request for the pull request", () => {
expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
});
});
});
describe("toDaemonCreateInput", () => {
it("maps branch-off without base", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
});
});
it("maps branch-off with base ref", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
});
});
it("maps checkout-branch to action=checkout + refName", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-branch", branch: "feat/x" },
}),
).toEqual({
cwd: REPO,
action: "checkout",
refName: "feat/x",
});
});
it("maps checkout-pr to action=checkout + githubPrNumber", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
}),
).toEqual({
cwd: REPO,
action: "checkout",
githubPrNumber: 42,

View File

@@ -0,0 +1,104 @@
import type { DaemonClient } from "@getpaseo/server";
import type { CommandError, CommandOptions } from "../../output/index.js";
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
type CreatePaseoWorktreeRequest = Parameters<DaemonClient["createPaseoWorktree"]>[0];
export function buildCreateWorktreeRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off":
return buildBranchOffRequest(options, cwd);
case "checkout-branch":
return buildCheckoutBranchRequest(options, cwd);
case "checkout-pr":
return buildCheckoutPrRequest(options, cwd);
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
function buildBranchOffRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
worktreeSlug: options.newBranch,
action: "branch-off",
...(options.base ? { refName: options.base } : {}),
};
}
function buildCheckoutBranchRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return {
cwd,
action: "checkout",
refName: options.branch,
};
}
function buildCheckoutPrRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const prNumber = Number(options.prNumber);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return {
cwd,
action: "checkout",
githubPrNumber: prNumber,
};
}
function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message };
}

View File

@@ -2,12 +2,8 @@ import path from "node:path";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
export interface WorktreeCreateResult {
name: string;
@@ -24,110 +20,6 @@ export const createSchema: OutputSchema<WorktreeCreateResult> = {
],
};
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
export type WorktreeCreateTarget =
| { mode: "branch-off"; newBranch: string; base?: string }
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
export interface ParsedWorktreeCreateInput {
cwd: string;
target: WorktreeCreateTarget;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
export function buildCreateWorktreeInput(
options: WorktreeCreateOptions,
cwd: string,
): ParsedWorktreeCreateInput {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off": {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
target: {
mode: "branch-off",
newBranch: options.newBranch,
...(options.base ? { base: options.base } : {}),
},
};
}
case "checkout-branch": {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return { cwd, target: { mode: "checkout-branch", branch: options.branch } };
}
case "checkout-pr": {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const parsed = Number(options.prNumber);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return { cwd, target: { mode: "checkout-pr", prNumber: parsed } };
}
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
switch (parsed.target.mode) {
case "branch-off":
return {
cwd: parsed.cwd,
worktreeSlug: parsed.target.newBranch,
action: "branch-off" as const,
...(parsed.target.base ? { refName: parsed.target.base } : {}),
};
case "checkout-branch":
return {
cwd: parsed.cwd,
action: "checkout" as const,
refName: parsed.target.branch,
};
case "checkout-pr":
return {
cwd: parsed.cwd,
action: "checkout" as const,
githubPrNumber: parsed.target.prNumber,
};
default:
throw new Error("unreachable");
}
}
function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message };
}
@@ -137,7 +29,7 @@ export async function runCreateCommand(
_command: Command,
): Promise<SingleResult<WorktreeCreateResult>> {
const cwd = options.cwd ?? process.cwd();
const parsed = buildCreateWorktreeInput(options, cwd);
const request = buildCreateWorktreeRequest(options, cwd);
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
@@ -153,7 +45,7 @@ export async function runCreateCommand(
}
try {
const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
const response = await client.createPaseoWorktree(request);
const workspace = response.workspace;
if (!workspace || response.error) {

View File

@@ -8,6 +8,26 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForLoopInList(
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
id: string,
) {
for (let attempt = 0; attempt < 20; attempt++) {
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
if (listedJson.some((item: { id: string }) => item.id === id)) {
return listedJson;
}
await sleep(250);
}
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
return JSON.parse(listed.stdout);
}
console.log("=== Loop And Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
@@ -143,13 +163,10 @@ try {
const runJson = JSON.parse(run.stdout);
assert.strictEqual(runJson.name, "smoke-loop");
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
const listedJson = await waitForLoopInList(ctx, runJson.id);
assert(
listedJson.some((item: { id: string }) => item.id === runJson.id),
listed.stdout,
JSON.stringify(listedJson),
);
async function pollStatus(attempt: number): Promise<string> {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.71",
"version": "0.1.75",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
@@ -70,6 +71,34 @@ function desktopSettingsWithManagement(enabled: boolean) {
};
}
type MockChildProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
pid: number;
spawnfile: string;
spawnargs: string[];
unref: ReturnType<typeof vi.fn>;
};
function createMockChildProcess(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 1234;
child.spawnfile = "node";
child.spawnargs = ["node", "daemon.js"];
child.unref = vi.fn();
return child;
}
function scheduleFailedStartupOutput(child: MockChildProcess): void {
setImmediate(() => {
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
child.emit("exit", 1, null);
});
}
describe("daemon-manager commands", () => {
beforeEach(() => {
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
@@ -165,4 +194,61 @@ describe("daemon-manager commands", () => {
"--json",
]);
});
it("uses a reachable daemon when the PID file is stale", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 7675,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.2",
desktopManaged: true,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: "dev-host",
pid: null,
home: "/tmp/paseo-home",
version: "1.2.2",
desktopManaged: false,
error: null,
});
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("bounds captured daemon startup output", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
});
mocks.spawnProcess.mockImplementation(() => {
const child = createMockChildProcess();
scheduleFailedStartupOutput(child);
return child;
});
const handlers = createDaemonCommandHandlers();
let thrown: Error | null = null;
try {
await handlers.start_desktop_daemon();
} catch (error) {
thrown = error instanceof Error ? error : new Error(String(error));
}
expect(thrown).toBeInstanceOf(Error);
const message = thrown?.message ?? "";
expect(message).toContain("Daemon failed to start: exit code 1");
expect(message).toContain("output truncated to the last 65536 chars");
expect(message).toContain("stdout-tail");
expect(message).toContain("stderr-tail");
expect(message.length).toBeLessThan(150_000);
});
});

View File

@@ -43,6 +43,7 @@ const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150;
const DETACHED_STARTUP_GRACE_MS = 1200;
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -69,6 +70,11 @@ interface DesktopPairingOffer {
qr: string | null;
}
interface StartupOutputCapture {
text: string;
truncated: boolean;
}
function parseReleaseChannel(
args: Record<string, unknown> | undefined,
): AppReleaseChannel | undefined {
@@ -145,6 +151,30 @@ function tailFile(filePath: string, lines = 50): string {
}
}
function createStartupOutputCapture(): StartupOutputCapture {
return { text: "", truncated: false };
}
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
const nextText = capture.text + chunk.toString();
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
return { text: nextText, truncated: capture.truncated };
}
return {
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
truncated: true,
};
}
function formatStartupOutput(capture: StartupOutputCapture): string {
if (!capture.truncated) {
return capture.text;
}
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
}
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
log.info("[desktop daemon]", message, {
pid: process.pid,
@@ -197,17 +227,28 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
unknown
>;
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
const running = localDaemon === "running";
const connectedDaemon =
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
status = "running";
} else if (localDaemon === "unresponsive") {
status = "errored";
}
return {
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
status: running ? "running" : "stopped",
status,
listen: typeof payload.listen === "string" ? payload.listen : null,
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
pid: running && typeof payload.pid === "number" ? payload.pid : null,
hostname:
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
home,
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
desktopManaged: payload.desktopManaged === true,
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
error: null,
};
} catch (error) {
@@ -248,15 +289,17 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error },
stdout: string,
stderr: string,
stdout: StartupOutputCapture,
stderr: StartupOutputCapture,
): Error {
const reason = result.error
? result.error.message
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
const parts = [`Daemon failed to start: ${reason}`];
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
const formattedStderr = formatStartupOutput(stderr).trim();
const formattedStdout = formatStartupOutput(stdout).trim();
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
const logs = tailFile(logFilePath(), 15);
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
return new Error(parts.join("\n\n"));
@@ -337,13 +380,13 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdout = createStartupOutputCapture();
let stderr = createStartupOutputCapture();
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
stdout = appendStartupOutput(stdout, data);
});
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
stderr = appendStartupOutput(stderr, data);
});
logDesktopDaemonLifecycle("detached spawn returned", {
@@ -381,8 +424,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
logDesktopDaemonLifecycle("detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly: result.exitedEarly,
stdout: stdout.slice(0, 2000),
stderr: stderr.slice(0, 2000),
stdout: formatStartupOutput(stdout).slice(0, 2000),
stderr: formatStartupOutput(stderr).slice(0, 2000),
...(result.exitedEarly
? {
exitCode: result.code,

View File

@@ -0,0 +1,53 @@
import { ipcMain, shell } from "electron";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener";
vi.mock("electron", () => ({
ipcMain: { handle: vi.fn() },
shell: { openExternal: vi.fn() },
}));
function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise<void> {
registerOpenerHandlers();
const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
return channel === "paseo:opener:openUrl";
})?.[1];
if (typeof handler !== "function") {
throw new Error("open URL handler was not registered");
}
return handler as (_event: unknown, url: unknown) => Promise<void>;
}
describe("desktop opener", () => {
beforeEach(() => {
vi.mocked(ipcMain.handle).mockReset();
vi.mocked(shell.openExternal).mockReset();
});
it("allows only http and https external URLs", () => {
expect(isAllowedExternalUrl("https://example.com/path")).toBe(true);
expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true);
expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false);
expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false);
expect(isAllowedExternalUrl("paseo://settings")).toBe(false);
expect(isAllowedExternalUrl("/relative/path")).toBe(false);
expect(isAllowedExternalUrl(null)).toBe(false);
});
it("opens allowed URLs through Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await handler({}, "https://example.com");
expect(shell.openExternal).toHaveBeenCalledWith("https://example.com");
});
it("rejects blocked URLs before invoking Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,25 @@
import { shell, ipcMain } from "electron";
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
export function isAllowedExternalUrl(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
try {
const url = new URL(value);
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol);
} catch {
return false;
}
}
export function registerOpenerHandlers(): void {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => {
if (!isAllowedExternalUrl(url)) {
throw new Error("Unsupported external URL");
}
await shell.openExternal(url);
});
}

View File

@@ -62,6 +62,7 @@ function resolveShellEnv(): Record<string, string> | undefined {
const result = spawnSync(shell, [...shellArgs, command], {
encoding: "utf8",
timeout: RESOLVE_TIMEOUT_MS,
windowsHide: true,
env: {
...shellEnv,
ELECTRON_RUN_AS_NODE: "1",

View File

@@ -191,6 +191,7 @@ if (forcedUserDataDir) {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim();
devWorktreeName = path.basename(topLevel);
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
@@ -200,6 +201,7 @@ if (forcedUserDataDir) {
cwd: topLevel,
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim(),
);
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.71",
"version": "0.1.75",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.71",
"version": "0.1.75",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.71",
"version": "0.1.75",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.71",
"version": "0.1.75",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -58,14 +58,14 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.71",
"@getpaseo/highlight": "0.1.75",
"@getpaseo/relay": "0.1.75",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@opencode-ai/sdk": "1.14.46",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",

View File

@@ -10,12 +10,9 @@ import {
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
interface DaemonRunnerConfig {
devMode: boolean;
workerArgs: string[];
@@ -77,28 +74,6 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
return existsSync(runnerPath) ? runnerPath : null;
}
function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
@@ -113,7 +88,7 @@ async function main(): Promise<void> {
const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);
try {
await acquirePidLock(paseoHome, null, {

View File

@@ -0,0 +1,42 @@
import path from "node:path";
import type { loadPersistedConfig } from "../src/server/persisted-config.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 3;
export function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
env: NodeJS.ProcessEnv = process.env,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
const envRotateSize = env.PASEO_LOG_ROTATE_SIZE?.trim();
const envRotateMaxFiles = parseOptionalPositiveInteger(env.PASEO_LOG_ROTATE_COUNT);
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? envRotateSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles:
configuredFile?.rotate?.maxFiles ?? envRotateMaxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
function parseOptionalPositiveInteger(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}

View File

@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -87,6 +88,57 @@ async function runSupervisorFixture(options: {
}
describe("supervisor durable logging", () => {
test("resolves rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(paseoHome, {}, {});
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "10m", maxFiles: 3 },
});
});
test("lets persisted rotation override env rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{
log: {
file: {
path: "logs/daemon.log",
rotate: { maxSize: "25m", maxFiles: 4 },
},
},
},
{
PASEO_LOG_ROTATE_SIZE: "200m",
PASEO_LOG_ROTATE_COUNT: "12",
},
);
expect(logFile).toEqual({
path: path.resolve(paseoHome, "logs", "daemon.log"),
rotate: { maxSize: "25m", maxFiles: 4 },
});
});
test("uses env rotation when persisted rotation is absent", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{},
{
PASEO_LOG_ROTATE_SIZE: "50m",
PASEO_LOG_ROTATE_COUNT: "8",
},
);
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "50m", maxFiles: 8 },
});
});
test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({
workerSource: `

View File

@@ -0,0 +1,70 @@
import { expect, test } from "vitest";
import { buildArchivedAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord } from "./agent-storage.js";
const BASE_RECORD: StoredAgentRecord = {
id: "agent-1",
provider: "codex",
cwd: "/workspace/project",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
labels: {},
lastStatus: "idle",
config: null,
};
test("archives a stored agent without changing terminal statuses", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["idle", "error", "closed"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe(status);
expect(archived.archivedAt).toBe("2025-01-03T00:00:00.000Z");
expect(archived.updatedAt).toBe(BASE_RECORD.updatedAt);
}
});
test("archives busy stored agents as idle", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["initializing", "running"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe("idle");
}
});
test("clears persisted attention when archiving", () => {
const archived = buildArchivedAgentRecord(
{
...BASE_RECORD,
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: "2025-01-02T12:00:00.000Z",
},
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived).toMatchObject({
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
});
test("can stamp updatedAt to the archive timestamp", () => {
const archived = buildArchivedAgentRecord(BASE_RECORD, {
archivedAt: "2025-01-03T00:00:00.000Z",
updatedAt: "2025-01-03T00:00:00.000Z",
});
expect(archived.updatedAt).toBe("2025-01-03T00:00:00.000Z");
});

View File

@@ -0,0 +1,30 @@
import type { StoredAgentRecord } from "./agent-storage.js";
export type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface BuildArchivedAgentRecordOptions {
archivedAt?: string;
updatedAt?: string;
}
export function buildArchivedAgentRecord(
record: StoredAgentRecord,
options?: BuildArchivedAgentRecordOptions,
): ArchivedStoredAgentRecord {
const archivedAt = options?.archivedAt ?? new Date().toISOString();
return {
...record,
archivedAt,
updatedAt: options?.updatedAt ?? record.updatedAt,
lastStatus: normalizeArchivedStatus(record.lastStatus),
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
}
function normalizeArchivedStatus(
status: StoredAgentRecord["lastStatus"],
): StoredAgentRecord["lastStatus"] {
return status === "running" || status === "initializing" ? "idle" : status;
}

View File

@@ -716,6 +716,7 @@ test("createAgent passes daemon launch env through the provider launch context",
modeId: "auto",
});
expect(client.lastLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},
@@ -1179,6 +1180,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
},
});
expect(client.lastResumeLaunchContext).toEqual({
agentId: resumed.id,
env: {
PASEO_AGENT_ID: resumed.id,
},
@@ -1283,6 +1285,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
});
expect(client.lastCreateLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},
@@ -1293,6 +1296,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
});
expect(client.lastResumeLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},

View File

@@ -10,31 +10,33 @@ import type { Logger } from "pino";
import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentSlashCommand,
AgentMode,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPersistenceHandle,
AgentPromptInput,
AgentProvider,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
type AgentRunOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import {
InMemoryAgentTimelineStore,
@@ -66,7 +68,6 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
};
type TimeoutResult = "completed" | "timed_out";
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface TimeoutOptions {
operation: Promise<void>;
@@ -1005,18 +1006,28 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
},
"closeAgent: start",
"agent.manager.close.start",
);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
await agent.session.close();
this.timelineStore.delete(agentId);
await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace({ agentId }, "closeAgent: completed");
this.logger.trace(
{
agentId,
provider: closedAgent.provider,
sessionId: closedAgent.persistence?.sessionId ?? undefined,
},
"agent.manager.close.complete",
);
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
@@ -1071,19 +1082,7 @@ export class AgentManager {
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const registry = this.requireRegistry();
const archivedAt = new Date().toISOString();
const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const archivedRecord: ArchivedStoredAgentRecord = {
...record,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt });
await registry.upsert(archivedRecord);
@@ -1259,19 +1258,7 @@ export class AgentManager {
throw new Error(`Agent not found: ${agentId}`);
}
const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const nextRecord: StoredAgentRecord = {
...record,
archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
const nextRecord = buildArchivedAgentRecord(record, { archivedAt });
await registry.upsert(nextRecord);
await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
@@ -1494,22 +1481,28 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options),
},
"streamAgent: requested",
"agent.manager.stream.request",
);
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
},
"streamAgent: rejected because a foreground run is already in flight",
"agent.manager.stream.reject",
);
throw new Error(`Agent ${agentId} already has an active run`);
}
@@ -1546,10 +1539,13 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
},
"streamAgent: started",
"agent.manager.stream.start",
);
turnStream = this.foregroundRuns.createTurnStream(turnId);
@@ -1601,11 +1597,14 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: mutableAgent.lifecycle,
terminalError,
pendingReplacement: mutableAgent.pendingReplacement,
},
"finalizeForegroundTurn: applying terminal state",
"agent.manager.finalize",
);
if (!shouldHoldBusyForReplacement) {
this.touchUpdatedAt(mutableAgent);
@@ -2395,6 +2394,16 @@ export class AgentManager {
}
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.enqueue",
);
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
@@ -2406,6 +2415,16 @@ export class AgentManager {
if (current.session == null) {
return;
}
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: current.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.dequeue",
);
await this.dispatchSessionEvent(current, event);
return;
})
@@ -2429,8 +2448,19 @@ export class AgentManager {
agent: ActiveManagedAgent,
event: AgentStreamEvent,
): Promise<void> {
const turnId = (event as { turnId?: string }).turnId;
const turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
matchingWaiterCount: matchingWaiters.length,
event,
},
"agent.manager.dispatch_session_event",
);
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
@@ -2441,6 +2471,18 @@ export class AgentManager {
this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
terminal: isTurnTerminalEvent(event),
});
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
notifiedWaiterCount: matchingWaiters.length,
terminal: isTurnTerminalEvent(event),
event,
},
"agent.manager.notify_waiters",
);
}
private async resolveInitialPersistedTitle(
@@ -2553,7 +2595,7 @@ export class AgentManager {
}
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
const turnId = (event as { turnId?: string }).turnId;
const turnId = getAgentStreamEventTurnId(event);
if (turnId == null) {
return;
}
@@ -2564,6 +2606,16 @@ export class AgentManager {
}
this.foregroundRuns.notifyAgentWaiters(agent, event);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.notify_waiters.coalesced",
);
}
private async handleStreamEvent(
@@ -2571,8 +2623,9 @@ export class AgentManager {
event: AgentStreamEvent,
options?: HandleStreamEventOptions,
): Promise<boolean> {
const eventTurnId = (event as { turnId?: string }).turnId;
const eventTurnId = getAgentStreamEventTurnId(event);
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
if (
eventTurnId &&
isTurnTerminalEvent(event) &&
@@ -2585,6 +2638,7 @@ export class AgentManager {
if (!options?.fromHistory) {
this.touchUpdatedAt(agent);
if (this.agentStreamCoalescer.handle(agent.id, event)) {
this.traceCoalescerBuffered(agent, event, eventTurnId);
return false;
}
this.agentStreamCoalescer.flushFor(agent.id);
@@ -2612,9 +2666,71 @@ export class AgentManager {
this.dispatchStream(agent.id, event);
}
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
return flags.shouldNotifyWaiters;
}
private traceHandleStreamEventStart(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
isForegroundEvent: boolean,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
isForegroundEvent,
event,
},
"agent.manager.handle_stream_event.start",
);
}
private traceCoalescerBuffered(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.coalescer.buffer",
);
}
private traceHandleStreamEventEnd(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
flags: StreamEventFlags,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
shouldDispatchEvent: flags.shouldDispatchEvent,
shouldNotifyWaiters: flags.shouldNotifyWaiters,
event,
},
"agent.manager.handle_stream_event.end",
);
}
private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent;
event: AgentStreamEvent;
@@ -2771,11 +2887,13 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"handleStreamEvent: turn_completed",
"agent.manager.turn.completed",
);
agent.lastUsage = event.usage;
agent.lastError = undefined;
@@ -2802,6 +2920,9 @@ export class AgentManager {
this.logger.warn(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
@@ -2842,11 +2963,14 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"handleStreamEvent: turn_canceled",
"agent.manager.turn.canceled",
);
if (!isForegroundEvent && !agent.pendingReplacement) {
agent.lifecycle = "idle";
@@ -2867,11 +2991,13 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"handleStreamEvent: turn_started",
"agent.manager.turn.started",
);
if (!isForegroundEvent) {
agent.lifecycle = "running";
@@ -3017,6 +3143,20 @@ export class AgentManager {
this.syncFeaturesFromSession(agent);
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
persist: options?.persist !== false,
},
"agent.manager.emit_state",
);
this.dispatch({
type: "agent_state",
agent: { ...agent },
@@ -3144,6 +3284,18 @@ export class AgentManager {
event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string },
): void {
const agent = this.agents.get(agentId);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
metadata,
event,
},
"agent.manager.dispatch_stream",
);
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
}
@@ -3239,6 +3391,7 @@ export class AgentManager {
private buildLaunchContext(agentId: string): AgentLaunchContext {
return {
agentId,
env: {
PASEO_AGENT_ID: agentId,
},

View File

@@ -17,6 +17,19 @@ export function startAgentRun(
logger: Logger,
options?: StartAgentRunOptions,
): { outOfBand: boolean } {
const snapshot = agentManager.getAgent(agentId);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
turnId: snapshot?.activeForegroundTurnId ?? undefined,
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options?.runOptions),
replaceRunning: Boolean(options?.replaceRunning),
},
"agent.session.start_stream.request",
);
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint.
@@ -28,12 +41,38 @@ export function startAgentRun(
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
: agentManager.streamAgent(agentId, prompt, runOptions);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
shouldReplace,
},
"agent.session.start_stream.iterator_returned",
);
void (async () => {
try {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
}
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
},
"agent.session.iterator.drained",
);
} catch (error) {
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
err: error,
},
"agent.session.iterator.error",
);
logger.error({ err: error, agentId }, "Agent stream failed");
}
})();

View File

@@ -358,6 +358,10 @@ export type AgentStreamEvent =
timestamp: string;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
return "turnId" in event ? event.turnId : undefined;
}
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
export type AgentPermissionUpdate = AgentMetadata;
@@ -476,6 +480,7 @@ export interface AgentSessionConfig {
}
export interface AgentLaunchContext {
agentId?: string;
env?: Record<string, string>;
}

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
import { getAgentStreamEventTurnId, type AgentStreamEvent } from "./agent-sdk-types.js";
export interface ForegroundTurnWaiter {
turnId: string;
@@ -105,7 +105,7 @@ export class ForegroundRunState {
event: AgentStreamEvent,
options?: { terminal?: boolean },
): void {
const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
const waiters = this.getMatchingWaiters(agent, getAgentStreamEventTurnId(event));
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
}
@@ -226,7 +226,3 @@ function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
pendingRun.settled = true;
pendingRun.resolveSettled();
}
function eventTurnId(event: AgentStreamEvent): string | undefined {
return (event as { turnId?: string }).turnId;
}

View File

@@ -230,6 +230,7 @@ export function findExecutable(name: string): string | null {
const result = execFileSync(cmd, [trimmed], {
encoding: "utf8",
env: createProviderEnv({ baseEnv: process.env }),
windowsHide: true,
}).trim();
const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
const candidate = lines.at(-1)?.trim() ?? null;

View File

@@ -11,6 +11,7 @@ export interface AgentModeVisuals {
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
AgentModeVisuals & {
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
isUnattended?: boolean;
};
@@ -96,9 +97,9 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
colorTier: "planning",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
id: "allow-all",
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,

View File

@@ -67,10 +67,21 @@ export interface BuildProviderRegistryOptions {
isDev?: boolean;
}
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService"
> {
customProvider?: {
id: string;
label: string;
extends: string;
};
}
type ProviderClientFactory = (
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
options?: ProviderClientFactoryOptions,
) => AgentClient;
interface ResolvedProvider {
@@ -92,6 +103,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
codex: (logger, runtimeSettings, options) =>
new CodexAppServerAgentClient(logger, runtimeSettings, {
workspaceGitService: options?.workspaceGitService,
customProvider: options?.customProvider,
}),
copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({
@@ -510,10 +522,11 @@ function addDerivedProviders(
continue;
}
const baseProvider = resolvedProviders.get(override.extends);
const baseProviderId = override.extends;
const baseProvider = resolvedProviders.get(baseProviderId);
if (!baseProvider) {
throw new Error(
`Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
`Custom provider '${providerId}' extends unknown provider '${baseProviderId}'`,
);
}
@@ -522,7 +535,7 @@ function addDerivedProviders(
toRuntimeSettings(override),
);
const baseDefinition = baseProvider.definition;
const baseFactory = getProviderClientFactory(override.extends);
const baseFactory = getProviderClientFactory(baseProviderId);
resolvedProviders.set(providerId, {
definition: createDerivedDefinition(providerId, baseDefinition, override),
@@ -530,8 +543,15 @@ function addDerivedProviders(
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
derivedFromProviderId: override.extends,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
derivedFromProviderId: baseProviderId,
createBaseClient: (logger) =>
baseFactory(logger, mergedRuntimeSettings, {
customProvider: {
id: providerId,
label: override.label ?? providerId,
extends: baseProviderId,
},
}),
});
}
}

View File

@@ -21,6 +21,15 @@ import {
resolveACPModeSelection,
resolveACPModelSelection,
} from "./acp-agent.js";
import {
COPILOT_ALLOW_ALL_MODE_ID,
COPILOT_MODES,
beforeCopilotModeWriter,
transformCopilotConfigOptions,
transformCopilotModeId,
transformCopilotSessionResponse,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -140,6 +149,73 @@ function selectConfigOption(
};
}
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
return new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: modeId ?? undefined,
},
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
}
function copilotModeConfigOption(currentValue: string): SessionConfigOption {
return {
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue,
options: [
{
value: "https://agentclientprotocol.com/protocol/session-modes#agent",
name: "Agent",
},
{
value: "https://agentclientprotocol.com/protocol/session-modes#plan",
name: "Plan",
},
{
value: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
name: "Autopilot",
},
],
};
}
function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigOption {
return {
id: "allow_all",
name: "Allow All",
category: "permissions",
type: "select",
currentValue,
options: [
{ value: "on", name: "On" },
{ value: "off", name: "Off" },
],
};
}
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
if (category === "mode") {
return "Mode";
@@ -190,7 +266,7 @@ function prepareConfiguredOverrideSession(
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
const childLogger = { trace: vi.fn(), warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const session = createSessionWithConfig({}, logger);
const setSessionConfigOption = vi.fn(async () => ({
@@ -577,7 +653,7 @@ describe("ACPAgentSession Zed parity", () => {
expect(await validSession.getCurrentMode()).toBe("default");
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
const childLogger = { trace: vi.fn(), warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const invalidSession = createSessionWithConfig(
{ modeId: "acceptEdits", model: "opus" },
@@ -796,12 +872,10 @@ describe("ACPAgentSession Zed parity", () => {
});
});
test("passes Copilot Autopilot ACP permission requests through to the user", async () => {
// Zed parity: Copilot Autopilot no longer auto-approves ACP permission requests,
// so the accepted UX regression is that every tool request reaches the user UI.
test("passes generic ACP permission requests through to the user", async () => {
const session = createSessionWithConfig({
provider: "copilot",
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
provider: "cursor-acp",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
const events: Array<{ type: string; request?: { id: string } }> = [];
const permissionOptions: PermissionOption[] = [
@@ -835,6 +909,126 @@ describe("ACPAgentSession Zed parity", () => {
outcome: { outcome: "selected", optionId: "allow-once" },
});
});
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
const { internals } = prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
await internals.applyConfiguredOverrides();
unsubscribe();
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
});
test("accepts Copilot's legacy autopilot mode ID as Allow All", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig();
prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#autopilot");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
test("switching Copilot away from Allow All turns allow_all off before setting the ACP mode", async () => {
const setSessionConfigOption = vi.fn(async (input: { value: string }) => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption(input.value === "on" ? "on" : "off"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
prepareConfiguredOverrideSession(session, {
currentMode: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption(COPILOT_ALLOW_ALL_MODE_ID),
copilotAllowAllConfigOption("on"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#agent");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "off",
});
expect(setSessionMode).toHaveBeenCalledWith({
sessionId: "session-1",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
});
test("trusts Copilot allow_all config updates as the current mode source", async () => {
const session = createCopilotSessionWithConfig();
const internals = asInternals<ACPSessionInternals>(session);
const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
});
expect(events).toMatchObject([
{
type: "mode_changed",
provider: "copilot",
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: expect.arrayContaining([
expect.objectContaining({ id: COPILOT_ALLOW_ALL_MODE_ID, label: "Allow All" }),
]),
},
]);
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
});
describe("deriveModelDefinitionsFromACP", () => {

View File

@@ -54,35 +54,36 @@ import {
} from "@agentclientprotocol/sdk";
import type { Logger } from "pino";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModesOptions,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallDetail,
ToolCallTimelineItem,
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModesOptions,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
createProviderEnvSpec,
@@ -226,7 +227,13 @@ interface ACPAgentClientOptions {
defaultModes?: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -245,7 +252,13 @@ interface ACPAgentSessionOptions {
defaultModes: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -253,6 +266,7 @@ interface ACPAgentSessionOptions {
) => Promise<void>;
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
@@ -337,6 +351,26 @@ interface ACPModelSelection {
hasAvailableModels: boolean;
}
export interface ACPProviderModeWriterContext {
connection: ClientSideConnection;
sessionId: string;
requestedModeId: string;
currentModeId: string | null;
selection: ACPModeSelection;
configOptions: SessionConfigOption[];
logger: Logger;
}
export interface ACPProviderModeWriteResult {
handled: boolean;
currentModeId?: string;
configOptions?: SessionConfigOption[];
}
export interface ACPBeforeModeWriteResult {
configOptions?: SessionConfigOption[];
}
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
if (!usage) {
return undefined;
@@ -465,7 +499,17 @@ export class ACPAgentClient implements AgentClient {
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -477,13 +521,20 @@ export class ACPAgentClient implements AgentClient {
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
this.logger = options.logger.child({ module: "agent", provider: options.provider });
this.logger = options.logger.child({
module: "agent",
provider: options.provider,
});
this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes ?? [];
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
@@ -504,9 +555,14 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -545,10 +601,15 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -747,7 +808,16 @@ export class ACPAgentClient implements AgentClient {
}
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
const transformed = this.sessionResponseTransformer
? this.sessionResponseTransformer(response)
: response;
if (!this.configOptionsTransformer || !transformed.configOptions) {
return transformed;
}
return {
...transformed,
configOptions: this.configOptionsTransformer(transformed.configOptions),
};
}
}
@@ -763,12 +833,23 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
private readonly agentId?: string;
private readonly launchEnv?: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
@@ -814,9 +895,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.defaultModes = options.defaultModes;
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.availableModes = options.defaultModes;
this.agentId = options.agentId;
this.launchEnv = options.launchEnv;
this.initialHandle = options.handle;
this.config = { ...config, provider: options.provider };
@@ -1068,6 +1154,25 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
const context = this.createProviderModeWriterContext(modeId, selection);
const providerResult = this.providerModeWriter
? await this.providerModeWriter(context)
: { handled: false };
if (providerResult.handled) {
this.currentMode = providerResult.currentModeId ?? modeId;
if (providerResult.configOptions) {
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
}
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
});
return;
}
if (selection.hasAvailableModes) {
if (!selection.availableMode) {
this.warnInvalidSelection(
@@ -1078,7 +1183,32 @@ export class ACPAgentSession implements AgentSession, ACPClient {
);
return;
}
} else {
const modeOption = selection.configOption;
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
}
if (this.beforeModeWriter) {
const beforeResult = await this.beforeModeWriter(context);
if (beforeResult?.configOptions) {
this.configOptions = this.transformConfigOptions(beforeResult.configOptions);
}
}
if (selection.hasAvailableModes) {
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
this.currentMode = modeId;
this.pushEvent({
@@ -1094,17 +1224,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId,
@@ -1127,6 +1246,24 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
}
private createProviderModeWriterContext(
requestedModeId: string,
selection: ACPModeSelection,
): ACPProviderModeWriterContext {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
return {
connection: this.connection,
sessionId: this.sessionId,
requestedModeId,
currentModeId: this.currentMode,
selection,
configOptions: this.configOptions,
logger: this.logger,
};
}
async setModel(modelId: string | null): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
@@ -1281,9 +1418,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: string;
label: string;
}): string {
this.configOptions = response.configOptions;
this.configOptions = this.transformConfigOptions(response.configOptions);
const responseOption = findSelectConfigOption({
configOptions: response.configOptions,
configOptions: this.configOptions,
category,
id: configId,
});
@@ -1409,7 +1546,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
// Match Zed acp.rs:3189-3220 — pure pass-through. Accepted UX regression: Copilot Autopilot will now prompt the user for every tool request.
// Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through.
const requestId = randomUUID();
let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ??
@@ -1439,11 +1576,31 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async sessionUpdate(params: SessionNotification): Promise<void> {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: params.sessionId,
rawEvent: params,
},
"provider.acp.raw_event",
);
if (params.sessionId !== this.sessionId) {
return;
}
const events = this.translateSessionUpdate(params.update);
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: this.activeForegroundTurnId ?? undefined,
rawEvent: params,
events,
},
"provider.acp.parsed_event",
);
if (this.replayingHistory) {
for (const event of events) {
if (event.type === "timeline") {
@@ -1622,7 +1779,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
? this.sessionResponseTransformer(response)
: response;
this.configOptions = transformed.configOptions ?? [];
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
@@ -1635,6 +1792,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
}
private transformConfigOptions(configOptions: SessionConfigOption[]): SessionConfigOption[] {
return this.configOptionsTransformer
? this.configOptionsTransformer(configOptions)
: configOptions;
}
private transformModeId(modeId: string): string | null {
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
}
private async applyConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) {
@@ -1772,11 +1939,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
this.currentMode = update.currentModeId;
this.currentMode = this.transformModeId(update.currentModeId);
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
this.configOptions = update.configOptions;
this.configOptions = this.transformConfigOptions(update.configOptions);
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const nextMode = modeInfo.currentModeId;
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
@@ -1864,6 +2031,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private pushEvent(event: AgentStreamEvent): void {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined,
event,
},
"provider.acp.event_emit",
);
for (const subscriber of this.subscribers) {
subscriber(event);
}

View File

@@ -14,7 +14,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import {
getAgentStreamEventTurnId,
type AgentSession,
type AgentStreamEvent,
} from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { ClaudeAgentClient } from "./agent.js";
@@ -37,15 +41,14 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
);
}
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
type EventWithTurnId = AgentStreamEvent & { turnId: string };
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
return getAgentStreamEventTurnId(event) !== undefined;
}
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
return events.filter((e) => getAgentStreamEventTurnId(e) === turnId);
}
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
@@ -157,7 +160,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// Invariant 2: Every turn_started has exactly one matching terminal
const turnStartedIds = events
.filter((e) => e.type === "turn_started" && hasTurnId(e))
.map((e) => (e as EventWithTurnId).turnId);
.map((e) => e.turnId);
for (const turnId of turnStartedIds) {
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
@@ -277,7 +280,7 @@ test("Test 3: Lifecycle doesn't get stuck in running", async () => {
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
expect(ts.turnId).not.toBe(turnId);
}
assertInvariants(events, [turnId]);
@@ -313,8 +316,9 @@ test("Test 4: Autonomous run", async () => {
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
(e): e is EventWithTurnId =>
e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
);
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;

View File

@@ -42,34 +42,35 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../prov
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPermissionUpdate,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
type AgentPermissionUpdate,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import {
createProviderEnv,
@@ -250,6 +251,7 @@ interface ClaudeAgentSessionOptions {
defaults?: { agents?: Record<string, AgentDefinition> };
runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
persistSession?: boolean;
logger: Logger;
@@ -1152,8 +1154,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
};
}
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
export class ClaudeAgentClient implements AgentClient {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1181,6 +1181,7 @@ export class ClaudeAgentClient implements AgentClient {
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
persistSession: options?.persistSession,
logger: this.logger,
@@ -1209,6 +1210,7 @@ export class ClaudeAgentClient implements AgentClient {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
logger: this.logger,
queryFactory: this.queryFactory,
@@ -1477,6 +1479,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean;
@@ -1526,10 +1529,11 @@ class ClaudeAgentSession implements AgentSession {
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
this.launchEnv = options.launchEnv;
this.agentId = options.agentId;
this.defaults = options.defaults;
this.runtimeSettings = options.runtimeSettings;
this.persistSession = options.persistSession;
this.logger = options.logger;
this.logger = options.logger.child({ agentId: this.agentId });
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
const handle = options.handle;
@@ -1869,13 +1873,16 @@ class ClaudeAgentSession implements AgentSession {
async close(): Promise<void> {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
turnState: this.turnState,
hasQuery: Boolean(this.query),
hasInput: Boolean(this.input),
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
},
"Claude session close: start",
"provider.claude.session_close.start",
);
this.closed = true;
this.rejectAllPendingPermissions(new Error("Claude session closed"));
@@ -1910,8 +1917,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
this.logger.trace(
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
"Claude session close: completed",
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnState: this.turnState,
},
"provider.claude.session_close.complete",
);
}
@@ -2188,16 +2200,41 @@ class ClaudeAgentSession implements AgentSession {
label: string,
): Promise<void> {
if (!promise) {
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.skip",
);
return;
}
const startedAt = Date.now();
this.logger.trace({ label }, "Claude query operation wait start");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.start",
);
try {
await withTimeout(promise, 3_000, "timeout");
this.logger.trace(
{ label, durationMs: Date.now() - startedAt },
"Claude query operation settled",
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
durationMs: Date.now() - startedAt,
},
"provider.claude.query_operation.settled",
);
} catch (error) {
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
@@ -2593,7 +2630,16 @@ class ClaudeAgentSession implements AgentSession {
}
const pump = this.runQueryPump().catch((error) => {
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.exit_unexpected",
);
});
this.queryPumpPromise = pump;
@@ -2609,24 +2655,34 @@ class ClaudeAgentSession implements AgentSession {
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
if (!claudeDebug) {
return;
}
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined,
rawEvent: message,
},
"Claude query pump: raw SDK message",
"provider.claude.raw_event",
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
@@ -2739,16 +2795,18 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message);
if (claudeDebug) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
turnId,
},
"Claude query pump: SDK message",
);
}
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: turnId ?? undefined,
messageType: message.type,
identifiers,
rawEvent: message,
},
"provider.claude.parsed_event",
);
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
@@ -2816,7 +2874,6 @@ class ClaudeAgentSession implements AgentSession {
this.logger.warn(
{
claudeSessionId: this.claudeSessionId,
error: staleResumeError,
},
"Claude resumed session no longer exists; invalidating persisted session",
@@ -2846,7 +2903,15 @@ class ClaudeAgentSession implements AgentSession {
private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.trace("interruptActiveTurn: no query to interrupt");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
},
"provider.claude.interrupt.no_query",
);
return;
}
this.pendingInterruptAbort = true;
@@ -3455,6 +3520,16 @@ class ClaudeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.claude.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);

View File

@@ -1,7 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex";
@@ -27,39 +31,7 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
},
];
interface CodexRequestFn {
(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
interface CodexClientLike {
request: CodexRequestFn;
}
interface CodexSessionTestAccess {
client: CodexClientLike | null;
connected: boolean;
currentThreadId: string | null;
serviceTier: "fast" | null;
planModeEnabled: boolean;
cachedRuntimeInfo: unknown;
ensureThreadLoaded: () => Promise<void>;
ensureThread: () => Promise<void>;
buildUserInput: (...args: unknown[]) => Promise<unknown>;
resolveSlashCommandInvocation: (...args: unknown[]) => Promise<unknown>;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
}
type CodexFeaturesTestSession = AgentSession & {
connected: boolean;
currentThreadId: string | null;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
};
function asInternals(session: CodexFeaturesTestSession): CodexSessionTestAccess {
return session as unknown as CodexSessionTestAccess;
}
type CodexFeaturesTestSession = AgentSession;
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
@@ -71,28 +43,36 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
};
}
function createSession(
configOverrides: Partial<AgentSessionConfig> = {},
): CodexFeaturesTestSession {
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): {
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
} {
const config = createConfig(configOverrides);
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER },
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as unknown as CodexFeaturesTestSession;
session.connected = true;
session.currentThreadId = "test-thread";
session.collaborationModes = TEST_COLLABORATION_MODES;
session.refreshResolvedCollaborationMode();
return session;
async () => appServer.child,
) as CodexFeaturesTestSession;
return { session, appServer };
}
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
}> {
const harness = createSessionHarness(configOverrides);
await harness.session.connect();
harness.appServer.assertNoErrors();
return harness;
}
describe("Codex app-server provider features", () => {
test("features returns fast and plan toggles when supported", async () => {
const session = createSession();
const { session } = await createConnectedSession();
expect(session.features).toEqual([
{
@@ -140,8 +120,8 @@ describe("Codex app-server provider features", () => {
]);
});
test("features returns only plan toggle when model does not support fast mode", () => {
const session = createSession({ model: "gpt-3.5-turbo" });
test("features returns only plan toggle when model does not support fast mode", async () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([
{
@@ -157,49 +137,56 @@ describe("Codex app-server provider features", () => {
});
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
const session = createSession();
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
expect(asInternals(session).serviceTier).toBe("fast");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
});
});
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
const session = createSession({
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true },
});
await session.setFeature?.("fast_mode", false);
await session.startTurn("hello");
expect(asInternals(session).serviceTier).toBeNull();
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
test("setFeature invalidates cachedRuntimeInfo", async () => {
const session = createSession();
test("setFeature invalidates runtime info", async () => {
const { session } = await createConnectedSession();
await session.getRuntimeInfo();
expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
extra: { collaborationMode: "Plan" },
});
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("plan_mode", true);
expect(asInternals(session).cachedRuntimeInfo).toBeNull();
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
extra: { collaborationMode: "Plan" },
});
});
test("setFeature throws for unknown feature ids", async () => {
const session = createSession();
const { session } = createSessionHarness();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature",
);
});
test("constructor restores feature flags from config.featureValues", () => {
const session = createSession({
test("constructor restores feature flags from config.featureValues", async () => {
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true, plan_mode: true },
});
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(true);
expect(session.features).toEqual([
{
type: "toggle",
@@ -220,41 +207,29 @@ describe("Codex app-server provider features", () => {
value: true,
},
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
collaborationMode: expect.objectContaining({
mode: "plan",
}),
});
});
test("startTurn includes serviceTier when fast mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
serviceTier: "fast",
}),
expect.any(Number),
);
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
});
});
test("setModel clears fast mode when switching to an unsupported model", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo");
@@ -270,41 +245,23 @@ describe("Codex app-server provider features", () => {
value: false,
},
]);
expect(asInternals(session).serviceTier).toBeNull();
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.not.objectContaining({
serviceTier: expect.anything(),
}),
expect.any(Number),
);
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("plan_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
expect.any(Number),
);
});
});
});

View File

@@ -1,9 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import type {
AgentLaunchContext,
@@ -17,11 +19,12 @@ import {
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import {
createCodexAppServerChildProcessStub,
TestCodexAppServerPeer,
} from "./codex/test-utils/test-app-server-peer.js";
createFakeCodexAppServer,
waitForNextPermission,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord {
name: string;
@@ -32,6 +35,7 @@ interface CollaborationModeRecord {
}
interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
loadPersistedHistory(): Promise<void>;
@@ -100,32 +104,98 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")");
}
function waitForNextPermission(
session: AgentSession,
events: AgentStreamEvent[],
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
const existing = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
if (existing) {
return Promise.resolve(existing);
}
type CapturedFakeCodexRecord = Record<string, unknown>;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
async function runCustomCodexProviderTurn(
providerId: string,
baseUrl: string,
): Promise<CapturedFakeCodexRecord[]> {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-"));
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
writeFileSync(
fakeAppServerPath,
`
const fs = require("node:fs");
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
let buffer = "";
fs.appendFileSync(capturePath, JSON.stringify({
kind: "env",
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
}) + "\\n");
function record(method, params) {
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n");
}
function resultFor(method) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "skills/list") return { data: [] };
if (method === "config/read") return { config: {} };
if (method === "getUserSavedConfig") return { config: {} };
if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] };
if (method === "thread/start") return { thread: { id: "thread-1" } };
if (method === "turn/start") return {};
return {};
}
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
const message = JSON.parse(line);
record(message.method, message.params);
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
}
});
`,
);
const registry = buildProviderRegistry(createTestLogger(), {
providerOverrides: {
[providerId]: {
extends: "codex",
label: "Custom Codex",
command: [process.execPath, fakeAppServerPath],
env: {
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: baseUrl,
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
},
},
},
});
const session = await registry[providerId].createClient(createTestLogger()).createSession({
provider: providerId,
cwd: "/workspace/project",
modeId: "auto",
model: "custom-model",
});
try {
await session.startTurn("use the custom endpoint");
return readFileSync(capturedRequestsPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord);
} finally {
await session.close();
rmSync(tempDir, { recursive: true, force: true });
}
}
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
const threadStart = records.find((record) => record.method === "thread/start");
const params = threadStart?.params as Record<string, unknown> | undefined;
return params?.config;
}
describe("Codex app-server provider", () => {
@@ -191,15 +261,21 @@ describe("Codex app-server provider", () => {
test("disposes an unresponsive app-server child with SIGKILL", async () => {
vi.useFakeTimers();
const child = createCodexAppServerChildProcessStub({ exitOnKill: false });
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
try {
const disposePromise = client.dispose();
expect(child.killSignals).toEqual(["SIGTERM"]);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
await vi.advanceTimersByTimeAsync(2_000);
expect(child.killSignals).toEqual(["SIGTERM", "SIGKILL"]);
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
await vi.advanceTimersByTimeAsync(1_000);
await expect(disposePromise).resolves.toBeUndefined();
@@ -209,7 +285,7 @@ describe("Codex app-server provider", () => {
});
test("round-trips server-initiated command approvals through the real app-server transport", async () => {
const peer = new TestCodexAppServerPeer({
const appServer = createFakeCodexAppServer({
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
@@ -218,28 +294,21 @@ describe("Codex app-server provider", () => {
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => peer.child,
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
peer.assertNoErrors();
appServer.assertNoErrors();
const permissionRequested = waitForNextPermission(session, events);
peer.writeRequest(
"item/commandExecution/requestApproval",
{
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
},
41,
);
const permissionRequested = waitForNextPermission(session);
appServer.requestCommandApproval({
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
});
const permissionEvent = await permissionRequested;
expect(permissionEvent.request).toMatchObject({
@@ -262,14 +331,128 @@ describe("Codex app-server provider", () => {
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
await expect(peer.waitForResponse(41, { decision: "accept" })).resolves.toMatchObject({
id: 41,
result: { decision: "accept" },
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({
decision: "accept",
});
peer.assertNoErrors();
appServer.assertNoErrors();
await session.close();
});
test("configures Codex app-server to use a custom provider base URL", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-iisb",
"https://custom-relay.example.com",
);
expect(capturedRequests[0]).toEqual({
kind: "env",
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: "https://custom-relay.example.com",
});
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-iisb",
model_providers: {
"codex-iisb": {
name: "Custom Codex",
base_url: "https://custom-relay.example.com/v1",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
wire_api: "responses",
},
},
});
});
test("does not append v1 twice for custom Codex provider base URLs", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-custom",
"https://custom-relay.example.com/v1/",
);
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-custom",
model_providers: {
"codex-custom": expect.objectContaining({
base_url: "https://custom-relay.example.com/v1",
}),
},
});
});
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
},
"thread/start": () => {
threadRequests.push("thread/start");
return { thread: { id: "replacement-empty-thread-id" } };
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
getUserSavedConfig: () => {
threadRequests.push("getUserSavedConfig");
return { config: {} };
},
"config/read": () => {
threadRequests.push("config/read");
return { config: {} };
},
"model/list": () => {
threadRequests.push("model/list");
return {
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
};
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
Promise.resolve(false);
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const outcome = await Promise.race([
provider
.resumeSession({
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
})
.then(
() => "resolved" as const,
(error) => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"no rollout found for thread id archived-thread-id",
);
return "rejected" as const;
},
),
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
]);
if (outcome === "timed_out") {
appServer.child.kill("SIGTERM");
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume"]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
const cwd = path.join(tempDir, "repo", "packages", "app");
@@ -280,12 +463,8 @@ describe("Codex app-server provider", () => {
path.join(repoSkillDir, "SKILL.md"),
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
);
const resolvedRepoRoots: string[] = [];
const workspaceGitService = {
resolveRepoRoot: async (pathToResolve: string) => {
resolvedRepoRoots.push(pathToResolve);
return path.join(tempDir, "repo");
},
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")),
};
try {
@@ -296,7 +475,7 @@ describe("Codex app-server provider", () => {
description: "Ship changes carefully.",
argumentHint: "",
});
expect(resolvedRepoRoots).toEqual([cwd]);
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
@@ -935,6 +1114,52 @@ describe("Codex app-server provider", () => {
});
});
test("keeps the parent sub-agent running when a child command fails during the child turn", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-child-command-failure",
tool: "spawnAgent",
status: "completed",
prompt: "Fix the regression test-first.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "running", message: null },
},
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
item: {
type: "commandExecution",
id: "child-failing-command",
status: "failed",
command: "npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts",
aggregatedOutput: "expected false to be true",
exitCode: 1,
error: { message: "Command failed" },
},
});
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Fix the regression test-first.",
},
});
});
test("loads Codex persisted history from the app-server thread", async () => {
const session = createSession();
const requests: Array<{ method: string; params: unknown }> = [];
@@ -984,6 +1209,37 @@ describe("Codex app-server provider", () => {
]);
});
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
const session = createSession({ thinkingOptionId: "medium" });
session.currentThreadId = "archived-thread-id";
const requests: Array<{ method: string; params: unknown }> = [];
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: [] };
}
if (method === "thread/resume") {
throw new Error("no rollout found for thread id archived-thread-id");
}
if (method === "thread/start") {
return { thread: { id: "replacement-empty-thread-id" } };
}
return {};
}),
};
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
"no rollout found for thread id archived-thread-id",
);
expect(session.currentThreadId).toBe("archived-thread-id");
expect(requests).toEqual([
{ method: "thread/loaded/list", params: {} },
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
]);
});
test("appends blank-line spacing to /goal status messages", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession({}, { goalsEnabled: true });
@@ -1790,7 +2046,14 @@ describe("Codex persisted sessions", () => {
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => {
return createCodexAppServerChildProcessStub();
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
};
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });

View File

@@ -1,32 +1,33 @@
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
McpServerConfig,
AgentPersistenceHandle,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
ToolCallTimelineItem,
AgentUsage,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type McpServerConfig,
type AgentPersistenceHandle,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type ToolCallTimelineItem,
type AgentUsage,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import type { Logger } from "pino";
import { homedir } from "node:os";
@@ -55,7 +56,10 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
CodexAppServerClient,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import {
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
@@ -174,9 +178,16 @@ interface CodexAppServerClientLike {
interface CodexAppServerAgentDeps {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
id: string;
label: string;
extends: string;
};
customCodexConfig?: Record<string, unknown> | null;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
getTraceContext: () => CodexAppServerTraceContext,
) => CodexAppServerClientLike;
}
@@ -2525,6 +2536,50 @@ function buildCodexAppServerInitializeParams(): {
};
}
function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const withoutTrailingSlashes = trimmed.replace(/\/+$/u, "");
if (withoutTrailingSlashes.endsWith("/v1")) {
return withoutTrailingSlashes;
}
return `${withoutTrailingSlashes}/v1`;
}
function buildCodexCustomProviderConfig(
runtimeSettings: ProviderRuntimeSettings | undefined,
customProvider: CodexAppServerAgentDeps["customProvider"],
): Record<string, unknown> | null {
if (customProvider?.extends !== CODEX_PROVIDER) {
return null;
}
const baseUrl = runtimeSettings?.env?.OPENAI_BASE_URL;
if (typeof baseUrl !== "string") {
return null;
}
const normalizedBaseUrl = normalizeOpenAICompatibleBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return null;
}
const providerConfig: Record<string, unknown> = {
name: customProvider.label,
base_url: normalizedBaseUrl,
wire_api: "responses",
};
if (runtimeSettings?.env?.OPENAI_API_KEY?.trim()) {
providerConfig.env_key = "OPENAI_API_KEY";
providerConfig.requires_openai_auth = false;
}
return {
model_provider: customProvider.id,
model_providers: {
[customProvider.id]: providerConfig,
},
};
}
interface CodexSubAgentCallState {
callId: string;
toolCall: ToolCallTimelineItem;
@@ -2603,8 +2658,13 @@ class CodexAppServerAgentSession implements AgentSession {
private readonly deps: CodexAppServerAgentDeps = {},
private readonly ephemeral: boolean = false,
private readonly goalsEnabled: boolean = false,
private readonly agentId?: string,
) {
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
this.logger = logger.child({
module: "agent",
provider: CODEX_PROVIDER,
agentId: this.agentId,
});
if (config.modeId === undefined) {
throw new Error("Codex agent requires modeId to be specified");
}
@@ -2641,7 +2701,7 @@ class CodexAppServerAgentSession implements AgentSession {
async connect(): Promise<void> {
if (this.connected) return;
const child = await this.spawnAppServer();
this.client = new CodexAppServerClient(child, this.logger);
this.client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
@@ -2659,6 +2719,14 @@ class CodexAppServerAgentSession implements AgentSession {
this.connected = true;
}
private traceContext(): CodexAppServerTraceContext {
return {
agentId: this.agentId,
sessionId: this.currentThreadId ?? undefined,
turnId: this.activeForegroundTurnId ?? undefined,
};
}
private async loadCollaborationModes(): Promise<void> {
if (!this.client) return;
try {
@@ -2679,7 +2747,16 @@ class CodexAppServerAgentSession implements AgentSession {
};
});
} catch (error) {
this.logger.trace({ error }, "Failed to load collaboration modes");
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.collaboration_modes_failed",
);
this.collaborationModes = [];
}
this.refreshResolvedCollaborationMode();
@@ -2711,7 +2788,16 @@ class CodexAppServerAgentSession implements AgentSession {
}
this.cachedSkills = skills;
} catch (error) {
this.logger.trace({ error }, "Failed to load skills list");
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.skills_failed",
);
this.cachedSkills = [];
}
}
@@ -2901,9 +2987,10 @@ class CodexAppServerAgentSession implements AgentSession {
}
await this.client.request("thread/resume", params);
} catch (error) {
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
this.currentThreadId = null;
await this.ensureThread();
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
}
}
@@ -3587,6 +3674,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.extra?.codex) {
Object.assign(innerConfig, this.config.extra.codex);
}
if (this.deps.customCodexConfig) {
Object.assign(innerConfig, this.deps.customCodexConfig);
}
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
}
@@ -3604,6 +3694,16 @@ class CodexAppServerAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.codex.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);
@@ -3619,6 +3719,7 @@ class CodexAppServerAgentSession implements AgentSession {
private handleNotification(method: string, params: unknown): void {
const parsed = CodexNotificationSchema.parse({ method, params });
this.traceParsedNotification(method, params, parsed);
switch (parsed.kind) {
case "thread_started":
this.handleThreadStartedNotification(parsed);
@@ -3675,6 +3776,25 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private traceParsedNotification(
method: string,
params: unknown,
parsed: z.infer<typeof CodexNotificationSchema>,
): void {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
parsed,
},
"provider.codex.parsed_event",
);
}
private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null {
if (!threadId || threadId === this.currentThreadId) {
return null;
@@ -3786,10 +3906,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(
callId,
timelineItem.type === "tool_call" && timelineItem.status === "failed" ? "failed" : "running",
);
this.emitSubAgentActivityUpdate(callId, "running");
}
private shouldSkipCompletedThreadItem(
@@ -4270,7 +4387,17 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
this.warnedUnknownNotificationMethods.add(method);
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
},
"provider.codex.event_unhandled",
);
}
private warnInvalidNotificationPayload(method: string, params: unknown): void {
@@ -4531,6 +4658,16 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {},
) {}
private sessionDeps(): CodexAppServerAgentDeps {
return {
...this.deps,
customCodexConfig: buildCodexCustomProviderConfig(
this.runtimeSettings,
this.deps.customProvider,
),
};
}
private resolveGoalsEnabled(): Promise<boolean> {
if (!this.goalsEnabledPromise) {
this.goalsEnabledPromise = (async () => {
@@ -4538,7 +4675,14 @@ export class CodexAppServerAgentClient implements AgentClient {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
this.logger.trace({ versionOutput, enabled }, "Resolved codex goals feature gate");
this.logger.trace(
{
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.goals_resolved",
);
return enabled;
} catch (error) {
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
@@ -4551,7 +4695,7 @@ export class CodexAppServerAgentClient implements AgentClient {
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean },
options?: { goalsEnabled?: boolean; agentId?: string },
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const args = [...launchPrefix.args, "app-server"];
@@ -4560,10 +4704,12 @@ export class CodexAppServerAgentClient implements AgentClient {
}
this.logger.trace(
{
agentId: options?.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options?.goalsEnabled === true,
},
"Spawning Codex app server",
"provider.codex.spawn",
);
const child = spawnProcess(launchPrefix.command, args, {
detached: process.platform !== "win32",
@@ -4595,10 +4741,12 @@ export class CodexAppServerAgentClient implements AgentClient {
sessionConfig,
null,
this.logger,
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.deps,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
options?.persistSession === false,
goalsEnabled,
launchContext?.agentId,
);
await session.connect();
return session;
@@ -4621,10 +4769,12 @@ export class CodexAppServerAgentClient implements AgentClient {
merged,
handle,
this.logger,
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.deps,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
false,
goalsEnabled,
launchContext?.agentId,
);
await session.connect();
return session;
@@ -4635,7 +4785,7 @@ export class CodexAppServerAgentClient implements AgentClient {
): Promise<PersistedAgentDescriptor[]> {
const child = await this.spawnAppServer();
const client =
this.deps._createCodexClient?.(child, this.logger) ??
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, this.logger);
try {

View File

@@ -1,20 +1,22 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
import { TestCodexAppServerPeer } from "./test-utils/test-app-server-peer.js";
describe("Codex app-server transport", () => {
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(peer.child, createTestLogger());
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("model/list", {});
peer.writeNonJsonStdout("Codex ha iniciado en modo localizado");
peer.writeResponse(1, { data: [] });
child.stdout.write("Codex ha iniciado en modo localizado\n");
child.stdout.write('{"id":1,"result":{"data":[]}}\n');
await expect(request).resolves.toEqual({ data: [] });
peer.close();
child.stdout.end();
child.stderr.end();
child.stdin.end();
});
test.each([
@@ -23,19 +25,23 @@ describe("Codex app-server transport", () => {
"item/tool/requestUserInput",
"tool/requestUserInput",
])("answers server-initiated %s requests through registered handlers", async (method) => {
const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(peer.child, createTestLogger());
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const handlerCalls: unknown[] = [];
client.setRequestHandler(method, async (params) => {
handlerCalls.push(params);
return { ok: true };
});
const response = peer.nextPaseoOutputLine();
peer.writeRequest(method);
const response = new Promise<string>((resolve) => {
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
expect(handlerCalls).toEqual([{}]);
peer.close();
child.stdout.end();
child.stderr.end();
child.stdin.end();
});
});

View File

@@ -35,6 +35,12 @@ interface PendingRequest {
type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
export interface CodexAppServerTraceContext {
agentId?: string;
sessionId?: string;
turnId?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -54,6 +60,24 @@ function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
return typeof msg.method === "string" && msg.id === undefined;
}
function readProviderSessionId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
return typeof params.threadId === "string" ? params.threadId : undefined;
}
function readProviderTurnId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
if (typeof params.turnId === "string") {
return params.turnId;
}
const turn = params.turn;
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
export class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
@@ -66,6 +90,7 @@ export class CodexAppServerClient {
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => {
@@ -224,6 +249,19 @@ export class CodexAppServerClient {
}
if (isJsonRpcNotification(raw)) {
const traceContext = this.getTraceContext();
this.logger.trace(
{
provider: "codex",
agentId: traceContext.agentId,
sessionId: traceContext.sessionId ?? readProviderSessionId(raw.params),
turnId: traceContext.turnId ?? readProviderTurnId(raw.params),
method: raw.method,
params: raw.params,
rawEvent: raw,
},
"provider.codex.raw_event",
);
this.notificationHandler?.(raw.method, raw.params);
}
}

View File

@@ -0,0 +1,228 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
};
export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess;
assertNoErrors(): void;
waitForTurnStart(): Promise<JsonObject>;
requestCommandApproval(params: {
itemId: string;
threadId: string;
turnId: string;
command: string;
cwd: string;
reason: string;
}): void;
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
}
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export function createFakeCodexAppServer(
handlers: Record<string, FakeCodexAppServerHandler> = {},
): FakeCodexAppServer {
const child = createCodexAppServerChildProcess();
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"config/read": () => ({ config: {} }),
getUserSavedConfig: () => ({ config: {} }),
"model/list": () => ({
data: [
{
id: "gpt-5.4",
isDefault: true,
defaultReasoningEffort: "medium",
},
],
}),
"skills/list": () => ({ data: [] }),
"thread/start": () => ({ thread: { id: "thread-1" } }),
"thread/loaded/list": () => ({ data: [] }),
"thread/resume": () => ({}),
"turn/start": () => ({}),
...handlers,
};
const messages: JsonObject[] = [];
const errors: Error[] = [];
const approvalRequestIds = new Map<string, number>();
const waiters = new Set<{
predicate: (message: JsonObject) => boolean;
resolve: (message: JsonObject) => void;
}>();
let buffer = "";
let nextServerRequestId = 1;
function processMessage(message: JsonObject): void {
messages.push(message);
for (const waiter of Array.from(waiters)) {
if (waiter.predicate(message)) {
waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = responseHandlers[message.method];
if (!handler) {
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
return undefined;
})
.catch((error) => {
child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const parsed: unknown = JSON.parse(line);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
processMessage(parsed as JsonObject);
}
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
});
function waitForMessage(
predicate: (message: JsonObject) => boolean,
label: string,
): Promise<JsonObject> {
const existing = messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonObject) => {
clearTimeout(timeout);
resolve(message);
},
};
waiters.add(waiter);
});
}
return {
child,
assertNoErrors() {
if (errors.length > 0) {
throw errors[0];
}
},
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "turn/start",
"turn start request",
);
return toJsonObject(message.params);
},
requestCommandApproval(params) {
const requestId = nextServerRequestId;
nextServerRequestId += 1;
approvalRequestIds.set(params.itemId, requestId);
child.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: requestId,
method: "item/commandExecution/requestApproval",
params,
})}\n`,
);
},
async waitForCommandApprovalDecision(itemId) {
const requestId = approvalRequestIds.get(itemId);
if (requestId === undefined) {
throw new Error(`No pending fake Codex app-server approval for ${itemId}`);
}
const message = await waitForMessage(
(candidate) =>
candidate.id === requestId && !("method" in candidate) && "result" in candidate,
"command approval response",
);
return message.result;
},
};
}
function toJsonObject(value: unknown): JsonObject {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonObject;
}
return {};
}
export function waitForNextPermission(
session: AgentSession,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}

View File

@@ -1,183 +0,0 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { isDeepStrictEqual } from "node:util";
interface JsonRecord {
[key: string]: unknown;
}
type RequestHandler = (params: unknown) => unknown;
type StubbedCodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killSignals: Array<NodeJS.Signals | number | null>;
};
function isJsonRecord(value: unknown): value is JsonRecord {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function parseJsonLine(line: string): JsonRecord | null {
const parsed: unknown = JSON.parse(line);
return isJsonRecord(parsed) ? parsed : null;
}
export function createCodexAppServerChildProcessStub(
options: { exitOnKill?: boolean } = {},
): StubbedCodexAppServerChildProcess {
const child = new EventEmitter() as StubbedCodexAppServerChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killSignals = [];
Object.defineProperty(child, "exitCode", { value: null, configurable: true });
Object.defineProperty(child, "signalCode", { value: null, configurable: true });
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killSignals.push(signal ?? null);
if (options.exitOnKill !== false) {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
}
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export class TestCodexAppServerPeer {
readonly child: StubbedCodexAppServerChildProcess;
private readonly messages: JsonRecord[] = [];
private readonly errors: Error[] = [];
private readonly waiters = new Set<{
predicate: (message: JsonRecord) => boolean;
resolve: (message: JsonRecord) => void;
}>();
private buffer = "";
constructor(handlers: Record<string, RequestHandler> = {}) {
this.child = createCodexAppServerChildProcessStub();
this.child.stdin.on("data", (chunk) => {
this.acceptPaseoOutput(chunk.toString(), handlers);
});
}
writeNonJsonStdout(line: string): void {
this.child.stdout.write(`${line}\n`);
}
writeResponse(id: number, result: unknown): void {
this.child.stdout.write(`${JSON.stringify({ id, result })}\n`);
}
writeRequest(method: string, params: unknown = {}, id = 7): void {
this.child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
}
nextPaseoOutputLine(): Promise<string> {
return new Promise((resolve) => {
this.child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
}
async waitForResponse(id: number, result: unknown): Promise<JsonRecord> {
return this.waitForMessage(
(message) =>
message.id === id && !("method" in message) && isDeepStrictEqual(message.result, result),
`response ${id}`,
);
}
assertNoErrors(): void {
if (this.errors.length > 0) {
throw this.errors[0];
}
}
close(): void {
this.child.stdout.end();
this.child.stderr.end();
this.child.stdin.end();
}
private acceptPaseoOutput(chunk: string, handlers: Record<string, RequestHandler>): void {
this.buffer += chunk;
for (;;) {
const newlineIndex = this.buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const message = parseJsonLine(line);
if (message) {
this.processPaseoMessage(message, handlers);
}
} catch (error) {
this.errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
}
private processPaseoMessage(message: JsonRecord, handlers: Record<string, RequestHandler>): void {
this.messages.push(message);
for (const waiter of Array.from(this.waiters)) {
if (waiter.predicate(message)) {
this.waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = handlers[message.method];
if (!handler) {
this.errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
this.writeResponse(message.id as number, result);
return undefined;
})
.catch((error) => {
this.child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
private waitForMessage(
predicate: (message: JsonRecord) => boolean,
label: string,
): Promise<JsonRecord> {
const existing = this.messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonRecord) => {
clearTimeout(timeout);
resolve(message);
},
};
this.waiters.add(waiter);
});
}
}

View File

@@ -64,6 +64,39 @@ describe("codex tool-call mapper", () => {
});
});
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-pwsh-string",
status: "running",
command:
'"C:\\Users\\example\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe" -NoLogo -NoProfile -Command "echo hello"',
cwd: "C:\\repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "C:\\repo",
});
});
it("unwraps cmd wrapper arrays for commandExecution on Windows", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-cmd-array",
status: "running",
command: ["cmd.exe", "/c", "echo hello"],
cwd: "C:\\repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "C:\\repo",
});
});
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",

View File

@@ -200,21 +200,48 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (!wrapperMatch) {
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (unixWrapperMatch) {
const candidate = unixWrapperMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
return stripMatchingEdgeQuotes(candidate);
}
const windowsWrapperMatch = trimmed.match(
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i,
);
if (!windowsWrapperMatch) {
return trimmed;
}
const candidate = wrapperMatch[1]?.trim() ?? "";
const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? "";
if (!wrappedCommand) {
return trimmed;
}
const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i);
if (!commandMatch) {
return trimmed;
}
const candidate = commandMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
return stripMatchingEdgeQuotes(candidate);
}
function stripMatchingEdgeQuotes(value: string): string {
if (
(candidate.startsWith('"') && candidate.endsWith('"')) ||
(candidate.startsWith("'") && candidate.endsWith("'"))
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return candidate.slice(1, -1);
return value.slice(1, -1);
}
return candidate;
return value;
}
function isWindowsShellCommand(command: string): boolean {
const normalized = command.replace(/^["']|["']$/g, "");
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
}
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
@@ -236,6 +263,14 @@ function normalizeCommandExecutionCommand(value: unknown): string | undefined {
const unwrapped = parts[2]?.trim();
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
}
if (
parts.length >= 3 &&
isWindowsShellCommand(parts[0] ?? "") &&
/^(-command|-c|\/c)$/i.test(parts[1] ?? "")
) {
const unwrapped = parts.slice(2).join(" ").trim();
return unwrapped.length > 0 ? stripMatchingEdgeQuotes(unwrapped) : undefined;
}
return parts.join(" ");
}

View File

@@ -1,10 +1,17 @@
import type { Logger } from "pino";
import { homedir } from "node:os";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { ACPAgentClient } from "./acp-agent.js";
import {
ACPAgentClient,
type ACPBeforeModeWriteResult,
type ACPProviderModeWriteResult,
type ACPProviderModeWriterContext,
type SessionStateResponse,
} from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -22,21 +29,31 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
supportsToolInvocations: true,
};
const COPILOT_MODES: AgentMode[] = [
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
const COPILOT_AUTOPILOT_MODE_ID =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
export const COPILOT_ALLOW_ALL_MODE_ID = "allow-all";
const COPILOT_ALLOW_ALL_CONFIG_ID = "allow_all";
const COPILOT_ALLOW_ALL_ON = "on";
const COPILOT_ALLOW_ALL_OFF = "off";
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
export const COPILOT_MODES: AgentMode[] = [
{
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
id: COPILOT_AGENT_MODE_ID,
label: "Agent",
description: "Default agent mode for conversational interactions",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
id: COPILOT_PLAN_MODE_ID,
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
id: COPILOT_ALLOW_ALL_MODE_ID,
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
},
];
@@ -53,6 +70,11 @@ export class CopilotACPAgentClient extends ACPAgentClient {
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
capabilities: COPILOT_CAPABILITIES,
});
}
@@ -113,3 +135,126 @@ export class CopilotACPAgentClient extends ACPAgentClient {
}
}
}
export function transformCopilotSessionResponse(
response: SessionStateResponse,
): SessionStateResponse {
if (!response.modes) {
return response;
}
const allowAllEnabled = isCopilotAllowAllEnabled(response.configOptions ?? []);
return {
...response,
modes: {
...response.modes,
availableModes: response.modes.availableModes
?.filter(
(mode) => mode.id !== COPILOT_AUTOPILOT_MODE_ID && mode.id !== COPILOT_ALLOW_ALL_MODE_ID,
)
.concat({
id: COPILOT_ALLOW_ALL_MODE_ID,
name: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
}),
currentModeId: allowAllEnabled
? COPILOT_ALLOW_ALL_MODE_ID
: (transformCopilotModeId(response.modes.currentModeId ?? COPILOT_AGENT_MODE_ID) ??
COPILOT_AGENT_MODE_ID),
},
};
}
export function transformCopilotConfigOptions(
configOptions: SessionConfigOption[],
): SessionConfigOption[] {
const allowAllEnabled = isCopilotAllowAllEnabled(configOptions);
return configOptions.map((option) => {
if (option.type !== "select" || option.category !== "mode") {
return option;
}
// Trust Copilot's allow_all config value as the source of truth when it changes in-process.
const options = flattenCopilotModeOptions(option.options)
.filter(
(choice) =>
choice.value !== COPILOT_AUTOPILOT_MODE_ID && choice.value !== COPILOT_ALLOW_ALL_MODE_ID,
)
.concat({
value: COPILOT_ALLOW_ALL_MODE_ID,
name: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
});
return {
...option,
currentValue: allowAllEnabled
? COPILOT_ALLOW_ALL_MODE_ID
: (transformCopilotModeId(option.currentValue) ?? COPILOT_AGENT_MODE_ID),
options,
};
});
}
function flattenCopilotModeOptions(
options: SelectConfigOption["options"],
): Array<{ value: string; name: string; description?: string | null }> {
const flattened: Array<{ value: string; name: string; description?: string | null }> = [];
for (const option of options) {
if ("value" in option) {
flattened.push(option);
continue;
}
flattened.push(...option.options);
}
return flattened;
}
export function transformCopilotModeId(modeId: string): string | null {
return modeId === COPILOT_AUTOPILOT_MODE_ID ? COPILOT_AGENT_MODE_ID : modeId;
}
export async function writeCopilotProviderMode(
context: ACPProviderModeWriterContext,
): Promise<ACPProviderModeWriteResult> {
// COMPAT(copilotAutopilotMode): added in v0.1.75, remove after 2026-11-12 once old clients no longer send Copilot's old ACP autopilot mode ID.
const requestsAllowAll =
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID ||
context.requestedModeId === COPILOT_AUTOPILOT_MODE_ID;
if (!requestsAllowAll) {
return { handled: false };
}
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId,
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
value: COPILOT_ALLOW_ALL_ON,
});
return {
handled: true,
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
configOptions: response.configOptions,
};
}
export async function beforeCopilotModeWriter(
context: ACPProviderModeWriterContext,
): Promise<ACPBeforeModeWriteResult> {
if (
context.currentModeId !== COPILOT_ALLOW_ALL_MODE_ID ||
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID
) {
return {};
}
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId,
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
value: COPILOT_ALLOW_ALL_OFF,
});
return { configOptions: response.configOptions };
}
function isCopilotAllowAllEnabled(configOptions: SessionConfigOption[]): boolean {
return configOptions.some(
(option) =>
option.type === "select" &&
option.id === COPILOT_ALLOW_ALL_CONFIG_ID &&
option.currentValue === COPILOT_ALLOW_ALL_ON,
);
}

View File

@@ -78,6 +78,14 @@ async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Pr
return result;
}
function createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
function isBinaryInstalled(binary: string): boolean {
try {
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
@@ -566,6 +574,160 @@ describe("OpenCode adapter context-window normalization", () => {
});
describe("OpenCode adapter startTurn error handling", () => {
test("unwraps OpenCode global event payloads during a turn", async () => {
const globalEvents = [
{
payload: {
type: "server.connected",
properties: {},
},
},
{
directory: "/tmp/other",
payload: {
type: "message.part.delta",
properties: {
sessionID: "other-session",
messageID: "msg_other",
partID: "prt_other",
field: "text",
delta: "ignore me",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.updated",
properties: {
info: {
id: "msg_assistant",
sessionID: "ses_unit_test",
role: "assistant",
},
},
},
},
{
directory: "/tmp/test",
payload: {
type: "message.part.delta",
properties: {
sessionID: "ses_unit_test",
messageID: "msg_assistant",
partID: "prt_text",
field: "text",
delta: "Hello from global",
},
},
},
{
directory: "/tmp/test",
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: { type: "idle" },
},
},
},
];
const fakeClient = {
event: {
subscribe: vi.fn(),
},
global: {
event: vi.fn().mockResolvedValue({ stream: createAsyncIterable(globalEvents) }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const turn = await collectTurnEvents(streamSession(session, "hello"));
expect(fakeClient.global.event).toHaveBeenCalledWith({
signal: expect.any(AbortSignal),
sseMaxRetryAttempts: 0,
});
expect(fakeClient.event.subscribe).not.toHaveBeenCalled();
expect(turn.turnCompleted).toBe(true);
expect(turn.turnFailed).toBe(false);
expect(turn.assistantMessages.map((message) => message.text).join("")).toBe(
"Hello from global",
);
});
test("fails a turn when OpenCode retry status does not recover", async () => {
vi.useFakeTimers();
const retryStream: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let emitted = false;
return {
next: async () => {
if (!emitted) {
emitted = true;
return {
done: false,
value: {
payload: {
type: "session.status",
properties: {
sessionID: "ses_unit_test",
status: {
type: "retry",
attempt: 1,
message: "model does not exist",
},
},
},
},
};
}
return new Promise(() => {});
},
};
},
};
const fakeClient = {
global: {
event: vi.fn().mockResolvedValue({ stream: retryStream }),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.startTurn("hello");
await vi.advanceTimersByTimeAsync(10_000);
const failed = events.find((event) => event.type === "turn_failed");
expect(failed).toMatchObject({
type: "turn_failed",
error: expect.stringContaining("model does not exist"),
});
vi.useRealTimers();
});
test("deletes provider session on close when persistence is disabled", async () => {
const fakeClient = {
session: {
@@ -580,6 +742,7 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
new Map(),
undefined,
false,
@@ -607,6 +770,7 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
await session.close();
@@ -615,19 +779,29 @@ describe("OpenCode adapter startTurn error handling", () => {
});
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
// Async iterable that never yields and never resolves. The IIFE in
// startTurn synchronously hits the promptAsync throw and finishes the
// turn before this iterator is ever pulled, so the never-resolving
// promise inside next() is fine and gets garbage-collected.
// Yield the server-connected event, then park forever. The adapter waits
// for that first event before sending the prompt.
const neverYieldingStream: AsyncIterable<OpenCodeEvent> = {
[Symbol.asyncIterator]: () => ({
next: () => new Promise(() => {}),
}),
[Symbol.asyncIterator]: () => {
let emittedConnected = false;
return {
next: () => {
if (!emittedConnected) {
emittedConnected = true;
return Promise.resolve({
done: false,
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
});
}
return new Promise(() => {});
},
};
},
};
const fakeClient = {
event: {
subscribe: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
global: {
event: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
},
session: {
promptAsync: vi.fn(() => {
@@ -641,6 +815,7 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const events: AgentStreamEvent[] = [];
@@ -655,6 +830,58 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(failed.error).toContain("boom: synchronous throw");
}
});
test("delays the next prompt until a slow interrupt abort settles", async () => {
vi.useFakeTimers();
const abortDeferred = createTestDeferred<{ data: boolean; error: undefined }>();
const promptAsync = vi.fn().mockResolvedValue({ data: {}, error: undefined });
const abort = vi
.fn()
.mockReturnValueOnce(abortDeferred.promise)
.mockResolvedValue({ data: true, error: undefined });
const fakeClient = {
global: {
event: vi.fn().mockImplementation(
async (options: {
signal: AbortSignal;
}): Promise<{ stream: AsyncIterable<OpenCodeEvent> }> => ({
stream: abortableOpenCodeStream(options.signal),
}),
),
},
session: {
promptAsync,
abort,
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
await session.startTurn("first");
expect(promptAsync).toHaveBeenCalledTimes(1);
const interruptPromise = session.interrupt();
await vi.advanceTimersByTimeAsync(2_000);
await interruptPromise;
expect(abort).toHaveBeenCalledTimes(1);
const secondTurnPromise = session.startTurn("second");
await vi.advanceTimersByTimeAsync(1_000);
expect(promptAsync).toHaveBeenCalledTimes(1);
abortDeferred.resolve({ data: true, error: undefined });
await secondTurnPromise;
expect(promptAsync).toHaveBeenCalledTimes(2);
await session.interrupt();
vi.useRealTimers();
});
});
describe("OpenCode persisted sessions", () => {
@@ -740,3 +967,45 @@ function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unk
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value), "utf8");
}
function createTestDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
return {
[Symbol.asyncIterator]: () => {
let emittedConnected = false;
return {
next: () => {
if (!emittedConnected) {
emittedConnected = true;
return Promise.resolve({
done: false,
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
});
}
return new Promise<IteratorResult<OpenCodeEvent>>((resolve) => {
if (signal.aborted) {
resolve({ done: true, value: undefined });
return;
}
signal.addEventListener("abort", () => resolve({ done: true, value: undefined }), {
once: true,
});
});
},
};
},
};
}

View File

@@ -13,33 +13,34 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
import type { Logger } from "pino";
import { z } from "zod";
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModelsOptions,
ListModesOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallDetail,
ToolCallTimelineItem,
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModelsOptions,
type ListModesOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
@@ -74,6 +75,8 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
const OPENCODE_RETRY_STATUS_FAILURE_MS = 10_000;
const DEFAULT_MODES: AgentMode[] = [
{
@@ -818,18 +821,32 @@ async function readOpenCodeSessionTimeline(
}
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
const parts = await readOpenCodeStoredParts(storageRoot, messageId);
return readOpenCodeTextFromParts(parts);
}
async function readOpenCodeStoredParts(
storageRoot: string,
messageId: string,
): Promise<OpenCodeStoredPart[]> {
const partRoot = path.join(storageRoot, "part", messageId);
const partFiles = await findJsonFiles(partRoot);
const parts: OpenCodeStoredPart[] = [];
for (const file of partFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
if (parsed?.type === "text" && typeof parsed.text === "string") {
if (parsed) {
parts.push(parsed);
}
}
return parts.sort(
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
);
}
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
return parts
.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n");
@@ -950,7 +967,7 @@ export class OpenCodeAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
@@ -984,9 +1001,11 @@ export class OpenCodeAgentClient implements AgentClient {
client,
session.id,
this.logger,
this.storageRoot,
new Map(this.modelContextWindows),
acquisition.release,
options?.persistSession,
launchContext?.agentId,
);
} catch (error) {
acquisition.release();
@@ -997,7 +1016,7 @@ export class OpenCodeAgentClient implements AgentClient {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
if (!cwd) {
@@ -1025,8 +1044,11 @@ export class OpenCodeAgentClient implements AgentClient {
client,
handle.sessionId,
this.logger,
this.storageRoot,
new Map(this.modelContextWindows),
acquisition.release,
undefined,
launchContext?.agentId,
);
} catch (error) {
acquisition.release();
@@ -1258,6 +1280,28 @@ export interface OpenCodeEventTranslationState {
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
}
interface OpenCodeTraceData {
turnId?: string;
[key: string]: unknown;
}
type OpenCodeTraceMessage =
| "provider.opencode.prompt_async.start"
| "provider.opencode.prompt_async.response"
| "provider.opencode.prompt_async.throw"
| "provider.opencode.subscribe.start"
| "provider.opencode.subscribe.ready"
| "provider.opencode.stream.eof"
| "provider.opencode.turn.fail_eof"
| "provider.opencode.subscribe.error"
| "provider.opencode.raw_event"
| "provider.opencode.event.skip"
| "provider.opencode.parsed_event"
| "provider.opencode.parsed_event.skip_active"
| "provider.opencode.event.terminal"
| "provider.opencode.finish_foreground_turn"
| "provider.opencode.event_emit";
type OpenCodeToolPartEventPart = Extract<
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
{ type: "tool" }
@@ -2122,6 +2166,24 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null {
const record = readOpenCodeRecord(event);
if (!record) {
return null;
}
const payload = readOpenCodeRecord(record.payload);
if (typeof payload?.type === "string") {
return payload as unknown as OpenCodeEvent;
}
if (typeof record.type === "string") {
return record as unknown as OpenCodeEvent;
}
return null;
}
class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
@@ -2134,6 +2196,7 @@ class OpenCodeAgentSession implements AgentSession {
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null;
private pendingAbortPromise: Promise<void> | null = null;
private accumulatedUsage: AgentUsage = {};
private mcpConfigured = false;
private mcpSetupPromise: Promise<void> | null = null;
@@ -2157,19 +2220,22 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null;
private readonly persistSession: boolean;
private deletedFromProvider = false;
private retryFailureTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
_storageRoot: string,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void,
persistSession = true,
private readonly agentId?: string,
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.logger = logger;
this.logger = logger.child({ agentId: this.agentId });
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.releaseServer = releaseServer ?? null;
@@ -2223,9 +2289,18 @@ class OpenCodeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId;
const turnAbortController = this.abortController;
turnAbortController?.abort();
await this.client.session.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
// the running tool actually stops, which can be tens of seconds for
// long-running tools. Cap the wait so the user-visible cancel lands
// quickly while still giving OpenCode a chance to confirm the abort
// cleanly. Drop the timeout once upstream returns abort acknowledgement
// before tool teardown.
const abortPromise = this.beginSessionAbort(turnId, "interrupt");
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId },
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
);
});
if (turnId) {
this.finishForegroundTurn(
@@ -2235,6 +2310,46 @@ class OpenCodeAgentSession implements AgentSession {
}
}
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
const abortPromise = this.client.session
.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
})
.then(() => undefined)
.catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId, reason },
"OpenCode session.abort rejected",
);
});
const trackedAbortPromise = abortPromise.finally(() => {
if (this.pendingAbortPromise === trackedAbortPromise) {
this.pendingAbortPromise = null;
}
});
this.pendingAbortPromise = trackedAbortPromise;
return trackedAbortPromise;
}
private async awaitPendingAbortBeforeStartingTurn(): Promise<void> {
const pendingAbortPromise = this.pendingAbortPromise;
if (!pendingAbortPromise) {
return;
}
await withTimeout(
pendingAbortPromise,
OPENCODE_PENDING_ABORT_START_TIMEOUT_MS,
"OpenCode pending session.abort",
).catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId },
"OpenCode session.abort was still pending before starting the next turn",
);
});
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
@@ -2242,11 +2357,13 @@ class OpenCodeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
await this.awaitPendingAbortBeforeStartingTurn();
this.runningToolCalls.clear();
this.subAgentsByCallId.clear();
this.subAgentCallIdByChildSessionId.clear();
this.pendingChildToolPartsBySessionId.clear();
this.clearRetryFailureTimer();
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
@@ -2380,6 +2497,14 @@ class OpenCodeAgentSession implements AgentSession {
// SDK input validation) is caught alongside async rejections. A plain
// `.then().catch()` chain would let a sync throw escape unhandled.
void (async () => {
this.traceOpenCode("provider.opencode.prompt_async.start", {
turnId,
sessionId: this.sessionId,
model,
effectiveMode,
effectiveVariant,
partTypes: parts.map((p) => p.type),
});
try {
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
@@ -2398,6 +2523,12 @@ class OpenCodeAgentSession implements AgentSession {
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
this.traceOpenCode("provider.opencode.prompt_async.response", {
turnId,
hasError: promptResponse.error !== undefined,
error: promptResponse.error,
data: promptResponse.data,
});
if (promptResponse.error) {
this.finishForegroundTurn(
{
@@ -2409,6 +2540,13 @@ class OpenCodeAgentSession implements AgentSession {
);
}
} catch (error) {
this.traceOpenCode("provider.opencode.prompt_async.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2436,36 +2574,51 @@ class OpenCodeAgentSession implements AgentSession {
turnAbortController: AbortController,
subscriptionReady: Deferred<void>,
): Promise<void> {
this.traceOpenCode("provider.opencode.subscribe.start", {
turnId,
sessionId: this.sessionId,
cwd: this.config.cwd,
});
try {
const result = await this.client.event.subscribe(
{ directory: this.config.cwd },
{ signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
);
subscriptionReady.resolve();
for await (const event of result.stream) {
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
break;
const result = await this.client.global.event({
signal: turnAbortController.signal,
sseMaxRetryAttempts: 0,
});
let eventCount = 0;
let subscriptionReadyResolved = false;
for await (const rawEvent of result.stream) {
eventCount += 1;
if (!subscriptionReadyResolved) {
subscriptionReadyResolved = true;
this.traceOpenCode("provider.opencode.subscribe.ready", {
turnId,
sessionId: this.sessionId,
});
subscriptionReady.resolve();
}
const translated = await this.translateEvent(event);
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
return;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
this.finishForegroundTurn(terminalEvent, turnId);
return;
}
this.notifySubscribers(e, turnId);
const shouldContinue = await this.consumeOpenCodeStreamEvent({
rawEvent,
eventCount,
turnId,
turnAbortController,
});
if (!shouldContinue) {
return;
}
}
this.traceOpenCode("provider.opencode.stream.eof", {
turnId,
eventCount,
aborted: turnAbortController.signal.aborted,
stillActive: this.activeForegroundTurnId === turnId,
});
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.traceOpenCode("provider.opencode.turn.fail_eof", { turnId, eventCount });
if (!subscriptionReadyResolved) {
subscriptionReady.reject(new Error("OpenCode event stream ended before it became ready"));
}
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2476,6 +2629,11 @@ class OpenCodeAgentSession implements AgentSession {
);
}
} catch (error) {
this.traceOpenCode("provider.opencode.subscribe.error", {
turnId,
error:
error instanceof Error ? { name: error.name, message: error.message } : String(error),
});
subscriptionReady.reject(error);
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
@@ -2504,10 +2662,80 @@ class OpenCodeAgentSession implements AgentSession {
}
}
private async consumeOpenCodeStreamEvent(params: {
rawEvent: unknown;
eventCount: number;
turnId: string;
turnAbortController: AbortController;
}): Promise<boolean> {
const { rawEvent, eventCount, turnId, turnAbortController } = params;
const event = unwrapOpenCodeGlobalEvent(rawEvent);
this.traceOpenCode("provider.opencode.raw_event", {
turnId,
n: eventCount,
type: event?.type,
rawType: readOpenCodeRecord(rawEvent)?.type,
directory: readOpenCodeRecord(rawEvent)?.directory,
rawEvent,
properties: event?.properties,
});
if (!event) {
return true;
}
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
this.traceOpenCode("provider.opencode.event.skip", {
turnId,
n: eventCount,
aborted: turnAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
});
return false;
}
this.armRetryFailureTimerForStatus(event, turnId);
const translated = await this.translateEvent(event);
this.traceOpenCode("provider.opencode.parsed_event", {
turnId,
n: eventCount,
count: translated.length,
types: translated.map((t) => t.type),
events: translated,
});
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
this.traceOpenCode("provider.opencode.parsed_event.skip_active", { turnId, type: e.type });
return false;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
this.traceOpenCode("provider.opencode.event.terminal", {
turnId,
type: terminalEvent.type,
});
this.finishForegroundTurn(terminalEvent, turnId);
return false;
}
this.notifySubscribers(e, turnId);
}
return true;
}
private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string,
): void {
this.traceOpenCode("provider.opencode.finish_foreground_turn", {
turnId,
activeTurnId: this.activeForegroundTurnId,
type: event.type,
error: event.type === "turn_failed" ? event.error : undefined,
reason: event.type === "turn_canceled" ? event.reason : undefined,
});
if (this.activeForegroundTurnId !== turnId) {
return;
}
@@ -2516,6 +2744,7 @@ class OpenCodeAgentSession implements AgentSession {
} else {
this.runningToolCalls.clear();
}
this.clearRetryFailureTimer();
this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort();
@@ -2531,6 +2760,44 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.delete(item.callId);
}
private armRetryFailureTimerForStatus(event: OpenCodeEvent, turnId: string): void {
if (this.retryFailureTimer || event.type !== "session.status") {
return;
}
if (event.properties.sessionID !== this.sessionId || event.properties.status.type !== "retry") {
return;
}
const retry = event.properties.status;
const message = typeof retry.message === "string" ? retry.message.trim() : "";
const error = message
? `OpenCode provider retry did not recover: ${message}`
: "OpenCode provider retry did not recover";
this.retryFailureTimer = setTimeout(() => {
this.retryFailureTimer = null;
if (this.activeForegroundTurnId !== turnId) {
return;
}
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error,
},
turnId,
);
}, OPENCODE_RETRY_STATUS_FAILURE_MS);
}
private clearRetryFailureTimer(): void {
if (!this.retryFailureTimer) {
return;
}
clearTimeout(this.retryFailureTimer);
this.retryFailureTimer = null;
}
private synthesizeInterruptedToolCalls(turnId: string): void {
for (const item of this.runningToolCalls.values()) {
const error = { message: "Tool execution aborted" };
@@ -2562,6 +2829,10 @@ class OpenCodeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.traceOpenCode("provider.opencode.event_emit", {
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
});
for (const callback of this.subscribers) {
try {
callback(tagged);
@@ -2575,6 +2846,19 @@ class OpenCodeAgentSession implements AgentSession {
return `opencode-turn-${this.nextTurnOrdinal++}`;
}
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
this.logger.trace(
{
agentId: this.agentId,
provider: "opencode",
sessionId: this.sessionId,
turnId: data.turnId ?? this.activeForegroundTurnId ?? undefined,
...data,
},
msg,
);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
const response = await this.client.session.messages({
sessionID: this.sessionId,

View File

@@ -47,6 +47,7 @@ export class TestOpenCodeClient {
appAgents: [] as unknown[],
commandList: [] as unknown[],
eventSubscribe: [] as unknown[],
globalEvent: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
questionReject: [] as unknown[],
@@ -99,6 +100,12 @@ export class TestOpenCodeClient {
return { stream: this.eventStream };
},
},
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
return { stream: this.eventStream };
},
},
mcp: {
add: async () => ({}),
connect: async () => ({}),

View File

@@ -30,29 +30,30 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
import { z } from "zod";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModesOptions,
ListModelsOptions,
ToolCallDetail,
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModesOptions,
type ListModelsOptions,
type ToolCallDetail,
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
@@ -738,21 +739,6 @@ function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersis
return {};
}
function getStreamEventTurnId(event: AgentStreamEvent): string | undefined {
switch (event.type) {
case "turn_started":
case "turn_completed":
case "turn_failed":
case "turn_canceled":
case "timeline":
case "permission_requested":
case "permission_resolved":
return event.turnId;
default:
return undefined;
}
}
function isPiRequestAbortError(error: unknown): boolean {
if (error instanceof Error && error.name === "AbortError") {
return true;
@@ -1045,7 +1031,7 @@ export class PiDirectAgentSession implements AgentSession {
return;
}
const eventTurnId = getStreamEventTurnId(event);
const eventTurnId = getAgentStreamEventTurnId(event);
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}

View File

@@ -1,9 +1,10 @@
import type {
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentStreamEvent,
AgentTimelineItem,
import {
getAgentStreamEventTurnId,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentStreamEvent,
type AgentTimelineItem,
} from "../agent-sdk-types.js";
export type ProviderFinalTextReducer = (params: {
@@ -44,7 +45,7 @@ export async function runProviderTurn({
if (settled) {
return;
}
const eventTurnId = "turnId" in event ? event.turnId : undefined;
const eventTurnId = getAgentStreamEventTurnId(event);
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}

View File

@@ -1,8 +1,9 @@
import type {
AgentPromptInput,
AgentRunOptions,
AgentSession,
AgentStreamEvent,
import {
getAgentStreamEventTurnId,
type AgentPromptInput,
type AgentRunOptions,
type AgentSession,
type AgentStreamEvent,
} from "../../agent-sdk-types.js";
function isTerminalEvent(event: AgentStreamEvent): boolean {
@@ -29,7 +30,7 @@ export async function* streamSession(
};
const matchesTurn = (event: AgentStreamEvent): boolean => {
const eventTurnId = (event as { turnId?: string }).turnId;
const eventTurnId = getAgentStreamEventTurnId(event);
return turnId == null || eventTurnId == null || eventTurnId === turnId;
};

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