mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 14:22:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67b7158b4 | ||
|
|
91dc927335 | ||
|
|
49f9cec6be | ||
|
|
e07da55f81 | ||
|
|
27d6a71856 | ||
|
|
bbf8cce3f7 | ||
|
|
6016ed7059 | ||
|
|
8e858f0e35 | ||
|
|
fbc83613c0 | ||
|
|
c356394bfa | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd | ||
|
|
f4efdbded7 | ||
|
|
c976e2e5a5 | ||
|
|
b2bb512b31 | ||
|
|
067937bace | ||
|
|
d615d3d424 | ||
|
|
fbe5005aa1 | ||
|
|
e998e0a082 | ||
|
|
ae42b0afb0 | ||
|
|
829cc5e173 | ||
|
|
48384cafdf | ||
|
|
2c7b38bc7e | ||
|
|
222d45a2fc | ||
|
|
373da70694 | ||
|
|
c3e1e084a0 | ||
|
|
9978988e35 | ||
|
|
8768500fc0 | ||
|
|
89073d4c5c | ||
|
|
ec43e90670 | ||
|
|
897a0abb1a | ||
|
|
c4771ca467 | ||
|
|
05bfffad41 | ||
|
|
faee1cd952 | ||
|
|
ba4595d4e8 | ||
|
|
34c9fd03c9 | ||
|
|
9a3f3a0dc6 | ||
|
|
d6861f81e5 | ||
|
|
3054f80050 | ||
|
|
6d37f7fd9f | ||
|
|
94ab368e4e | ||
|
|
d161b5987d | ||
|
|
90737e1dec | ||
|
|
3eaf0be0b2 | ||
|
|
8269a0ea78 | ||
|
|
5303313632 | ||
|
|
81ad00790f | ||
|
|
290306fd16 | ||
|
|
3a36cc33c6 | ||
|
|
17c5050216 | ||
|
|
5db3bc82fd | ||
|
|
3dbbbb535e | ||
|
|
a3fc59e818 | ||
|
|
bc5bc969c8 |
@@ -180,6 +180,37 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||
# bound runtime.
|
||||
server-tests-macos:
|
||||
name: server-tests (macos-14, file observation)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run file observation tests
|
||||
run: |
|
||||
npm run test:unit --workspace=@getpaseo/server -- \
|
||||
src/server/file-observer \
|
||||
src/server/workspace-git-service.observation.test.ts \
|
||||
src/server/workspace-git-service.observation.integration.test.ts \
|
||||
--bail=1
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
name: desktop-tests (ubuntu-latest)
|
||||
needs: changes
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
|
||||
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
|
||||
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
|
||||
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
|
||||
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
|
||||
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
|
||||
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
|
||||
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
|
||||
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
|
||||
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
|
||||
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
|
||||
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
|
||||
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
|
||||
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
|
||||
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
|
||||
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
|
||||
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
|
||||
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
|
||||
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
|
||||
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
|
||||
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
|
||||
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
|
||||
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
|
||||
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
|
||||
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
|
||||
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
|
||||
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
|
||||
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
|
||||
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
|
||||
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
|
||||
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
|
||||
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
|
||||
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
|
||||
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
|
||||
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
|
||||
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
|
||||
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
|
||||
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
|
||||
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
|
||||
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
|
||||
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
|
||||
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
|
||||
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
|
||||
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
|
||||
@@ -161,6 +161,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
||||
|
||||
@@ -193,6 +193,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
|
||||
@@ -714,7 +714,7 @@ Each entry in the `models` array:
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
+5
-2
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
|
||||
## Session grants and agent operations
|
||||
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
|
||||
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
|
||||
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
|
||||
workspace through `workspace.title.set.request` without `workspace.manage`.
|
||||
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+14
-5
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
|
||||
top-level provider options.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
|
||||
`settings`. `providerOptions.extraArgs` passes the SDK's documented
|
||||
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
|
||||
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
|
||||
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
|
||||
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
|
||||
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
|
||||
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
|
||||
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
|
||||
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
|
||||
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
|
||||
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-3cqO8DpPVDjYaSh0q5EPJuMpCgl2OJ17uLGVGuk1Wwc=
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36196,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37262,12 +37262,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37514,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37528,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +37772,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38668,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38697,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38714,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38737,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38764,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,16 +38982,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39895,7 +39895,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
|
||||
// Where Settings → Keyboard shortcuts persists a rebound shortcut.
|
||||
const OVERRIDE_STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const COMMAND_CENTER_BINDING = "command-center-toggle-ctrl-k-non-mac";
|
||||
|
||||
async function rebindCommandCenter(page: Page, combo: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
([key, bindingId, value]) => {
|
||||
window.localStorage.setItem(key, JSON.stringify({ [bindingId]: value }));
|
||||
},
|
||||
[OVERRIDE_STORAGE_KEY, COMMAND_CENTER_BINDING, combo] as const,
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-step shortcut", () => {
|
||||
test("completes a chord whose second step is a bare key", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
// Releasing Ctrl between the steps makes the browser emit a bare `Control`
|
||||
// keydown before `Ctrl+J`, which used to reset the chord to its first step.
|
||||
test("completes a chord whose second step carries a modifier", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K Ctrl+J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("Control+j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
|
||||
// "Toggle command center" recorded as a two-key sequence. Both platform
|
||||
// bindings are overridden so the spec does not depend on the host OS.
|
||||
const COMMAND_CENTER_SEQUENCE = {
|
||||
"command-center-toggle-cmd-k-mac": "Cmd+K J",
|
||||
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K J",
|
||||
};
|
||||
|
||||
const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K";
|
||||
const SEQUENCE_FINAL_KEY = "j";
|
||||
|
||||
/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */
|
||||
async function recordCommandCenterSequence(page: Page): Promise<void> {
|
||||
await page.addInitScript((overrides) => {
|
||||
localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides));
|
||||
}, COMMAND_CENTER_SEQUENCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the window and let the app render again, so the resize lands between
|
||||
* the two keys of the sequence rather than after them.
|
||||
*/
|
||||
async function resizeWindowAndSettle(page: Page, width: number): Promise<void> {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.waitForFunction((expected) => window.innerWidth === expected, width);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-key shortcut sequences", () => {
|
||||
test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
test("a sequence completes when the window resizes between its keys", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await resizeWindowAndSettle(page, 1100);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
|
||||
interface SeededProject {
|
||||
projectKey: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a project whose directory name ends in a space, which `seedWorkspace`
|
||||
* cannot produce because `mkdtemp` appends the random suffix. The project is
|
||||
* owned until `cleanup`, which removes the daemon record before the directory.
|
||||
*/
|
||||
async function seedProjectNamedWithTrailingSpace(): Promise<SeededProject> {
|
||||
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-trailing-space-"));
|
||||
const directoryPath = path.join(parentDirectory, `Reklamation-${randomUUID().slice(0, 8)} `);
|
||||
await mkdir(directoryPath);
|
||||
await writeFile(path.join(directoryPath, "README.md"), "# Trailing space\n");
|
||||
|
||||
const client = await connectSeedClient();
|
||||
const removeParentDirectory = () => rm(parentDirectory, { recursive: true, force: true });
|
||||
try {
|
||||
const added = await client.addProject(directoryPath);
|
||||
expect(added.error).toBeNull();
|
||||
expect(added.project?.projectRootPath).toBe(directoryPath);
|
||||
const project = added.project;
|
||||
if (!project?.projectKey) {
|
||||
throw new Error(`The daemon added ${directoryPath} without a project key`);
|
||||
}
|
||||
return {
|
||||
projectKey: project.projectKey,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectProjectRow(page: Page, projectKey: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function expectAppShellRendered(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Paseo ran into a problem.")).toHaveCount(0);
|
||||
}
|
||||
|
||||
// A project view key carries the project's path, so a directory whose name ends
|
||||
// in a space produces a key that ends in a space. The sidebar order store used
|
||||
// to trim it on write, which left the reconcile effect writing a new order on
|
||||
// every render until React tore the app down with "Maximum update depth
|
||||
// exceeded" on every load (#4880).
|
||||
test("a project whose folder name ends in a space renders in the sidebar", async ({ page }) => {
|
||||
const project = await seedProjectNamedWithTrailingSpace();
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectProjectRow(page, project.projectKey);
|
||||
await expectAppShellRendered(page);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -54,6 +54,54 @@ describe("question form card core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps checked options and appends the other answer for multi-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
|
||||
Fruits: "Apple, Cherry, durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
|
||||
Fruits: "Apple, Cherry",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
|
||||
Fruits: "durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
|
||||
});
|
||||
|
||||
test("replaces the selected option with the other answer for single-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
|
||||
Provider: "OpenCode",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
|
||||
Provider: "Codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows text input for explicit other questions", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
|
||||
@@ -111,10 +111,13 @@ export function buildQuestionFormAnswers(
|
||||
const q = questions[i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[q.header] = otherText;
|
||||
// Multi-select keeps the checked options and appends the custom answer, the way
|
||||
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
|
||||
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
|
||||
continue;
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
@@ -123,8 +126,7 @@ export function buildQuestionFormAnswers(
|
||||
}
|
||||
}
|
||||
|
||||
if (selected && selected.size > 0) {
|
||||
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||
if (labels.length > 0) {
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
|
||||
* gap between that input and React state: the input owns its text and never replays state, so a
|
||||
* card that drops the Other text from state alone keeps showing it while submit ignores it.
|
||||
*/
|
||||
|
||||
interface Mounted {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function buildPermission(question: Record<string, unknown>): PendingPermission {
|
||||
return {
|
||||
key: "perm-1",
|
||||
agentId: "agent-1",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "claude",
|
||||
name: "AskUserQuestion",
|
||||
kind: "question",
|
||||
input: { questions: [question] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountCard(question: Record<string, unknown>) {
|
||||
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(
|
||||
<QuestionFormCard
|
||||
permission={buildPermission(question)}
|
||||
onRespond={onRespond}
|
||||
isResponding={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
mounted.push({ root, container });
|
||||
|
||||
const view = within(container);
|
||||
const optionRole = question.multiSelect ? "checkbox" : "radio";
|
||||
const otherInput = () =>
|
||||
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
|
||||
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
|
||||
const type = (text: string) => {
|
||||
const input = otherInput();
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
|
||||
act(() => {
|
||||
valueSetter.call(input, text);
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
|
||||
});
|
||||
};
|
||||
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
|
||||
const submittedAnswers = (): Record<string, string> => {
|
||||
const response = onRespond.mock.calls[0]?.[0];
|
||||
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
|
||||
return (response.updatedInput as { answers: Record<string, string> }).answers;
|
||||
};
|
||||
return { check, type, otherInput, submit, submittedAnswers };
|
||||
}
|
||||
|
||||
const multiSelectQuestion = {
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
const singleSelectQuestion = {
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
describe("QuestionFormCard other answers", () => {
|
||||
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.check("Apple");
|
||||
card.check("Cherry");
|
||||
card.type("durian");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
|
||||
});
|
||||
|
||||
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.type("durian");
|
||||
card.check("Apple");
|
||||
card.check("Banana");
|
||||
|
||||
expect(card.otherInput().value).toBe("durian");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
|
||||
});
|
||||
|
||||
it("replaces the selected option with the typed other answer (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.check("Codex");
|
||||
card.type("OpenCode");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
|
||||
});
|
||||
|
||||
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.type("OpenCode");
|
||||
card.check("Codex");
|
||||
|
||||
expect(card.otherInput().value).toBe("");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -9,6 +9,7 @@ import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -258,6 +259,7 @@ function QuestionNav({
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
inputRef: RefObject<EditingTextInputHandle | null>;
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
@@ -268,6 +270,7 @@ interface QuestionOtherInputProps {
|
||||
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
inputRef,
|
||||
accessibilityLabel,
|
||||
value,
|
||||
placeholder,
|
||||
@@ -303,6 +306,7 @@ function QuestionOtherInput({
|
||||
);
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
@@ -328,6 +332,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
|
||||
@@ -349,29 +354,40 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
|
||||
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
|
||||
// Single-select: an option and a custom answer replace each other, as in Claude Code.
|
||||
// Multi-select keeps both. The editing surface owns its text and never replays state
|
||||
// (docs/forms.md), so clearing state alone would leave stale text on screen that
|
||||
// submit ignores; clear the surface explicitly.
|
||||
if (!multiSelect && otherTexts[qIndex]) {
|
||||
setOtherTexts((prev) => {
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
otherInputRef.current?.replaceText("");
|
||||
}
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[activeQuestionIndex, questions, selections],
|
||||
[activeQuestionIndex, otherTexts, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
if (text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const setOtherText = useCallback(
|
||||
(qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
|
||||
if (!multiSelect && text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
},
|
||||
[questions],
|
||||
);
|
||||
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
const resolvedActiveQuestionIndex = questions
|
||||
@@ -554,6 +570,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
inputRef={otherInputRef}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder({
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BackHandler, Platform } from "react-native";
|
||||
import type { BackPressSource } from "./visibility-tracker";
|
||||
|
||||
/**
|
||||
* Android's hardware Back press.
|
||||
*
|
||||
* iOS and web have no such press, and `react-native-web`'s `BackHandler` logs an error as soon as
|
||||
* anything subscribes, so nothing is registered off Android.
|
||||
*/
|
||||
export const systemBackPress: BackPressSource = {
|
||||
subscribe(onBackPress) {
|
||||
if (Platform.OS !== "android") return () => {};
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
||||
return () => subscription.remove();
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ElementRef, ReactNode } from "react";
|
||||
import { systemBackPress } from "./back-press";
|
||||
import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
@@ -85,7 +86,11 @@ export function useIsolatedBottomSheetVisibility({
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
const tracker = useMemo(
|
||||
() => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }),
|
||||
() =>
|
||||
createBottomSheetVisibilityTracker({
|
||||
onClose: () => onCloseRef.current(),
|
||||
backPress: systemBackPress,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BackPressSource,
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
} from "./visibility-tracker";
|
||||
@@ -20,21 +21,55 @@ class FakeBottomSheet implements BottomSheetController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for Android's `BackHandler`, which offers the press to listeners newest-first and
|
||||
* stops at the first one that claims it.
|
||||
*/
|
||||
class FakeBackPress implements BackPressSource {
|
||||
private listeners: (() => boolean)[] = [];
|
||||
|
||||
subscribe(onBackPress: () => boolean): () => void {
|
||||
this.listeners.push(onBackPress);
|
||||
return () => {
|
||||
const index = this.listeners.indexOf(onBackPress);
|
||||
if (index >= 0) this.listeners.splice(index, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a sheet claimed the press, false when it falls through to the navigator. */
|
||||
press(): boolean {
|
||||
for (let index = this.listeners.length - 1; index >= 0; index--) {
|
||||
if (this.listeners[index]?.()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const backPress = new FakeBackPress();
|
||||
let closeCount = 0;
|
||||
const tracker = createBottomSheetVisibilityTracker({
|
||||
onClose: () => {
|
||||
closeCount += 1;
|
||||
},
|
||||
backPress,
|
||||
});
|
||||
return {
|
||||
sheet,
|
||||
backPress,
|
||||
tracker,
|
||||
closeCount: () => closeCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a sheet and runs it through the presentation the real sheet reports back. */
|
||||
function openSheet(context: ReturnType<typeof setup>) {
|
||||
context.tracker.attachController(context.sheet);
|
||||
context.tracker.syncDesired({ visible: true });
|
||||
context.tracker.handleSheetIndexChange(0);
|
||||
}
|
||||
|
||||
describe("bottom sheet visibility tracker", () => {
|
||||
it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
@@ -160,4 +195,53 @@ describe("bottom sheet visibility tracker", () => {
|
||||
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
|
||||
});
|
||||
|
||||
it("dismisses the sheet on a Back press instead of letting it reach the navigator", () => {
|
||||
const context = setup();
|
||||
openSheet(context);
|
||||
|
||||
expect(context.backPress.press()).toBe(true);
|
||||
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
|
||||
const context = setup();
|
||||
context.tracker.attachController(context.sheet);
|
||||
context.tracker.syncDesired({ visible: false });
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
|
||||
openSheet(context);
|
||||
context.tracker.handleSheetIndexChange(-1);
|
||||
context.tracker.handleSheetDismiss();
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
});
|
||||
|
||||
it("gives the Back press to the sheet presented last", () => {
|
||||
const backPress = new FakeBackPress();
|
||||
const sheets = ["below", "above"].map((name) => {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const tracker = createBottomSheetVisibilityTracker({ onClose: () => {}, backPress });
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
return { name, sheet };
|
||||
});
|
||||
|
||||
backPress.press();
|
||||
|
||||
expect(sheets.map((entry) => [entry.name, entry.sheet.events.at(-1)?.type])).toEqual([
|
||||
["below", "present"],
|
||||
["above", "dismiss"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops claiming Back once the sheet leaves the tree", () => {
|
||||
const context = setup();
|
||||
openSheet(context);
|
||||
context.tracker.attachController(null);
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,22 @@ export interface BottomSheetVisibilityInput {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The system Back press, as the tracker needs it.
|
||||
*
|
||||
* A sheet is presented through `@gorhom/portal`, which renders it into the app's own view tree
|
||||
* rather than a native modal window, so Android delivers Back to the navigator underneath it
|
||||
* unless something on top claims the press first.
|
||||
*
|
||||
* `subscribe` registers a listener and returns its removal. The listener returns `true` when it
|
||||
* claims the press. Listeners are offered the press newest-first, so a tracker holds its
|
||||
* subscription only while its sheet is on screen, and a stacked sheet answers before the one
|
||||
* below it.
|
||||
*/
|
||||
export interface BackPressSource {
|
||||
subscribe(onBackPress: () => boolean): () => void;
|
||||
}
|
||||
|
||||
export interface BottomSheetVisibilityTracker {
|
||||
attachController(controller: BottomSheetController | null): void;
|
||||
syncDesired(input: BottomSheetVisibilityInput): void;
|
||||
@@ -19,23 +35,46 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
|
||||
|
||||
export function createBottomSheetVisibilityTracker(opts: {
|
||||
onClose: () => void;
|
||||
backPress?: BackPressSource;
|
||||
}): BottomSheetVisibilityTracker {
|
||||
let controller: BottomSheetController | null = null;
|
||||
let visible = false;
|
||||
let isEnabled: boolean | undefined;
|
||||
let phase: BottomSheetPhase = "closed";
|
||||
let hasNotifiedClose = false;
|
||||
let releaseBackPress: (() => void) | null = null;
|
||||
|
||||
function setPhase(next: BottomSheetPhase): void {
|
||||
phase = next;
|
||||
syncBackPress();
|
||||
}
|
||||
|
||||
/** Holds the Back press for exactly the window the sheet is on screen, and no longer. */
|
||||
function syncBackPress(): void {
|
||||
const wantsBackPress = phase === "presented" && controller !== null;
|
||||
if (wantsBackPress === (releaseBackPress !== null)) return;
|
||||
if (!wantsBackPress) {
|
||||
releaseBackPress?.();
|
||||
releaseBackPress = null;
|
||||
return;
|
||||
}
|
||||
releaseBackPress =
|
||||
opts.backPress?.subscribe(() => {
|
||||
dismiss();
|
||||
return true;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function present(): void {
|
||||
if (!controller || phase !== "closed") return;
|
||||
phase = "presenting";
|
||||
hasNotifiedClose = false;
|
||||
setPhase("presenting");
|
||||
controller.present();
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
if (!controller || phase === "closed" || phase === "dismissing") return;
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
controller.dismiss();
|
||||
}
|
||||
|
||||
@@ -48,6 +87,9 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
return {
|
||||
attachController(next) {
|
||||
controller = next;
|
||||
// A sheet that left the tree cannot answer, so it lets go of the press without changing
|
||||
// phase: the parent has not acknowledged the dismissal yet.
|
||||
syncBackPress();
|
||||
if (next && visible && isEnabled !== false) {
|
||||
present();
|
||||
}
|
||||
@@ -61,7 +103,7 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
return;
|
||||
}
|
||||
if (phase === "dismissing") {
|
||||
phase = "closed";
|
||||
setPhase("closed");
|
||||
hasNotifiedClose = false;
|
||||
return;
|
||||
}
|
||||
@@ -70,21 +112,21 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
handleSheetIndexChange(index) {
|
||||
if (index !== -1) {
|
||||
if (phase === "presenting" || phase === "dismissing") {
|
||||
phase = "presented";
|
||||
setPhase("presented");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
}
|
||||
},
|
||||
handleSheetDismiss() {
|
||||
if (visible) {
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
notifyClose();
|
||||
return;
|
||||
}
|
||||
phase = "closed";
|
||||
setPhase("closed");
|
||||
hasNotifiedClose = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/keyboard/route-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
@@ -75,7 +76,6 @@ export function useKeyboardShortcuts({
|
||||
const openProjectPickerAction = useOpenAddProject();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const badgeModifierKeyRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
@@ -107,271 +107,285 @@ export function useKeyboardShortcuts({
|
||||
publishBrowserShortcutPolicy();
|
||||
}, [isDesktopApp, publishBrowserShortcutPolicy]);
|
||||
|
||||
// Only the modifier that actually performs the workspace-index jump on this
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
||||
// to drive badge visibility, so we set the flag matching this runtime.
|
||||
// Derived from the effective bindings: `null` when the user unassigned or
|
||||
// rebound the jump shortcut, and no `event.key` ever equals null, so the
|
||||
// badges simply never appear.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
|
||||
{ isMac, isDesktop: isDesktopApp },
|
||||
bindings,
|
||||
);
|
||||
|
||||
// The keyup listener matches the released key against the current modifier,
|
||||
// so a modifier held while the jump binding changes could never be released
|
||||
// and the badges would stay up until a blur. Clear them when it changes.
|
||||
useEffect(() => {
|
||||
resetModifiers();
|
||||
}, [badgeModifierKey, resetModifiers]);
|
||||
|
||||
const setBadgeModifierDown = (down: boolean) => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (isDesktopApp) {
|
||||
state.setCmdOrCtrlDown(down);
|
||||
} else {
|
||||
state.setAltDown(down);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||
};
|
||||
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const performShortcutAction = (
|
||||
action: ShortcutAction,
|
||||
event: KeyboardEvent | null,
|
||||
browserFocusRestoreElement: HTMLElement | null = null,
|
||||
): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
keyboardWorkspaceSelectionRef.current = {
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
if (navigateToLastWorkspace()) {
|
||||
return true;
|
||||
}
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return true;
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||
return true;
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
if (event) {
|
||||
captureCommandCenterFocusRestore(event);
|
||||
} else {
|
||||
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
|
||||
}
|
||||
}
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const routeAndPerformShortcut = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: input.action, payload: input.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace:
|
||||
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
const handled = performShortcutAction(
|
||||
shortcutAction,
|
||||
input.domEvent,
|
||||
input.browserFocusRestoreElement,
|
||||
);
|
||||
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
|
||||
exitFocusMode();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
const resolveAndPerformShortcut = (input: {
|
||||
event: KeyboardShortcutInput;
|
||||
focusScope: KeyboardFocusScope;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}) => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const previousChordState = chordStateRef.current;
|
||||
const result = resolveKeyboardShortcut({
|
||||
event: input.event,
|
||||
context: {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
focusScope: input.focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
},
|
||||
chordState: chordStateRef.current,
|
||||
onChordReset: () => {
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
publishBrowserShortcutPolicy();
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
chordStateRef.current = result.nextChordState;
|
||||
if (
|
||||
shouldPublishBrowserShortcutPolicy({
|
||||
isBrowserInput: "browserId" in input.event,
|
||||
previousChordState,
|
||||
nextChordState: result.nextChordState,
|
||||
})
|
||||
) {
|
||||
publishBrowserShortcutPolicy(result.nextChordState);
|
||||
}
|
||||
|
||||
if (result.preventDefault && input.domEvent) {
|
||||
input.domEvent.preventDefault();
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
|
||||
if (!result.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = routeAndPerformShortcut({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
domEvent: input.domEvent,
|
||||
browserFocusRestoreElement: input.browserFocusRestoreElement,
|
||||
});
|
||||
if (!handled || !input.domEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.match.preventDefault) {
|
||||
input.domEvent.preventDefault();
|
||||
}
|
||||
if (result.match.stopPropagation) {
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// The window listeners must outlive ordinary re-renders: removing them resets
|
||||
// any in-progress chord. Stable events let them read the latest render instead
|
||||
// of being re-registered whenever a route, callback, or selection changes.
|
||||
const handleKeyDown = useStableEvent((event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatchTopWebOverlayKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// During IME composition, Enter confirms the candidate selection and must
|
||||
// not route through global shortcuts like message send.
|
||||
if (isImeComposingKeyboardEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (store.capturingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (
|
||||
key === "Escape" &&
|
||||
pathname.startsWith("/settings") &&
|
||||
!isMobile &&
|
||||
hasActiveWebOverlay()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (key === badgeModifierKey && !event.shiftKey) {
|
||||
setBadgeModifierDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
resolveAndPerformShortcut({
|
||||
event,
|
||||
focusScope,
|
||||
domEvent: event,
|
||||
});
|
||||
});
|
||||
|
||||
const handleKeyUp = useStableEvent((event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === badgeModifierKey) {
|
||||
setBadgeModifierDown(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBrowserShortcutInput = useStableEvent((payload: unknown) => {
|
||||
const input = parseBrowserShortcutInput(payload);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
resolveAndPerformShortcut({
|
||||
event: input,
|
||||
focusScope: "browser",
|
||||
domEvent: null,
|
||||
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!shortcutsAvailable) return;
|
||||
|
||||
// Only the modifier that actually performs the workspace-index jump on this
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
||||
// to drive badge visibility, so we set the flag matching this runtime.
|
||||
// Derived from the effective bindings: `null` when the user unassigned or
|
||||
// rebound the jump shortcut, and no `event.key` ever equals null, so the
|
||||
// badges simply never appear.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
|
||||
{ isMac, isDesktop: isDesktopApp },
|
||||
bindings,
|
||||
);
|
||||
const setBadgeModifierDown = (down: boolean) => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (isDesktopApp) {
|
||||
state.setCmdOrCtrlDown(down);
|
||||
} else {
|
||||
state.setAltDown(down);
|
||||
}
|
||||
};
|
||||
|
||||
// The keyup listener matches the released key against the modifier derived
|
||||
// when the effect last ran, so a modifier held while the jump binding
|
||||
// changes can never be released -- the badges would stay up until a blur.
|
||||
// Clear on change only: this effect also re-runs on every navigation, and
|
||||
// clearing unconditionally would drop the badges mid Cmd+1, Cmd+2.
|
||||
if (badgeModifierKeyRef.current !== badgeModifierKey) {
|
||||
badgeModifierKeyRef.current = badgeModifierKey;
|
||||
resetModifiers();
|
||||
}
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||
};
|
||||
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const performShortcutAction = (
|
||||
action: ShortcutAction,
|
||||
event: KeyboardEvent | null,
|
||||
browserFocusRestoreElement: HTMLElement | null = null,
|
||||
): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
keyboardWorkspaceSelectionRef.current = {
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
if (navigateToLastWorkspace()) {
|
||||
return true;
|
||||
}
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return true;
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||
return true;
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
if (event) {
|
||||
captureCommandCenterFocusRestore(event);
|
||||
} else {
|
||||
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
|
||||
}
|
||||
}
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const routeAndPerformShortcut = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: input.action, payload: input.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace:
|
||||
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
const handled = performShortcutAction(
|
||||
shortcutAction,
|
||||
input.domEvent,
|
||||
input.browserFocusRestoreElement,
|
||||
);
|
||||
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
|
||||
exitFocusMode();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
const resolveAndPerformShortcut = (input: {
|
||||
event: KeyboardShortcutInput;
|
||||
focusScope: KeyboardFocusScope;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}) => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const previousChordState = chordStateRef.current;
|
||||
const result = resolveKeyboardShortcut({
|
||||
event: input.event,
|
||||
context: {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
focusScope: input.focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
},
|
||||
chordState: chordStateRef.current,
|
||||
onChordReset: () => {
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
publishBrowserShortcutPolicy();
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
chordStateRef.current = result.nextChordState;
|
||||
if (
|
||||
shouldPublishBrowserShortcutPolicy({
|
||||
isBrowserInput: "browserId" in input.event,
|
||||
previousChordState,
|
||||
nextChordState: result.nextChordState,
|
||||
})
|
||||
) {
|
||||
publishBrowserShortcutPolicy(result.nextChordState);
|
||||
}
|
||||
|
||||
if (result.preventDefault && input.domEvent) {
|
||||
input.domEvent.preventDefault();
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
|
||||
if (!result.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = routeAndPerformShortcut({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
domEvent: input.domEvent,
|
||||
browserFocusRestoreElement: input.browserFocusRestoreElement,
|
||||
});
|
||||
if (!handled || !input.domEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.match.preventDefault) {
|
||||
input.domEvent.preventDefault();
|
||||
}
|
||||
if (result.match.stopPropagation) {
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatchTopWebOverlayKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// During IME composition, Enter confirms the candidate selection and must
|
||||
// not route through global shortcuts like message send.
|
||||
if (isImeComposingKeyboardEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (store.capturingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (
|
||||
key === "Escape" &&
|
||||
pathname.startsWith("/settings") &&
|
||||
!isMobile &&
|
||||
hasActiveWebOverlay()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (key === badgeModifierKey && !event.shiftKey) {
|
||||
setBadgeModifierDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
resolveAndPerformShortcut({
|
||||
event,
|
||||
focusScope,
|
||||
domEvent: event,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === badgeModifierKey) {
|
||||
setBadgeModifierDown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurOrHide = () => {
|
||||
resetModifiers();
|
||||
};
|
||||
@@ -382,18 +396,7 @@ export function useKeyboardShortcuts({
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
|
||||
const browserShortcutSubscription = isElectronRuntime()
|
||||
? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => {
|
||||
const input = parseBrowserShortcutInput(payload);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
resolveAndPerformShortcut({
|
||||
event: input,
|
||||
focusScope: "browser",
|
||||
domEvent: null,
|
||||
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
|
||||
});
|
||||
})
|
||||
? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput)
|
||||
: null;
|
||||
return () => {
|
||||
if (chordStateRef.current.timeoutId !== null) {
|
||||
@@ -415,23 +418,11 @@ export function useKeyboardShortcuts({
|
||||
}
|
||||
};
|
||||
}, [
|
||||
bindings,
|
||||
cycleTheme,
|
||||
enabled,
|
||||
exitFocusMode,
|
||||
activeWorkspaceSelection,
|
||||
isDesktopApp,
|
||||
isMac,
|
||||
isMobile,
|
||||
isWorkspaceFocusModeEnabled,
|
||||
keyboardActionDispatcher,
|
||||
openProjectPickerAction,
|
||||
pathname,
|
||||
publishBrowserShortcutPolicy,
|
||||
handleBrowserShortcutInput,
|
||||
handleKeyDown,
|
||||
handleKeyUp,
|
||||
resetModifiers,
|
||||
router,
|
||||
shortcutsAvailable,
|
||||
toggleAgentList,
|
||||
toggleBothSidebars,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -550,6 +550,36 @@ describe("keyboard-shortcuts", () => {
|
||||
expectNoShortcutResolution({ event, context });
|
||||
});
|
||||
|
||||
// A rebound pane-focus shortcut has to fire wherever the user is typing.
|
||||
// Its default combo carries `editable: false` so that Cmd+Shift+Arrow keeps
|
||||
// selecting text (the two cases above), and that guard describes the default
|
||||
// combo rather than the action, so it must not survive the rebind.
|
||||
describe("a rebound pane-focus shortcut", () => {
|
||||
const PANE_FOCUS_DOWN_BINDING = "workspace-pane-focus-down-cmd-shift-down";
|
||||
// macOS emits U+2206 for Option+J; the stored combo comes from the code.
|
||||
const altJ = { key: "\u2206", code: "KeyJ", altKey: true };
|
||||
|
||||
it.each(["message-input", "editable"] as const)("fires with %s focused", (focusScope) => {
|
||||
const result = resolveShortcut({
|
||||
event: altJ,
|
||||
context: { isMac: true, focusScope },
|
||||
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe("workspace.pane.focus.down");
|
||||
});
|
||||
|
||||
it("still fires outside a text field", () => {
|
||||
const result = resolveShortcut({
|
||||
event: altJ,
|
||||
context: { isMac: true, focusScope: "other" },
|
||||
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe("workspace.pane.focus.down");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
|
||||
const bindings = buildEffectiveBindings({
|
||||
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
|
||||
@@ -593,6 +623,42 @@ describe("keyboard-shortcuts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("completes a chord whose second step carries a modifier", () => {
|
||||
const bindings = buildEffectiveBindings({
|
||||
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K Ctrl+J",
|
||||
});
|
||||
|
||||
// The browser emits a keydown for the bare Control key before every combo,
|
||||
// so the stream for Ctrl+K, release, Ctrl+J is four keydowns, not two.
|
||||
const firstModifier = resolveShortcut({
|
||||
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
bindings,
|
||||
});
|
||||
const firstStep = resolveShortcut({
|
||||
event: { key: "k", code: "KeyK", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
chordState: firstModifier.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
expect(firstStep.nextChordState.step).toBe(1);
|
||||
|
||||
const secondModifier = resolveShortcut({
|
||||
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
chordState: firstStep.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
const secondStep = resolveShortcut({
|
||||
event: { key: "j", code: "KeyJ", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
chordState: secondModifier.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(secondStep.match?.action).toBe("command-center.toggle");
|
||||
});
|
||||
|
||||
it("schedules a chord reset timeout for advancing candidates", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "@/keyboard/actions";
|
||||
import {
|
||||
chordStringToShortcutKeys,
|
||||
isModifierKeyCode,
|
||||
type KeyCombo,
|
||||
parseChordString,
|
||||
} from "@/keyboard/shortcut-string";
|
||||
@@ -1229,14 +1230,33 @@ export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShor
|
||||
if (binding.repeat === false && lastCombo) {
|
||||
lastCombo.repeat = false;
|
||||
}
|
||||
const when = withoutDefaultComboGuard(binding.when);
|
||||
if (!binding.help?.defaultDisplayKeys) {
|
||||
return { ...binding, combo: override, parsedChord };
|
||||
return { ...binding, combo: override, parsedChord, when };
|
||||
}
|
||||
const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help;
|
||||
return { ...binding, combo: override, parsedChord, help };
|
||||
return { ...binding, combo: override, parsedChord, when, help };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `editable: false` is a statement about a binding's *default* combo, not
|
||||
* about its action: the pane-focus defaults carry it so that Cmd+Shift+Arrow
|
||||
* keeps selecting text in a field instead of moving pane focus. An override
|
||||
* replaces that combo, so the guard no longer describes anything and has to
|
||||
* go, the same way `defaultDisplayKeys` does — otherwise the combo the user
|
||||
* picked in Settings silently refuses to fire wherever they are typing.
|
||||
*
|
||||
* The other guards stay. Platform, command center, terminal and focus scope
|
||||
* are properties of the action and of where it makes sense, and none of them
|
||||
* change because the keys did.
|
||||
*/
|
||||
function withoutDefaultComboGuard(when: ShortcutWhen | undefined): ShortcutWhen | undefined {
|
||||
if (when?.editable !== false) return when;
|
||||
const { editable: _editable, ...rest } = when;
|
||||
return rest;
|
||||
}
|
||||
|
||||
// --- Matching engine ---
|
||||
|
||||
function parseDigit(event: KeyboardShortcutInput): number | null {
|
||||
@@ -1517,6 +1537,13 @@ export function resolveKeyboardShortcut(input: {
|
||||
preventDefault: boolean;
|
||||
} {
|
||||
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
|
||||
// Pressing a modifier emits its own keydown before the combo that holds it,
|
||||
// so a chord waiting on `Ctrl+J` sees a bare `Control` first. That keydown
|
||||
// matches no combo, and resolving it would drop the chord back to its first
|
||||
// step. It decides nothing: leave the chord where it is.
|
||||
if (isModifierKeyCode(event.code)) {
|
||||
return { match: null, nextChordState: chordState, preventDefault: false };
|
||||
}
|
||||
if (chordState.step === 0) {
|
||||
return resolveInitialChordStep({ event, context, chordState, onChordReset, bindings });
|
||||
}
|
||||
|
||||
@@ -245,8 +245,17 @@ export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
|
||||
return parts.length > 0 ? parts.join("+") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a keydown carries a modifier key itself rather than a key pressed
|
||||
* with one. The browser emits one of these before every combo that holds a
|
||||
* modifier, and it is the prefix of a combo, never a combo of its own.
|
||||
*/
|
||||
export function isModifierKeyCode(code: string): boolean {
|
||||
return MODIFIER_CODES.has(code);
|
||||
}
|
||||
|
||||
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
if (isModifierKeyCode(event.code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
type SqliteValue,
|
||||
} from "@/runtime/replica-cache/row-store-sqlite";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
DaemonClient,
|
||||
WorkspaceLabelListPayload,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeProjectDescriptor,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
|
||||
import { useWorkspaceLabels } from "@/workspace-labels";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import {
|
||||
DirectoryRefreshSupersededError,
|
||||
@@ -29,6 +33,8 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
supportsWorkspaceLabels = false;
|
||||
listWorkspaceLabelsCalls = 0;
|
||||
fetchAgentsCalls = 0;
|
||||
lastAgentOptions: unknown;
|
||||
fetchWorkspacesCalls = 0;
|
||||
@@ -144,19 +150,36 @@ class FakeDirectoryClient {
|
||||
});
|
||||
}
|
||||
|
||||
getLastServerInfoMessage(): null {
|
||||
return null;
|
||||
observeWorkspaceLabels() {
|
||||
return subscriptionFixture(this.listWorkspaceLabels(), () => () => {});
|
||||
}
|
||||
|
||||
async listWorkspaceLabels(): Promise<WorkspaceLabelListPayload> {
|
||||
this.listWorkspaceLabelsCalls += 1;
|
||||
return {
|
||||
requestId: "workspace-labels",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
sync: { mode: "snapshot", removals: [], generation: "generation-1", headSeq: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const serverIds = new Set<string>();
|
||||
|
||||
function createDirectory(serverId: string): {
|
||||
function createDirectory(
|
||||
serverId: string,
|
||||
options?: { workspaceLabels?: boolean },
|
||||
): {
|
||||
client: FakeDirectoryClient;
|
||||
directory: DirectorySync;
|
||||
} {
|
||||
serverIds.add(serverId);
|
||||
const client = new FakeDirectoryClient();
|
||||
client.supportsWorkspaceLabels = options?.workspaceLabels === true;
|
||||
const directory = new DirectorySync(serverId, {
|
||||
onAgentStoppedRunning: () => undefined,
|
||||
markAgentLoading: () => undefined,
|
||||
@@ -207,6 +230,7 @@ function createAgent(serverId: string, id: string) {
|
||||
afterEach(() => {
|
||||
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
|
||||
serverIds.clear();
|
||||
useWorkspaceLabels.setState({ hosts: {} });
|
||||
});
|
||||
|
||||
describe("DirectorySync session readiness", () => {
|
||||
@@ -1349,3 +1373,103 @@ it("fills every cached workspace beneath live updates received during the SQLite
|
||||
await cache.flush();
|
||||
database.close();
|
||||
});
|
||||
|
||||
function createWorkspaceLabelDirectory(serverId: string) {
|
||||
const { client, directory } = createDirectory(serverId, { workspaceLabels: true });
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: {
|
||||
directorySync: true,
|
||||
projectList: true,
|
||||
workspaceLabels: true,
|
||||
workspaceMultiplicity: true,
|
||||
},
|
||||
});
|
||||
return { client, directory };
|
||||
}
|
||||
|
||||
async function flushWorkspaceLabels(): Promise<void> {
|
||||
for (let index = 0; index < 4; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("DirectorySync workspace labels", () => {
|
||||
it("loads the catalog when an agent route is open before the sidebar", async () => {
|
||||
const serverId = "workspace-labels-route-then-full";
|
||||
const { directory } = createWorkspaceLabelDirectory(serverId);
|
||||
|
||||
directory.setAgentRouteDemand(["agent-1"]);
|
||||
await directory.refreshDemand();
|
||||
directory.setDemand({}, true);
|
||||
await flushWorkspaceLabels();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("loads the catalog when the sidebar demands the directory first", async () => {
|
||||
const serverId = "workspace-labels-full-first";
|
||||
const { directory } = createWorkspaceLabelDirectory(serverId);
|
||||
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps the picker online during a full refresh", async () => {
|
||||
const serverId = "workspace-labels-refresh-no-flicker";
|
||||
const { client, directory } = createWorkspaceLabelDirectory(serverId);
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
const callsBeforeRefresh = client.listWorkspaceLabelsCalls;
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = useWorkspaceLabels.subscribe((state) => {
|
||||
const status = state.hosts[serverId]?.status;
|
||||
if (status) statuses.push(status);
|
||||
});
|
||||
|
||||
await directory.refreshDemand();
|
||||
|
||||
expect(client.listWorkspaceLabelsCalls).toBeGreaterThan(callsBeforeRefresh);
|
||||
expect(statuses).not.toContain("offline");
|
||||
unsubscribe();
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("restores the catalog after reconnecting", async () => {
|
||||
const serverId = "workspace-labels-reconnect";
|
||||
const { client, directory } = createWorkspaceLabelDirectory(serverId);
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
|
||||
directory.connectionChanged({
|
||||
client: null,
|
||||
status: "offline",
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
});
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]?.status).toBe("offline");
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 2 },
|
||||
});
|
||||
await flushWorkspaceLabels();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,6 +189,8 @@ export class DirectorySync {
|
||||
this.connection = connection;
|
||||
this.abortPendingSessionWaits();
|
||||
if (!connection.client || connection.status !== "online") return true;
|
||||
// Reattach labels here because route-only demand can satisfy the epoch before full demand requests them.
|
||||
void this.connectWorkspaceLabels().catch(() => undefined);
|
||||
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
writes = 0;
|
||||
cleanups = 0;
|
||||
nextWriteFailure: Error | null = null;
|
||||
persistentWriteFailure: Error | null = null;
|
||||
readGate: Promise<void> | null = null;
|
||||
onRead: (() => void) | null = null;
|
||||
/** Throws once a read goes past this count, so a non-terminating read loop fails the test. */
|
||||
readLimit: number | null = null;
|
||||
|
||||
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
|
||||
return `${row.serverId}:${row.kind}:${row.id}`;
|
||||
@@ -47,6 +50,11 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
|
||||
if (this.readLimit !== null && this.reads.length > this.readLimit) {
|
||||
throw new Error(
|
||||
`replica cache read ${this.reads.length} passed the limit of ${this.readLimit}`,
|
||||
);
|
||||
}
|
||||
this.onRead?.();
|
||||
await this.readGate;
|
||||
const acceptedKinds = new Set(kinds);
|
||||
@@ -71,6 +79,7 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
|
||||
async apply(changes: ReplicaRowChanges): Promise<void> {
|
||||
this.writes += 1;
|
||||
if (this.persistentWriteFailure) throw this.persistentWriteFailure;
|
||||
if (this.nextWriteFailure) {
|
||||
const error = this.nextWriteFailure;
|
||||
this.nextWriteFailure = null;
|
||||
@@ -432,6 +441,34 @@ describe("ReplicaCache", () => {
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives up a read instead of retrying while the store keeps rejecting writes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
commitDirectory(cache, SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
storage.persistentWriteFailure = new Error("QuotaExceededError");
|
||||
storage.reads.length = 0;
|
||||
storage.readLimit = 5;
|
||||
|
||||
deleteDirectory(cache, SERVER_ID);
|
||||
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
expect(storage.reads.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("still reads a host whose rows are stored when another host's write is rejected", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = new ReplicaCache(storage, noLegacyCleanup);
|
||||
cache.setHosts([SERVER_ID, "other-host"]);
|
||||
commitDirectory(cache, SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
storage.persistentWriteFailure = new Error("QuotaExceededError");
|
||||
|
||||
commitDirectory(cache, "other-host", directory());
|
||||
|
||||
expect((await cache.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.id).toBe("workspace-1");
|
||||
});
|
||||
|
||||
it("discards a durable read when the host changes while it is in flight", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
|
||||
@@ -983,7 +983,11 @@ export class ReplicaCache {
|
||||
try {
|
||||
await this.prepareStore();
|
||||
while (this.activeServerIds.has(serverId)) {
|
||||
await this.flush();
|
||||
// A read may only answer from rows the store already holds, so it waits for this host's
|
||||
// accepted commits to land. A store that rejects them will keep rejecting them: fail closed
|
||||
// rather than re-attempt the write on every pass. A write rejected for another host leaves
|
||||
// this host's stored rows readable.
|
||||
if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return [];
|
||||
const revision = this.hostRevisions.get(serverId) ?? 0;
|
||||
const rows = await this.rowStore.read(serverId, kinds, ids);
|
||||
if (this.canReadHostRevision(serverId, revision)) return rows;
|
||||
@@ -1173,20 +1177,26 @@ export class ReplicaCache {
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.persist();
|
||||
await this.syncPending();
|
||||
}
|
||||
|
||||
/** Resolves to whether every pending change reached the store. */
|
||||
private async syncPending(): Promise<boolean> {
|
||||
const persisted = await this.persist();
|
||||
await this.writeQueue.catch(() => undefined);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private async flushPending(): Promise<void> {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
private async persist(): Promise<boolean> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
if (!this.hasPendingChanges()) return;
|
||||
if (!this.hasPendingChanges()) return true;
|
||||
const write = this.writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
@@ -1206,11 +1216,16 @@ export class ReplicaCache {
|
||||
} catch {
|
||||
this.restorePendingChanges(pending);
|
||||
if (this.hasPendingChanges()) this.schedulePersist();
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
return true;
|
||||
});
|
||||
this.writeQueue = write;
|
||||
await write;
|
||||
// The queue only sequences writes; every consumer decides for itself what a failure means.
|
||||
this.writeQueue = write.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return write;
|
||||
}
|
||||
|
||||
private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void {
|
||||
|
||||
@@ -377,8 +377,7 @@ export function KeyboardShortcutsSection() {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (key === "Backspace") {
|
||||
if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) {
|
||||
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migrateSidebarOrderState } from "./sidebar-order-store";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeSidebarOrderUpdates,
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/sidebar-workspaces-view-model";
|
||||
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
|
||||
|
||||
describe("migrateSidebarOrderState", () => {
|
||||
it("prefixes legacy per-server workspace order with the source server id", () => {
|
||||
@@ -31,3 +35,71 @@ describe("migrateSidebarOrderState", () => {
|
||||
expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]);
|
||||
});
|
||||
});
|
||||
|
||||
// A directory whose name ends in a space produces a view key that ends in a
|
||||
// space. Trimming it on write used to make the sidebar's reconcile effect loop
|
||||
// forever, crashing the app with React error #185 (see #4880).
|
||||
describe("sidebar order keys that end in whitespace", () => {
|
||||
const PROJECT_KEY = "host:srv-1:/home/u/Reklamation ";
|
||||
|
||||
beforeEach(() => {
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a project key exactly as given", () => {
|
||||
useSidebarOrderStore.getState().setProjectOrder([PROJECT_KEY]);
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]);
|
||||
});
|
||||
|
||||
it("scopes a workspace order under the project key the sidebar indexes by", () => {
|
||||
useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]);
|
||||
|
||||
// use-sidebar-workspaces-list reads workspaceOrderByProject by view key
|
||||
// directly, so the scope has to be stored under that same key.
|
||||
expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([
|
||||
"srv-1:main",
|
||||
]);
|
||||
expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]);
|
||||
});
|
||||
|
||||
it("settles the sidebar order reconcile instead of rewriting it forever", () => {
|
||||
const projects: SidebarProjectEntry[] = [
|
||||
{
|
||||
viewKey: PROJECT_KEY,
|
||||
projectName: "Reklamation ",
|
||||
projectKind: "non_git",
|
||||
iconWorkingDir: "/home/u/Reklamation ",
|
||||
hosts: [],
|
||||
workspaces: [],
|
||||
},
|
||||
];
|
||||
const runPass = () =>
|
||||
computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: useSidebarOrderStore.getState().projectOrder,
|
||||
getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key),
|
||||
});
|
||||
|
||||
// First pass adds the newly visible project.
|
||||
const first = runPass();
|
||||
expect(first.projectOrder).toEqual([PROJECT_KEY]);
|
||||
useSidebarOrderStore.getState().setProjectOrder(first.projectOrder ?? []);
|
||||
|
||||
// Second pass must find nothing left to do; otherwise the effect that
|
||||
// applies these updates re-runs on every render.
|
||||
expect(runPass().projectOrder).toBeNull();
|
||||
});
|
||||
|
||||
it("still trims keys when migrating persisted state", () => {
|
||||
const migrated = migrateSidebarOrderState({
|
||||
projectOrder: [" host-a:one ", "host-a:one"],
|
||||
});
|
||||
|
||||
expect(migrated.projectOrder).toEqual(["host-a:one"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ interface SidebarWorkspaceOrderScope {
|
||||
projectViewKey: string;
|
||||
}
|
||||
|
||||
// Trims each key. Only for persisted state read at migration time, where a
|
||||
// stray space is an artifact of an older format rather than part of the key.
|
||||
function normalizeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
@@ -54,6 +56,28 @@ function normalizeKeys(keys: string[]): string[] {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops blank keys and duplicates but keeps each key exactly as given. View
|
||||
* keys embed a project's path, so a directory whose name ends in a space
|
||||
* produces a key that ends in a space. Trimming it stores a key that can never
|
||||
* match the one the sidebar looks up, so the caller sees its key as missing,
|
||||
* writes it again, and the effect that reconciles the order never settles.
|
||||
*/
|
||||
function dedupeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!key.trim() || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(key);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceOrderByProject(
|
||||
workspaceOrderByProject: Record<string, string[]> | undefined,
|
||||
): Record<string, string[]> {
|
||||
@@ -134,27 +158,22 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
workspaceOrderByProject: {},
|
||||
getProjectOrder: () => get().projectOrder,
|
||||
setProjectOrder: (keys) => {
|
||||
const normalized = normalizeKeys(keys);
|
||||
set({ projectOrder: normalized });
|
||||
set({ projectOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder,
|
||||
setPinnedWorkspaceOrder: (keys) => {
|
||||
const normalized = normalizeKeys(keys);
|
||||
set({ pinnedWorkspaceOrder: normalized });
|
||||
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getWorkspaceOrder: (projectViewKey) => {
|
||||
const scope = projectViewKey.trim();
|
||||
if (!scope) return [];
|
||||
return get().workspaceOrderByProject[scope] ?? [];
|
||||
if (!projectViewKey.trim()) return [];
|
||||
return get().workspaceOrderByProject[projectViewKey] ?? [];
|
||||
},
|
||||
setWorkspaceOrder: (projectViewKey, keys) => {
|
||||
const scope = projectViewKey.trim();
|
||||
if (!scope) return;
|
||||
const normalized = normalizeKeys(keys);
|
||||
if (!projectViewKey.trim()) return;
|
||||
set((state) => ({
|
||||
workspaceOrderByProject: {
|
||||
...state.workspaceOrderByProject,
|
||||
[scope]: normalized,
|
||||
[projectViewKey]: dedupeKeys(keys),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import {
|
||||
createSidebarViewStorage,
|
||||
@@ -8,14 +8,6 @@ import {
|
||||
useSidebarViewStore,
|
||||
} from "./sidebar-view-store";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn().mockResolvedValue(null),
|
||||
setItem: vi.fn().mockResolvedValue(undefined),
|
||||
removeItem: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MemoryStorage extends StateStorage<Promise<void>> {
|
||||
reads: string[];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import { createVoiceRuntime, type VoiceSessionAdapter } from "@/voice/voice-runtime";
|
||||
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
||||
|
||||
const CUE_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
|
||||
const THINKING_TONE_MIN_SILENCE_MS = 1500;
|
||||
const SEGMENT_MIME_TYPE = "audio/pcm;rate=24000;bits=16";
|
||||
|
||||
function createAudioEngineMock(): AudioEngine {
|
||||
return {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -252,10 +256,11 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioFinished("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS);
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the thinking tone when an agent turn begins before playback", async () => {
|
||||
it("starts the thinking tone once the wait for a reply outlasts an inter-segment gap", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
runtime.registerSession(adapter);
|
||||
@@ -264,6 +269,10 @@ describe("voice runtime", () => {
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS - 1);
|
||||
expect(engine.play).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -284,6 +293,7 @@ describe("voice runtime", () => {
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS);
|
||||
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||
vi.mocked(engine.stop).mockClear();
|
||||
vi.mocked(engine.clearQueue).mockClear();
|
||||
@@ -303,6 +313,53 @@ describe("voice runtime", () => {
|
||||
resolvePlay(0.1);
|
||||
});
|
||||
|
||||
it("does not play the thinking tone in the gap between two segments of one reply", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const engine = createAudioEngineMock();
|
||||
const played: string[] = [];
|
||||
vi.mocked(engine.play).mockImplementation(async (source: { type: string }) => {
|
||||
played.push(source.type);
|
||||
return 0.1;
|
||||
});
|
||||
const { runtime } = createRuntime({ engine });
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "seg-1-chunk-0",
|
||||
groupId: "seg-1",
|
||||
chunkIndex: 0,
|
||||
isLastChunk: true,
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
|
||||
runtime.handleAudioOutput(
|
||||
"server-1",
|
||||
createAudioPayload({
|
||||
id: "seg-2-chunk-0",
|
||||
groupId: "seg-2",
|
||||
chunkIndex: 0,
|
||||
isLastChunk: true,
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(played).toContain(SEGMENT_MIME_TYPE);
|
||||
});
|
||||
|
||||
const replyStart = played.indexOf(SEGMENT_MIME_TYPE);
|
||||
expect(replyStart).toBeGreaterThanOrEqual(0);
|
||||
expect(played.slice(replyStart).filter((type) => type === CUE_MIME_TYPE)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns to listening after assistant playback once the turn is complete", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const { runtime, engine } = createRuntime();
|
||||
@@ -310,6 +367,7 @@ describe("voice runtime", () => {
|
||||
|
||||
await runtime.startVoice("server-1", "agent-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS);
|
||||
runtime.onAssistantAudioStarted("server-1");
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_completed");
|
||||
runtime.onAssistantAudioFinished("server-1");
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
|
||||
const KEEP_AWAKE_TAG = "paseo:voice";
|
||||
const THINKING_TONE_REPEAT_GAP_MS = 350;
|
||||
/**
|
||||
* A reply is spoken as one TTS segment per sentence, and the daemon starts the next segment
|
||||
* only once the current one has finished playing, so a reply in progress is punctuated by short
|
||||
* silences. The cue is for a wait the user cannot otherwise explain, so it starts only once the
|
||||
* silence has outlasted those gaps.
|
||||
*/
|
||||
const THINKING_TONE_MIN_SILENCE_MS = 1500;
|
||||
const DISPLAY_VOLUME_PUBLISH_INTERVAL_MS = 120;
|
||||
const DISPLAY_VOLUME_CHANGE_EPSILON = 0.02;
|
||||
const DISPLAY_VOLUME_ATTACK = 0.35;
|
||||
@@ -492,7 +499,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
});
|
||||
};
|
||||
|
||||
playNext();
|
||||
cue.timeout = setTimeout(playNext, THINKING_TONE_MIN_SILENCE_MS);
|
||||
}
|
||||
|
||||
const uploader: ContinuousVoiceUploader = {
|
||||
|
||||
@@ -147,6 +147,11 @@ class WorkspaceLabelsController {
|
||||
client: DaemonClient;
|
||||
supportsWorkspaceLabels: boolean;
|
||||
}): Promise<void> {
|
||||
const existing = this.connections.get(input.serverId);
|
||||
if (existing?.client === input.client && input.supportsWorkspaceLabels) {
|
||||
await this.refresh(input.serverId);
|
||||
return;
|
||||
}
|
||||
this.disconnect(input.serverId);
|
||||
const replica = this.replicas.get(input.serverId) ?? new HostWorkspaceLabelReplica();
|
||||
this.replicas.set(input.serverId, replica);
|
||||
|
||||
@@ -131,6 +131,7 @@ function WorkspaceLabelPickerPage({
|
||||
}, [assignedLabels, host?.status, labels, model]);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.snapshot, model.snapshot);
|
||||
const offline = !snapshot.online;
|
||||
const error = snapshot.error ?? (host?.error ? t("workspaceLabels.errors.load") : null);
|
||||
const pending = useMemo(() => new Set(snapshot.pendingNames), [snapshot.pendingNames]);
|
||||
const toggle = useCallback(
|
||||
(label: WorkspaceLabelDefinition, assigned: boolean) => {
|
||||
@@ -159,9 +160,7 @@ function WorkspaceLabelPickerPage({
|
||||
>
|
||||
{t("workspaceLabels.create")}
|
||||
</MenuSubTrigger>
|
||||
{snapshot.error ? (
|
||||
<MenuHint testID="workspace-label-picker-error">{snapshot.error}</MenuHint>
|
||||
) : null}
|
||||
{error ? <MenuHint testID="workspace-label-picker-error">{error}</MenuHint> : null}
|
||||
{host?.status === "unsupported" ? (
|
||||
<MenuHint>{t("workspaceLabels.updateHostUse")}</MenuHint>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* AsyncStorage has no implementation under Node: its default export reaches for
|
||||
* the native module and every call rejects, so any test that touches a
|
||||
* persisted store emits unhandled rejections from the persist middleware.
|
||||
*
|
||||
* This is a real in-memory key-value store with the same contract, not a
|
||||
* recording double, so a test can read back what a store wrote.
|
||||
*/
|
||||
const entries = new Map<string, string>();
|
||||
|
||||
export function clearAsyncStorageStub(): void {
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
const AsyncStorageStub = {
|
||||
getItem: (key: string): Promise<string | null> => Promise.resolve(entries.get(key) ?? null),
|
||||
setItem: (key: string, value: string): Promise<void> => {
|
||||
entries.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
removeItem: (key: string): Promise<void> => {
|
||||
entries.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
getAllKeys: (): Promise<string[]> => Promise.resolve([...entries.keys()]),
|
||||
// `provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
|
||||
// hands it the real AsyncStorage, so a stub that stops at the single-key calls
|
||||
// fails any test that reaches the provider cache with `multiGet is not a function`.
|
||||
multiGet: (keys: readonly string[]): Promise<Array<[string, string | null]>> =>
|
||||
Promise.resolve(keys.map((key) => [key, entries.get(key) ?? null] as [string, string | null])),
|
||||
multiSet: (pairs: ReadonlyArray<readonly [string, string]>): Promise<void> => {
|
||||
for (const [key, value] of pairs) entries.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
multiRemove: (keys: readonly string[]): Promise<void> => {
|
||||
for (const key of keys) entries.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clear: (): Promise<void> => {
|
||||
entries.clear();
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
export default AsyncStorageStub;
|
||||
@@ -185,6 +185,12 @@ export default defineConfig({
|
||||
find: /^expo-linking$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"),
|
||||
},
|
||||
// No Node implementation: every call on the real module rejects, so a test
|
||||
// touching a persisted store drowns in unhandled rejections.
|
||||
{
|
||||
find: /^@react-native-async-storage\/async-storage$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/async-storage.ts"),
|
||||
},
|
||||
{
|
||||
find: /^lucide-react-native$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/lucide-react-native.ts"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -5,6 +5,7 @@ import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { hashDaemonPassword } from "@getpaseo/server/auth";
|
||||
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control";
|
||||
import { expect, test } from "vitest";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
@@ -196,6 +197,26 @@ test("removed flags and ambiguous targets fail before side effects; observation
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("status reports the server id of a password-protected daemon to a caller without the password", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
const configPath = path.join(home, "config.json");
|
||||
const config = JSON.parse(await readFile(configPath, "utf8"));
|
||||
config.daemon.auth = { password: await hashDaemonPassword("secret") };
|
||||
await writeFile(configPath, JSON.stringify(config));
|
||||
await f.ok(["start", "--home", home, "--timeout", "30"]);
|
||||
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
|
||||
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
|
||||
connectedDaemon: "auth_required",
|
||||
serverId: authenticated.serverId,
|
||||
});
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
|
||||
const f = await fixture();
|
||||
const [a, b] = f.homes as [string, string];
|
||||
@@ -591,6 +612,25 @@ test("malformed configuration cannot turn a readiness timeout into launch cancel
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
await mkdir(home, { recursive: true });
|
||||
await writeFile(configPath, '{"version":1,');
|
||||
const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]);
|
||||
expect(failed.code).toBe(1);
|
||||
expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`);
|
||||
expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`);
|
||||
expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain(
|
||||
`Invalid JSON in ${configPath}`,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
|
||||
@@ -124,6 +124,7 @@ function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemo
|
||||
pid: instance?.pid ?? null,
|
||||
startedAt: instance?.startedAt ?? null,
|
||||
listen: instance?.listen ?? null,
|
||||
serverId: instance?.serverId ?? undefined,
|
||||
hostname: instance?.hostname ?? null,
|
||||
configuredListen: config.listen,
|
||||
localDaemon,
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
|
||||
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
idField: "requestId",
|
||||
columns: [
|
||||
{ header: "REQUEST ID", field: "requestId", width: 12 },
|
||||
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
|
||||
{ header: "AGENT", field: "agentShortId", width: 10 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{
|
||||
@@ -32,6 +32,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Transform a permission the agent answered to a response item */
|
||||
export function toPermissionResponseItem(
|
||||
agentId: string,
|
||||
permission: AgentPermissionRequest,
|
||||
result: "allowed" | "denied",
|
||||
): PermissionResponseItem {
|
||||
return {
|
||||
requestId: permission.id,
|
||||
agentId,
|
||||
agentShortId: agentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
export type PermitAllowResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
export interface PermitAllowOptions extends CommandOptions {
|
||||
@@ -118,13 +133,7 @@ export async function runAllowCommand(
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "allowed",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
|
||||
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
|
||||
import {
|
||||
permitResponseSchema,
|
||||
toPermissionResponseItem,
|
||||
type PermissionResponseItem,
|
||||
} from "./allow.js";
|
||||
|
||||
export type PermitDenyResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
@@ -83,13 +87,7 @@ export async function runDenyCommand(
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "denied",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "AGENT", field: "agentShortId", width: 12 },
|
||||
{ header: "REQ_ID", field: "id", width: 12 },
|
||||
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{ header: "DESCRIPTION", field: "description", width: 50 },
|
||||
],
|
||||
@@ -30,7 +30,7 @@ function toListItem(
|
||||
permission: AgentPermissionRequest,
|
||||
): PermissionListItem {
|
||||
return {
|
||||
id: permission.id.slice(0, 8),
|
||||
id: permission.id,
|
||||
agentId: agent.id,
|
||||
agentShortId: agent.id.slice(0, 7),
|
||||
name: permission.name,
|
||||
@@ -40,6 +40,22 @@ function toListItem(
|
||||
|
||||
export type PermitLsResult = ListResult<PermissionListItem>;
|
||||
|
||||
/** Collect the pending permissions of every agent */
|
||||
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
for (const permission of agent.pendingPermissions ?? []) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermitLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
@@ -55,21 +71,7 @@ export async function runLsCommand(
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
await client.close();
|
||||
|
||||
// Collect all pending permissions from all agents
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
|
||||
for (const permission of agent.pendingPermissions) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
return listPendingPermissions(agents);
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "../../output/index.js";
|
||||
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
|
||||
import { listPendingPermissions } from "./ls.js";
|
||||
|
||||
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
|
||||
|
||||
function permissionRequest(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "claude", name: "Write", kind: "tool" };
|
||||
}
|
||||
|
||||
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
|
||||
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
|
||||
|
||||
const agent = {
|
||||
id: agentId,
|
||||
pendingPermissions: [writeRequest, editRequest],
|
||||
} as AgentSnapshotPayload;
|
||||
|
||||
describe("permit ls output", () => {
|
||||
it("emits full request ids that permit allow accepts", () => {
|
||||
const result = listPendingPermissions([agent]);
|
||||
|
||||
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
|
||||
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
|
||||
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permit allow and deny output", () => {
|
||||
it("emits the full id of the request that was answered", () => {
|
||||
const result = {
|
||||
type: "list" as const,
|
||||
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
|
||||
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
|
||||
requestId: string;
|
||||
}>;
|
||||
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
|
||||
});
|
||||
});
|
||||
@@ -52,13 +52,20 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
|
||||
return {
|
||||
code,
|
||||
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
details:
|
||||
options.target.kind === "instance"
|
||||
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
|
||||
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
|
||||
details: describeConnectionRemedy(code, options.target),
|
||||
};
|
||||
}
|
||||
|
||||
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
|
||||
if (code === "AUTH_REQUIRED")
|
||||
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
|
||||
if (code === "AUTH_FAILED")
|
||||
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
|
||||
if (target.kind === "instance")
|
||||
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
|
||||
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
|
||||
}
|
||||
|
||||
export function normalizeDaemonHost(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Regression: a reboot reassigns the supervisor's PID to an unrelated process. The lock
|
||||
* left behind must not make the daemon look like it is running, and must never be treated
|
||||
* as a handle on whatever now holds that PID.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir, uptime } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
|
||||
console.log("=== Daemon lock left behind by a reboot ===\n");
|
||||
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stale-boot-lock-"));
|
||||
const env = {
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
};
|
||||
|
||||
// Stands in for the process that inherits the supervisor's PID after a reboot. It records a
|
||||
// signal rather than dying of it, so a late delivery cannot slip past the assertion.
|
||||
const signalMarker = join(paseoHome, "bystander-signalled");
|
||||
const bystander = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`process.on("SIGTERM", () => require("node:fs").writeFileSync(${JSON.stringify(signalMarker)}, "SIGTERM"));` +
|
||||
`setTimeout(() => {}, 120_000);`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
let bystanderExited = false;
|
||||
bystander.once("exit", () => {
|
||||
bystanderExited = true;
|
||||
});
|
||||
assert(bystander.pid, "bystander process should have a pid");
|
||||
|
||||
try {
|
||||
const beforeThisBoot = new Date(Date.now() - uptime() * 1000 - 60 * 60_000);
|
||||
await writeFile(
|
||||
join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({
|
||||
pid: bystander.pid,
|
||||
startedAt: beforeThisBoot.toISOString(),
|
||||
hostname: "before-reboot",
|
||||
uid: process.getuid?.() ?? 0,
|
||||
listen: "127.0.0.1:6767",
|
||||
desktopManaged: true,
|
||||
heartbeat: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const statusResult = await runLocalPaseo(
|
||||
["daemon", "status", "--home", paseoHome, "--json"],
|
||||
env,
|
||||
);
|
||||
assert.strictEqual(
|
||||
statusResult.exitCode,
|
||||
0,
|
||||
`daemon status should succeed:\nstdout:\n${statusResult.stdout}\nstderr:\n${statusResult.stderr}`,
|
||||
);
|
||||
const status = JSON.parse(statusResult.stdout) as { localDaemon?: string; pid?: number | null };
|
||||
assert.strictEqual(
|
||||
status.localDaemon,
|
||||
"stopped",
|
||||
`daemon left behind by a reboot should read as stopped, got: ${statusResult.stdout}`,
|
||||
);
|
||||
assert.strictEqual(status.pid, null, "status should not report the reused pid as the daemon");
|
||||
console.log("✓ daemon status reports stopped\n");
|
||||
|
||||
const stopResult = await runLocalPaseo(["daemon", "stop", "--home", paseoHome], env);
|
||||
assert.strictEqual(
|
||||
stopResult.exitCode,
|
||||
0,
|
||||
`daemon stop should succeed:\nstdout:\n${stopResult.stdout}\nstderr:\n${stopResult.stderr}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
existsSync(signalMarker),
|
||||
false,
|
||||
"daemon stop must not signal the process that inherited the pid",
|
||||
);
|
||||
assert.strictEqual(bystanderExited, false, "the process that inherited the pid should survive");
|
||||
console.log("✓ daemon stop leaves the process holding that pid alone\n");
|
||||
} finally {
|
||||
bystander.kill("SIGKILL");
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("=== Daemon lock left behind by a reboot passed ===");
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Daemon Auth Command Errors ===\n");
|
||||
|
||||
const daemon = await startTestDaemon({
|
||||
env: { PASEO_PASSWORD: "shared-secret" },
|
||||
});
|
||||
|
||||
async function lsError(password: string) {
|
||||
const result = await runLocalPaseo(["ls", "--json"], {
|
||||
PASEO_HOME: daemon.paseoHome,
|
||||
PASEO_HOST: "",
|
||||
PASEO_PASSWORD: password,
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
|
||||
return JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("");
|
||||
assert.strictEqual(error.code, "AUTH_REQUIRED");
|
||||
assert.match(error.message, /Password required/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ missing password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("wrong-secret");
|
||||
assert.strictEqual(error.code, "AUTH_FAILED");
|
||||
assert.match(error.message, /Incorrect password/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ wrong password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== Daemon Auth Command Errors Tests Passed ===");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -14,10 +14,13 @@ const SHORTCUTS_ROW = "show-shortcuts";
|
||||
* daemon is left unmanaged so the app talks to the E2E daemon instead of trying
|
||||
* to start one of its own.
|
||||
*/
|
||||
async function installDesktopBridge(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
async function installDesktopBridge(
|
||||
page: Page,
|
||||
platform: "darwin" | "win32" = "darwin",
|
||||
): Promise<void> {
|
||||
await page.addInitScript((desktopPlatform) => {
|
||||
window.paseoDesktop = {
|
||||
platform: "darwin",
|
||||
platform: desktopPlatform,
|
||||
events: { on: () => () => {} },
|
||||
invoke: async (command: string) => {
|
||||
if (command === "get_desktop_settings") {
|
||||
@@ -29,11 +32,11 @@ async function installDesktopBridge(page: Page): Promise<void> {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
}, platform);
|
||||
}
|
||||
|
||||
async function openShortcutsSettings(page: Page) {
|
||||
await installDesktopBridge(page);
|
||||
async function openShortcutsSettings(page: Page, platform: "darwin" | "win32" = "darwin") {
|
||||
await installDesktopBridge(page, platform);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "shortcuts");
|
||||
@@ -67,6 +70,38 @@ async function closeRowMenu(page: Page) {
|
||||
await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function clearShortcut(page: Page, label: string, action: string) {
|
||||
await page.getByRole("button", { name: `Actions for ${label}` }).click();
|
||||
await page.getByTestId(`shortcut-clear-${action}`).click();
|
||||
}
|
||||
|
||||
async function captureShortcut(page: Page, label: string, action: string, keys: string) {
|
||||
await page.getByRole("button", { name: `Actions for ${label}` }).click();
|
||||
await page.getByTestId(`shortcut-bind-${action}`).click();
|
||||
await page.keyboard.press(keys);
|
||||
}
|
||||
|
||||
async function expectInterruptShortcut(page: Page, chord: string) {
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await expect(row.getByText(chord, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function finishInterruptCapture(page: Page, action: "Done" | "Cancel") {
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await row.getByRole("button", { name: action }).click();
|
||||
}
|
||||
|
||||
async function eraseLastCapturedCombo(page: Page, firstCombo: string, secondCombo: string) {
|
||||
await captureShortcut(page, "Interrupt agent", "agent-interrupt", "Alt+K");
|
||||
await page.keyboard.press("Alt+J");
|
||||
await expectInterruptShortcut(page, firstCombo);
|
||||
await expectInterruptShortcut(page, secondCombo);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expectInterruptShortcut(page, firstCombo);
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await expect(row.getByText(secondCombo, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("unassigning a shortcut leaves it inert until it is reset", async ({ page }) => {
|
||||
await openShortcutsSettings(page);
|
||||
|
||||
@@ -146,3 +181,35 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
for (const { platform, keys, label, firstCombo, secondCombo } of [
|
||||
{
|
||||
platform: "darwin",
|
||||
keys: "Meta+Shift+Backspace",
|
||||
label: "⇧⌘⌫",
|
||||
firstCombo: "⌥K",
|
||||
secondCombo: "⌥J",
|
||||
},
|
||||
{
|
||||
platform: "win32",
|
||||
keys: "Control+Shift+Backspace",
|
||||
label: "Ctrl+Shift+⌫",
|
||||
firstCombo: "Alt+K",
|
||||
secondCombo: "Alt+J",
|
||||
},
|
||||
] as const) {
|
||||
test(`binds modified Backspace and erases a combo with bare Backspace on ${platform}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openShortcutsSettings(page, platform);
|
||||
await clearShortcut(page, "Archive workspace", "archive-workspace");
|
||||
await captureShortcut(page, "Interrupt agent", "agent-interrupt", keys);
|
||||
await expectInterruptShortcut(page, label);
|
||||
await finishInterruptCapture(page, "Done");
|
||||
await expectInterruptShortcut(page, label);
|
||||
|
||||
await eraseLastCapturedCombo(page, firstCombo, secondCombo);
|
||||
await finishInterruptCapture(page, "Cancel");
|
||||
await expectInterruptShortcut(page, label);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -65,8 +65,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -76,8 +76,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -82,6 +82,7 @@
|
||||
"measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts",
|
||||
"measure:git-observation": "tsx scripts/measure-git-observation.ts",
|
||||
"measure:file-observer": "tsx scripts/measure-file-observer.ts",
|
||||
"analyze:observer-metrics": "tsx scripts/analyze-observer-metrics.ts",
|
||||
"repro:file-observer-teardown": "tsx scripts/reproduce-file-observer-teardown.ts",
|
||||
"speech:models": "tsx scripts/list-speech-models.ts",
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
@@ -106,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
interface Sample {
|
||||
uptimeSeconds: number;
|
||||
trackedFiles: number;
|
||||
renames: number;
|
||||
classifications: number;
|
||||
pendingClassifications: number;
|
||||
pendingWork: number;
|
||||
maxReconciliationMs: number;
|
||||
eventLoopMaxMs: number;
|
||||
rssBytes: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
// Pino lines are prefixed with a plain-text timestamp; the JSON payload starts
|
||||
// at the first `{`. Lines that aren't JSON (or aren't ws_runtime_metrics) are skipped.
|
||||
function parseLogLine(line: string): Record<string, unknown> | undefined {
|
||||
const start = line.indexOf("{");
|
||||
if (start < 0) return undefined;
|
||||
try {
|
||||
return asRecord(JSON.parse(line.slice(start)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractObserverRecord(root: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const workspaceService = asRecord(asRecord(root.git)?.workspaceService);
|
||||
return asRecord(workspaceService?.fileObserver);
|
||||
}
|
||||
|
||||
function buildSample(root: Record<string, unknown>, observer: Record<string, unknown>): Sample {
|
||||
const eventLoopDelay = asRecord(root.eventLoopDelay);
|
||||
const memory = asRecord(root.memory);
|
||||
return {
|
||||
uptimeSeconds: asNumber(root.uptimeSeconds) ?? 0,
|
||||
trackedFiles: asNumber(observer.nativeTrackedFileCount) ?? 0,
|
||||
renames: asNumber(observer.nativeRenameEventCount) ?? 0,
|
||||
classifications: asNumber(observer.nativeClassificationCount) ?? 0,
|
||||
pendingClassifications: asNumber(observer.pendingClassificationCount) ?? 0,
|
||||
pendingWork: asNumber(observer.pendingReconciliationWorkCount) ?? 0,
|
||||
maxReconciliationMs: asNumber(observer.maxReconciliationDurationMs) ?? 0,
|
||||
eventLoopMaxMs: asNumber(eventLoopDelay?.maxMs) ?? 0,
|
||||
rssBytes: asNumber(memory?.rss) ?? 0,
|
||||
failures:
|
||||
(asNumber(observer.observerFailureCount) ?? 0) +
|
||||
(asNumber(observer.reconciliationFailureCount) ?? 0) +
|
||||
(asNumber(observer.directoryLimitFailureCount) ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function readSamples(path: string): Promise<Sample[]> {
|
||||
const samples: Sample[] = [];
|
||||
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
||||
for await (const line of lines) {
|
||||
const root = parseLogLine(line);
|
||||
if (!root) continue;
|
||||
const observer = extractObserverRecord(root);
|
||||
if (!observer) continue;
|
||||
samples.push(buildSample(root, observer));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
// A daemon restart resets uptime, so a decrease starts a new run.
|
||||
function splitRuns(samples: Sample[]): Sample[][] {
|
||||
const runs: Sample[][] = [];
|
||||
let current: Sample[] = [];
|
||||
let previous = Number.NEGATIVE_INFINITY;
|
||||
for (const sample of samples) {
|
||||
if (sample.uptimeSeconds < previous && current.length > 0) {
|
||||
runs.push(current);
|
||||
current = [];
|
||||
}
|
||||
current.push(sample);
|
||||
previous = sample.uptimeSeconds;
|
||||
}
|
||||
if (current.length > 0) runs.push(current);
|
||||
return runs;
|
||||
}
|
||||
|
||||
function peak(run: Sample[], select: (sample: Sample) => number): number {
|
||||
return run.reduce((highest, sample) => Math.max(highest, select(sample)), 0);
|
||||
}
|
||||
|
||||
const logPath = process.argv[2];
|
||||
if (!logPath) {
|
||||
console.error("usage: analyze-observer-metrics <path-to-daemon.log>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runs = splitRuns(await readSamples(logPath));
|
||||
const header = [
|
||||
"run".padStart(3),
|
||||
"samples".padStart(7),
|
||||
"hours".padStart(6),
|
||||
"peakTracked".padStart(11),
|
||||
"renames".padStart(10),
|
||||
"classify".padStart(10),
|
||||
"peakPendClass".padStart(13),
|
||||
"peakPendWork".padStart(12),
|
||||
"maxReconS".padStart(9),
|
||||
"peakLoopS".padStart(9),
|
||||
"peakRssMB".padStart(9),
|
||||
"fails".padStart(5),
|
||||
].join(" ");
|
||||
console.log(header);
|
||||
runs.forEach((run, index) => {
|
||||
console.log(
|
||||
[
|
||||
String(index + 1).padStart(3),
|
||||
String(run.length).padStart(7),
|
||||
(run[run.length - 1].uptimeSeconds / 3600).toFixed(1).padStart(6),
|
||||
String(peak(run, (s) => s.trackedFiles)).padStart(11),
|
||||
String(peak(run, (s) => s.renames)).padStart(10),
|
||||
String(peak(run, (s) => s.classifications)).padStart(10),
|
||||
String(peak(run, (s) => s.pendingClassifications)).padStart(13),
|
||||
String(peak(run, (s) => s.pendingWork)).padStart(12),
|
||||
(peak(run, (s) => s.maxReconciliationMs) / 1000).toFixed(1).padStart(9),
|
||||
(peak(run, (s) => s.eventLoopMaxMs) / 1000).toFixed(1).padStart(9),
|
||||
(peak(run, (s) => s.rssBytes) / 1048576).toFixed(0).padStart(9),
|
||||
String(peak(run, (s) => s.failures)).padStart(5),
|
||||
].join(" "),
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
|
||||
import { promisify } from "node:util";
|
||||
import { createFileObserver, type FileChange } from "../src/server/file-observer/index.js";
|
||||
import {
|
||||
createFileObserver,
|
||||
type FileChange,
|
||||
type FileObserverSubscription,
|
||||
} from "../src/server/file-observer/index.js";
|
||||
|
||||
const DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_DIRS", 500);
|
||||
const ROOT_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_ROOTS", 1);
|
||||
@@ -17,8 +21,58 @@ const SUSTAINED_EDIT_INTERVAL_MS = readPositiveInteger(
|
||||
500,
|
||||
);
|
||||
const REPETITIONS = readPositiveInteger("PASEO_WATCH_BENCH_REPETITIONS", 1);
|
||||
const MODE = resolveMode();
|
||||
const LATE_IGNORED_DIRS = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_DIRS", 2_000);
|
||||
const LATE_IGNORED_FILES = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_FILES", 20_000);
|
||||
// directory-churn mode exercises removeSubtree/reconcileSubtree/paths.collapse
|
||||
// in packages/server/src/server/file-observer/internal/native-recursive.ts,
|
||||
// which the healthy and late-ignored fixtures never touch: they only
|
||||
// mkdir/writeFile, and every delete happens after unsubscribe.
|
||||
const CHURN_DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_DIRECTORIES", 20_000);
|
||||
const CHURN_FILES_PER_DIRECTORY = readPositiveInteger(
|
||||
"PASEO_WATCH_BENCH_CHURN_FILES_PER_DIRECTORY",
|
||||
3,
|
||||
);
|
||||
const CHURN_ROUNDS = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_ROUNDS", 3);
|
||||
const CHURN_DIRECTORIES_PER_ROUND = readPositiveInteger(
|
||||
"PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND",
|
||||
500,
|
||||
);
|
||||
// Sustained rename churn (the production symptom this mode approximates) can
|
||||
// leave a single reconciliation running for minutes once the tracked set is
|
||||
// large; give it room to finish instead of calling that a timeout failure.
|
||||
const CHURN_SETTLE_TIMEOUT_MS = readPositiveInteger(
|
||||
"PASEO_WATCH_BENCH_CHURN_SETTLE_TIMEOUT_MS",
|
||||
300_000,
|
||||
);
|
||||
// Sequential `await`-per-entry construction lets the observer's pending
|
||||
// fs.stat() classifications drain between filesystem calls, so the storm this
|
||||
// mode exists to reproduce never piles up: the sequential version could never
|
||||
// push pendingClassificationCount past 32, nowhere near the 2,080 bound
|
||||
// (32 concurrent + 2,048 queued) a later fix is measured against. Firing
|
||||
// batches of concurrent mkdir/writeFile calls overwhelms the libuv threadpool
|
||||
// that both the harness's own fs calls and the observer's classify() stat()
|
||||
// calls share, which is what produces a real backlog. The batch size is
|
||||
// derived from this process's actual open-file limit (see
|
||||
// resolveLateIgnoredBatchSize below) rather than hardcoded, so a replay on a
|
||||
// machine with a much lower `ulimit -n` (common on Linux/CI, historically on
|
||||
// macOS too) degrades to a smaller burst instead of throwing EMFILE mid-run.
|
||||
const LATE_IGNORED_BATCH_SIZE = resolveLateIgnoredBatchSize();
|
||||
// Reuses the same descriptor-aware sizing as LATE_IGNORED_BATCH_SIZE above:
|
||||
// directory-churn mode needs concurrent, not sequential, filesystem calls to
|
||||
// outrun the audit's 500ms quiet window and force scopes to pile up.
|
||||
const CHURN_BATCH_SIZE = resolveLateIgnoredBatchSize();
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type BenchMode = "healthy" | "late-ignored" | "directory-churn";
|
||||
|
||||
function resolveMode(): BenchMode {
|
||||
const raw = process.env.PASEO_WATCH_BENCH_MODE;
|
||||
if (raw === "late-ignored") return "late-ignored";
|
||||
if (raw === "directory-churn") return "directory-churn";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
interface Measurement {
|
||||
backend: string;
|
||||
run: number;
|
||||
@@ -40,6 +94,8 @@ interface Measurement {
|
||||
sustainedNativeClassificationCount: number | null;
|
||||
sustainedNativeShallowScanCount: number | null;
|
||||
sustainedMissedPaths: number;
|
||||
lateIgnoredTrackedFileCount: number | null;
|
||||
peakPendingClassificationCount: number;
|
||||
teardownMs: number;
|
||||
startupEvents: number;
|
||||
deliveredEvents: number;
|
||||
@@ -53,6 +109,16 @@ interface Measurement {
|
||||
kernelWatchCount: number | null;
|
||||
scopedReconciliationCount: number | null;
|
||||
fullReconciliationCount: number | null;
|
||||
// Absolute values (not deltas) read straight off diagnostics: the observer
|
||||
// instance is fresh per run, so "max"/"last" already scope to this run.
|
||||
maxReconciliationDurationMs: number;
|
||||
lastReconciliationDurationMs: number;
|
||||
// directory-churn mode only; null for healthy/late-ignored.
|
||||
nativeRenameEventCount: number | null;
|
||||
churnDirectoryCount: number | null;
|
||||
churnFileCount: number | null;
|
||||
churnRounds: number | null;
|
||||
churnDirectoriesChurned: number | null;
|
||||
}
|
||||
|
||||
interface FixtureRoot {
|
||||
@@ -65,7 +131,7 @@ async function main(): Promise<void> {
|
||||
const isChild = process.argv.includes("--child");
|
||||
|
||||
if (isChild) {
|
||||
process.stdout.write(`${JSON.stringify(await measure(1))}\n`);
|
||||
process.stdout.write(`${JSON.stringify(await runMeasurement(1))}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,6 +146,10 @@ async function main(): Promise<void> {
|
||||
if (!evaluation.passed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function runMeasurement(run: number): Promise<Measurement> {
|
||||
return MODE === "directory-churn" ? measureDirectoryChurn(run) : measure(run);
|
||||
}
|
||||
|
||||
async function measureInChild(run: number): Promise<Measurement> {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
@@ -98,7 +168,7 @@ async function measure(run: number): Promise<Measurement> {
|
||||
const ignoredRoot = join(root, "ignored");
|
||||
const trackedRoot = join(root, "tracked");
|
||||
await Promise.all([
|
||||
mkdir(ignoredRoot, { recursive: true }),
|
||||
MODE === "healthy" ? mkdir(ignoredRoot, { recursive: true }) : Promise.resolve(),
|
||||
mkdir(trackedRoot, { recursive: true }),
|
||||
]);
|
||||
for (let index = 0; index < DIRECTORY_COUNT; index += 1) {
|
||||
@@ -135,7 +205,7 @@ async function measure(run: number): Promise<Measurement> {
|
||||
firstObservedAt.set(event.path, performance.now());
|
||||
}
|
||||
},
|
||||
{ ignore: [ignoredRoot] },
|
||||
{ ignore: MODE === "healthy" ? [ignoredRoot] : [] },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -144,7 +214,40 @@ async function measure(run: number): Promise<Measurement> {
|
||||
const startupEvents = delivered.length;
|
||||
const kernelWatchCount = await readKernelWatchCount();
|
||||
|
||||
let peakPendingClassificationCount = 0;
|
||||
let lateIgnoredTrackedFileCount: number | null = null;
|
||||
let samplePending: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
try {
|
||||
samplePending = setInterval(() => {
|
||||
peakPendingClassificationCount = Math.max(
|
||||
peakPendingClassificationCount,
|
||||
observer.getDiagnostics().pendingClassificationCount,
|
||||
);
|
||||
}, 50);
|
||||
samplePending.unref();
|
||||
|
||||
if (MODE === "late-ignored") {
|
||||
// The whole point: the ignored tree appears only after the observation
|
||||
// exists. Construction runs in concurrent batches (not one
|
||||
// mkdir/writeFile at a time) so filesystem events arrive faster than
|
||||
// the observer can classify them — otherwise nothing ever piles up.
|
||||
for (const target of roots) {
|
||||
await mkdir(target.ignoredRoot, { recursive: true });
|
||||
await runInBatches(LATE_IGNORED_DIRS, LATE_IGNORED_BATCH_SIZE, (index) =>
|
||||
mkdir(join(target.ignoredRoot, `package-${index}`), { recursive: true }),
|
||||
);
|
||||
await runInBatches(LATE_IGNORED_FILES, LATE_IGNORED_BATCH_SIZE, (index) =>
|
||||
writeFile(
|
||||
join(target.ignoredRoot, `package-${index % LATE_IGNORED_DIRS}`, `module-${index}.js`),
|
||||
`${index}\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
lateIgnoredTrackedFileCount = observer.getDiagnostics().nativeTrackedFileCount;
|
||||
}
|
||||
|
||||
for (let index = 0; index < IGNORED_FILE_COUNT; index += 1) {
|
||||
const target = roots[index % roots.length];
|
||||
await writeFile(join(target.ignoredRoot, `artifact-${index}.txt`), `${index}\n`);
|
||||
@@ -263,6 +366,8 @@ async function measure(run: number): Promise<Measurement> {
|
||||
sustainedMissedPaths: sustainedPaths.filter(
|
||||
(path) => !delivered.some((event) => event.path === path),
|
||||
).length,
|
||||
lateIgnoredTrackedFileCount,
|
||||
peakPendingClassificationCount,
|
||||
teardownMs,
|
||||
startupEvents,
|
||||
deliveredEvents: delivered.length,
|
||||
@@ -282,14 +387,307 @@ async function measure(run: number): Promise<Measurement> {
|
||||
diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount,
|
||||
fullReconciliationCount:
|
||||
diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount,
|
||||
maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs,
|
||||
lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs,
|
||||
nativeRenameEventCount: null,
|
||||
churnDirectoryCount: null,
|
||||
churnFileCount: null,
|
||||
churnRounds: null,
|
||||
churnDirectoriesChurned: null,
|
||||
};
|
||||
} finally {
|
||||
if (samplePending) clearInterval(samplePending);
|
||||
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
|
||||
await observer.close();
|
||||
await rm(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// directory-churn mode. Unlike healthy/late-ignored, this builds one large
|
||||
// tracked tree and then repeatedly makes whole known directories disappear —
|
||||
// the only way to invoke removeSubtree, reconcileSubtree, and paths.collapse
|
||||
// in native-recursive.ts.
|
||||
async function measureDirectoryChurn(run: number): Promise<Measurement> {
|
||||
const totalChurnTargets = CHURN_ROUNDS * CHURN_DIRECTORIES_PER_ROUND;
|
||||
if (totalChurnTargets > CHURN_DIRECTORY_COUNT) {
|
||||
throw new Error(
|
||||
`PASEO_WATCH_BENCH_CHURN_ROUNDS (${CHURN_ROUNDS}) * ` +
|
||||
`PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND (${CHURN_DIRECTORIES_PER_ROUND}) = ` +
|
||||
`${totalChurnTargets} must not exceed PASEO_WATCH_BENCH_CHURN_DIRECTORIES ` +
|
||||
`(${CHURN_DIRECTORY_COUNT})`,
|
||||
);
|
||||
}
|
||||
|
||||
const base = await mkdtemp(join(tmpdir(), "paseo-watch-churn-"));
|
||||
const root = join(base, "root");
|
||||
await mkdir(root, { recursive: true });
|
||||
const observer = createFileObserver();
|
||||
let subscription: FileObserverSubscription | null = null;
|
||||
|
||||
try {
|
||||
// 1. Build a large, entirely-tracked tree before subscribing. Nothing
|
||||
// here is ignored: removeSubtree and reconcileSubtree scale with the size
|
||||
// of `this.files` / `this.directories`, so the fixture needs a big
|
||||
// tracked set for their cost to show up at all.
|
||||
await runInBatches(CHURN_DIRECTORY_COUNT, CHURN_BATCH_SIZE, (index) =>
|
||||
mkdir(join(root, `dir-${index}`)),
|
||||
);
|
||||
await runInBatches(
|
||||
CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY,
|
||||
CHURN_BATCH_SIZE,
|
||||
(flatIndex) => {
|
||||
const directoryIndex = Math.floor(flatIndex / CHURN_FILES_PER_DIRECTORY);
|
||||
const fileIndex = flatIndex % CHURN_FILES_PER_DIRECTORY;
|
||||
return writeFile(
|
||||
join(root, `dir-${directoryIndex}`, `file-${fileIndex}.txt`),
|
||||
`${flatIndex}\n`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const rssBefore = process.memoryUsage().rss;
|
||||
const cpuBefore = process.cpuUsage();
|
||||
const diagnosticsBefore = observer.getDiagnostics();
|
||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||
eventLoopDelay.enable();
|
||||
const delivered: FileChange[] = [];
|
||||
let error: Error | null = null;
|
||||
|
||||
const setupStarted = performance.now();
|
||||
subscription = await observer.subscribe(root, (nextError, events) => {
|
||||
error ??= nextError;
|
||||
delivered.push(...events);
|
||||
});
|
||||
const setupMs = performance.now() - setupStarted;
|
||||
|
||||
// 2. Let the initial inventory settle before churning anything.
|
||||
const initialSettled = await waitFor(() => {
|
||||
const diagnostics = observer.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingEventCount === 0 &&
|
||||
diagnostics.pendingReconciliationWorkCount === 0 &&
|
||||
diagnostics.reconciliationInFlightCount === 0
|
||||
);
|
||||
}, 120_000);
|
||||
if (!initialSettled) {
|
||||
throw new Error("Timed out waiting for directory-churn initial inventory to settle");
|
||||
}
|
||||
if (error) throw error;
|
||||
const startupEvents = delivered.length;
|
||||
const kernelWatchCount = await readKernelWatchCount();
|
||||
|
||||
// 3. Drive directory churn. Each round deletes half of a batch of known
|
||||
// directories outright (their parent's next shallow scan finds them gone
|
||||
// and calls removeSubtree) and replaces the other half atomically in
|
||||
// place: rm the old directory, then rename a freshly built sibling onto
|
||||
// the same path. The backend's classify() stat() resolves after the
|
||||
// rename completes, sees a directory at a path it already knew as a
|
||||
// directory, and treats it as the directory itself changing —
|
||||
// populating recursiveScopes and driving reconcileSubtree. Firing every
|
||||
// round's operations concurrently, faster than the audit's 500ms quiet
|
||||
// window, is what forces the resulting scopes onto one large
|
||||
// reconciliation instead of many small ones, which is what the
|
||||
// production incident this mode approximates looked like.
|
||||
const diagnosticsBeforeChurn = observer.getDiagnostics();
|
||||
const churnCpuBefore = process.cpuUsage();
|
||||
const churnStarted = performance.now();
|
||||
let nextDirectoryIndex = 0;
|
||||
for (let round = 0; round < CHURN_ROUNDS; round += 1) {
|
||||
const batch: number[] = [];
|
||||
for (let i = 0; i < CHURN_DIRECTORIES_PER_ROUND; i += 1) {
|
||||
batch.push(nextDirectoryIndex);
|
||||
nextDirectoryIndex += 1;
|
||||
}
|
||||
const deleteCount = Math.floor(batch.length / 2);
|
||||
const deleteTargets = batch.slice(0, deleteCount);
|
||||
const replaceTargets = batch.slice(deleteCount);
|
||||
await runInBatches(deleteTargets.length, CHURN_BATCH_SIZE, (offset) =>
|
||||
rm(join(root, `dir-${deleteTargets[offset]}`), { recursive: true, force: true }),
|
||||
);
|
||||
await runInBatches(replaceTargets.length, CHURN_BATCH_SIZE, (offset) =>
|
||||
replaceDirectoryAtomically(
|
||||
join(root, `dir-${replaceTargets[offset]}`),
|
||||
CHURN_FILES_PER_DIRECTORY,
|
||||
),
|
||||
);
|
||||
}
|
||||
const churnSettled = await waitFor(() => {
|
||||
const diagnostics = observer.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingEventCount === 0 &&
|
||||
diagnostics.pendingReconciliationWorkCount === 0 &&
|
||||
diagnostics.reconciliationInFlightCount === 0
|
||||
);
|
||||
}, CHURN_SETTLE_TIMEOUT_MS);
|
||||
if (!churnSettled) {
|
||||
throw new Error(
|
||||
`Timed out waiting for directory-churn reconciliation to settle within ` +
|
||||
`${CHURN_SETTLE_TIMEOUT_MS}ms`,
|
||||
);
|
||||
}
|
||||
const churnDurationMs = performance.now() - churnStarted;
|
||||
const churnCpu = process.cpuUsage(churnCpuBefore);
|
||||
const churnCpuMs = (churnCpu.user + churnCpu.system) / 1_000;
|
||||
if (error) throw error;
|
||||
const diagnosticsAfterChurn = observer.getDiagnostics();
|
||||
|
||||
const teardownStarted = performance.now();
|
||||
await subscription.unsubscribe();
|
||||
const teardownMs = performance.now() - teardownStarted;
|
||||
const diagnosticsAfter = observer.getDiagnostics();
|
||||
const cpu = process.cpuUsage(cpuBefore);
|
||||
eventLoopDelay.disable();
|
||||
|
||||
return {
|
||||
backend: "production",
|
||||
run,
|
||||
setupMs,
|
||||
editLatencyMs: 0,
|
||||
editLatencyP50Ms: 0,
|
||||
editLatencyP95Ms: 0,
|
||||
editLatencyP99Ms: 0,
|
||||
burstDurationMs: churnDurationMs,
|
||||
burstCpuMs: churnCpuMs,
|
||||
sustainedDurationMs: 0,
|
||||
sustainedCpuMs: 0,
|
||||
sustainedScopedReconciliationCount: null,
|
||||
sustainedFullReconciliationCount: null,
|
||||
sustainedNativeEventCount: null,
|
||||
sustainedNativeChangeEventCount: null,
|
||||
sustainedNativeRenameEventCount: null,
|
||||
sustainedNativePathlessEventCount: null,
|
||||
sustainedNativeClassificationCount: null,
|
||||
sustainedNativeShallowScanCount: null,
|
||||
sustainedMissedPaths: 0,
|
||||
lateIgnoredTrackedFileCount: null,
|
||||
peakPendingClassificationCount: 0,
|
||||
teardownMs,
|
||||
startupEvents,
|
||||
deliveredEvents: delivered.length,
|
||||
// Nothing is ignored in this mode's fixture; see the comment at the
|
||||
// CHURN_DIRECTORY_COUNT declaration above.
|
||||
ignoredEvents: 0,
|
||||
missedTrackedPaths: 0,
|
||||
cpuUserMs: cpu.user / 1_000,
|
||||
cpuSystemMs: cpu.system / 1_000,
|
||||
rssDeltaMiB: (process.memoryUsage().rss - rssBefore) / 1024 / 1024,
|
||||
eventLoopDelayP99Ms: eventLoopDelay.percentile(99) / 1_000_000,
|
||||
eventLoopDelayMaxMs: eventLoopDelay.max / 1_000_000,
|
||||
kernelWatchCount,
|
||||
scopedReconciliationCount:
|
||||
diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount,
|
||||
fullReconciliationCount:
|
||||
diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount,
|
||||
maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs,
|
||||
lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs,
|
||||
nativeRenameEventCount:
|
||||
diagnosticsAfterChurn.nativeRenameEventCount -
|
||||
diagnosticsBeforeChurn.nativeRenameEventCount,
|
||||
churnDirectoryCount: CHURN_DIRECTORY_COUNT,
|
||||
churnFileCount: CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY,
|
||||
churnRounds: CHURN_ROUNDS,
|
||||
churnDirectoriesChurned: totalChurnTargets,
|
||||
};
|
||||
} finally {
|
||||
if (subscription) await subscription.unsubscribe();
|
||||
await observer.close();
|
||||
await rm(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a fresh sibling directory, then rm+rename it onto `directory`. This
|
||||
// is the atomic-replace pattern dev-server builds use to swap output
|
||||
// directories in place, and the specific shape that drives reconcileSubtree:
|
||||
// see the comment inside measureDirectoryChurn's churn loop above.
|
||||
async function replaceDirectoryAtomically(directory: string, fileCount: number): Promise<void> {
|
||||
const staging = `${directory}.replacement`;
|
||||
await mkdir(staging);
|
||||
for (let fileIndex = 0; fileIndex < fileCount; fileIndex += 1) {
|
||||
await writeFile(join(staging, `file-${fileIndex}.txt`), `${fileIndex}\n`);
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await rename(staging, directory);
|
||||
}
|
||||
|
||||
function resolveLateIgnoredBatchSize(): number {
|
||||
// A batch this small still produces a genuine burst instead of degenerating
|
||||
// back toward the sequential, one-at-a-time construction this code
|
||||
// replaces — see the comment on LATE_IGNORED_BATCH_SIZE above for why a
|
||||
// burst is required at all.
|
||||
const floor = 64;
|
||||
// Never exceed this regardless of how generous the descriptor limit is.
|
||||
// 2,000 already produces peak pending classifications in the ~2,700-3,200
|
||||
// range on a high-limit machine, comfortably clearing the 2,080 bound the
|
||||
// later fix imposes, so there is no reason to risk more file descriptors
|
||||
// for a larger, noisier number.
|
||||
const ceiling = 2_000;
|
||||
// Used when this runtime cannot report its own descriptor limit at all.
|
||||
// Small enough to be safe under a default Linux/CI soft limit of roughly
|
||||
// 1,024-4,096, still large enough to produce a real burst.
|
||||
const fallback = 512;
|
||||
const softLimit = readOpenFileSoftLimit();
|
||||
if (softLimit === null) return fallback;
|
||||
// Leave headroom for everything else the process holds open at the same
|
||||
// time: stdio, the fixture's own already-created directories/files, the
|
||||
// observer's native watch handle, and Node's own bookkeeping fds.
|
||||
const quarter = Math.floor(softLimit / 4);
|
||||
return Math.min(ceiling, Math.max(floor, quarter));
|
||||
}
|
||||
|
||||
function readOpenFileSoftLimit(): number | null {
|
||||
// Node's diagnostic report is the only cross-platform way to read this
|
||||
// process's own open-file (RLIMIT_NOFILE) soft limit without shelling out
|
||||
// to `ulimit -n`, which is a shell builtin with no direct Node API.
|
||||
// `header.fileDescriptorSoftLimit` is checked first for forward/backward
|
||||
// compatibility, but it does not exist on this script's tested runtime
|
||||
// (verified empirically: `undefined` on Node v24.15.0/darwin). The field
|
||||
// that does exist there, and is expected to exist on Linux too since it
|
||||
// comes from the same libuv getrlimit() call, is
|
||||
// `userLimits.open_files.soft`.
|
||||
let report: unknown;
|
||||
try {
|
||||
report = process.report?.getReport();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof report !== "object" || report === null) return null;
|
||||
const asRecord = report as Record<string, unknown>;
|
||||
return (
|
||||
parseDescriptorLimit(readNestedField(asRecord, ["header", "fileDescriptorSoftLimit"])) ??
|
||||
parseDescriptorLimit(readNestedField(asRecord, ["userLimits", "open_files", "soft"]))
|
||||
);
|
||||
}
|
||||
|
||||
function readNestedField(source: Record<string, unknown>, path: string[]): unknown {
|
||||
let current: unknown = source;
|
||||
for (const key of path) {
|
||||
if (typeof current !== "object" || current === null) return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function parseDescriptorLimit(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
||||
if (typeof value === "string" && value.trim().toLowerCase() === "unlimited") {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runInBatches(
|
||||
count: number,
|
||||
batchSize: number,
|
||||
createEntry: (index: number) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
for (let start = 0; start < count; start += batchSize) {
|
||||
const end = Math.min(start + batchSize, count);
|
||||
const batch: Promise<unknown>[] = [];
|
||||
for (let index = start; index < end; index += 1) batch.push(createEntry(index));
|
||||
await Promise.all(batch);
|
||||
}
|
||||
}
|
||||
|
||||
async function readKernelWatchCount(): Promise<number | null> {
|
||||
if (process.platform !== "linux") return null;
|
||||
let count = 0;
|
||||
@@ -311,6 +709,11 @@ function fixtureDescription() {
|
||||
sustainedEdits: SUSTAINED_EDIT_COUNT,
|
||||
sustainedEditIntervalMs: SUSTAINED_EDIT_INTERVAL_MS,
|
||||
repetitions: REPETITIONS,
|
||||
mode: MODE,
|
||||
churnDirectories: CHURN_DIRECTORY_COUNT,
|
||||
churnFilesPerDirectory: CHURN_FILES_PER_DIRECTORY,
|
||||
churnRounds: CHURN_ROUNDS,
|
||||
churnDirectoriesPerRound: CHURN_DIRECTORIES_PER_ROUND,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
@@ -321,6 +724,12 @@ function evaluate(results: Measurement[]): {
|
||||
passed: boolean;
|
||||
failures: string[];
|
||||
} {
|
||||
if (MODE === "directory-churn") {
|
||||
// This phase exists to characterize a known-bad code path (removeSubtree
|
||||
// and reconcileSubtree scanning the whole tracked set per disappeared
|
||||
// directory), not to gate CI on a threshold yet.
|
||||
return { passed: true, failures: [] };
|
||||
}
|
||||
const failures: string[] = [];
|
||||
for (const result of results) {
|
||||
evaluateStandaloneResult(result, failures);
|
||||
@@ -332,7 +741,10 @@ function evaluateStandaloneResult(result: Measurement, failures: string[]): void
|
||||
if (result.missedTrackedPaths !== 0) {
|
||||
failures.push(`observer missed ${result.missedTrackedPaths} tracked paths`);
|
||||
}
|
||||
if (result.ignoredEvents !== 0) {
|
||||
// In late-ignored mode, nonzero ignoredEvents is the bug reproducing, not a
|
||||
// regression: the observer is deliberately given the ignored tree only
|
||||
// after it subscribes, so it has no way to exclude it yet.
|
||||
if (MODE !== "late-ignored" && result.ignoredEvents !== 0) {
|
||||
failures.push(`observer emitted ${result.ignoredEvents} excluded events`);
|
||||
}
|
||||
if (result.sustainedMissedPaths !== 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
acquirePidLock,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
updatePidLock,
|
||||
} from "../src/server/pid-lock.js";
|
||||
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
||||
import { daemonLogPath } from "../src/server/daemon-instance.js";
|
||||
import { PRIVATE_FILE_MODE } from "../src/server/private-files.js";
|
||||
import { loadPersistedConfig } from "../src/server/persisted-config.js";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
||||
@@ -118,9 +120,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PidLockError) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
return;
|
||||
failStartup(error.message, error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -172,17 +172,41 @@ async function main(): Promise<void> {
|
||||
: undefined,
|
||||
restartOnCrash: true,
|
||||
logFile: supervisorLogFile,
|
||||
onWorkerReady: async ({ listen }) => {
|
||||
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
|
||||
onWorkerReady: async ({ listen, serverId }) => {
|
||||
await updatePidLock(paseoHome, { listen, serverId }, { ownerPid: process.pid });
|
||||
},
|
||||
onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }),
|
||||
onWorkerExit: () =>
|
||||
updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }),
|
||||
onSupervisorExit: releaseLock,
|
||||
});
|
||||
requestSupervisorShutdown = supervisor.requestShutdown;
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
// The supervisor opens its log only after config and the PID lock succeed. A background
|
||||
// launch discards stderr, so earlier failures also go to the log the launcher points at.
|
||||
function failStartup(detail: string, summary: string): never {
|
||||
process.stderr.write(`${detail}\n`);
|
||||
try {
|
||||
const logPath = daemonLogPath(resolvePaseoHome(process.env));
|
||||
mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({
|
||||
level: "fatal",
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
name: "DaemonRunner",
|
||||
msg: summary,
|
||||
})}\n`,
|
||||
{ mode: PRIVATE_FILE_MODE },
|
||||
);
|
||||
} catch {
|
||||
// stderr already carries the failure.
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
if (error instanceof Error) failStartup(error.stack ?? error.message, error.message);
|
||||
failStartup(String(error), String(error));
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ async function runSupervisorFixture(options: {
|
||||
|
||||
await writeFile(
|
||||
workerPath,
|
||||
`process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`,
|
||||
`process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`,
|
||||
);
|
||||
await writeFile(
|
||||
runnerPath,
|
||||
|
||||
@@ -92,7 +92,7 @@ test("a replacement worker must independently reach ready", async () => {
|
||||
process.on("message", message => {
|
||||
if (message.type === "paseo:graceful-shutdown") process.exit(0);
|
||||
});
|
||||
process.send({ type: "paseo:ready", listen: "test-endpoint" });
|
||||
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" });
|
||||
process.send({ type: "paseo:restart" });
|
||||
`);
|
||||
expect(result.code).toBe(1);
|
||||
@@ -102,7 +102,7 @@ test("a replacement worker must independently reach ready", async () => {
|
||||
test("exit clears publication after an in-flight ready write", async () => {
|
||||
const result = await runWorker(
|
||||
`
|
||||
process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0));
|
||||
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }, () => process.exit(0));
|
||||
`,
|
||||
100,
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ type WorkerLifecycleMessage =
|
||||
| {
|
||||
type: "paseo:ready";
|
||||
listen: string;
|
||||
serverId: string;
|
||||
}
|
||||
| {
|
||||
type: "paseo:restart";
|
||||
@@ -50,7 +51,7 @@ interface SupervisorOptions {
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} | null;
|
||||
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
|
||||
onWorkerReady?: (message: { listen: string; serverId: string }) => Promise<void> | void;
|
||||
onWorkerExit?: () => Promise<void> | void;
|
||||
restartOnCrash?: boolean;
|
||||
onSupervisorExit?: () => Promise<void> | void;
|
||||
@@ -78,11 +79,14 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
|
||||
};
|
||||
}
|
||||
if (type === "paseo:ready") {
|
||||
const listen = (msg as { listen?: unknown }).listen;
|
||||
const { listen, serverId } = msg as { listen?: unknown; serverId?: unknown };
|
||||
if (typeof listen !== "string" || listen.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { type: "paseo:ready", listen };
|
||||
if (typeof serverId !== "string" || serverId.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { type: "paseo:ready", listen, serverId };
|
||||
}
|
||||
if (type === "paseo:restart") {
|
||||
const reason = (msg as { reason?: unknown }).reason;
|
||||
@@ -289,7 +293,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
reachedReady = true;
|
||||
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
|
||||
publication = publication
|
||||
.then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen }))
|
||||
.then(() =>
|
||||
options.onWorkerReady?.({
|
||||
listen: lifecycleMessage.listen,
|
||||
serverId: lifecycleMessage.serverId,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
lifecycleFailed = true;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -6,6 +6,7 @@ import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { startAgentRun } from "./agent-prompt.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -140,3 +141,61 @@ test("resuming a stored agent keeps its unread flag and its last-activity time",
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads an archived agent's history after its working directory is removed", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
|
||||
const worktree = path.join(root, "managed-worktree");
|
||||
await mkdir(worktree, { recursive: true });
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(root, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const agentId = "00000000-0000-4000-8000-000000000501";
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
|
||||
workspaceId: "workspace-worktree",
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "what did you change", logger, {});
|
||||
// Dispatching a run does not finish it: the provider appends the reply to its
|
||||
// history afterwards, and the turn is finalized only once that append lands.
|
||||
// Archive after the turn is finalized so the transcript this test reads back is
|
||||
// already on disk when the worktree goes away.
|
||||
const finished = await manager.waitForAgentEvent(agent.id);
|
||||
expect(finished.status).toBe("idle");
|
||||
await manager.archiveAgent(agent.id);
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
// Archiving the workspace removes the worktree it owned. The agent's history is
|
||||
// persisted and reading it must not depend on that directory still being there.
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
|
||||
const loaded = await ensureAgentLoaded(agentId, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(loaded.id).toBe(agentId);
|
||||
// The transcript is replayed from the provider's persisted history, so the reply the
|
||||
// agent gave before the worktree went away is still readable.
|
||||
const replies = manager
|
||||
.getTimeline(agentId)
|
||||
.filter((item) => item.type === "assistant_message");
|
||||
expect(replies.length).toBeGreaterThan(0);
|
||||
expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -692,6 +692,137 @@ test("uses an injected timeline store without making it a production requirement
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-"));
|
||||
const store = new RecordingTimelineStore();
|
||||
class HistorySession extends TestAgentSession {
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "continue" },
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
};
|
||||
}
|
||||
}
|
||||
class HistoryClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new HistorySession(config);
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new HistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
|
||||
}
|
||||
}
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new HistoryClient() },
|
||||
durableTimelineStore: store,
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
|
||||
// What session.handleRefreshAgentRequest does for a loaded agent, twice over.
|
||||
for (let refresh = 0; refresh < 2; refresh += 1) {
|
||||
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
|
||||
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
|
||||
await manager.flush();
|
||||
}
|
||||
|
||||
const rows = await manager.getTimelineRows(agent.id);
|
||||
expect(rows.map((row) => row.item)).toEqual([
|
||||
{ type: "user_message", text: "continue" },
|
||||
{ type: "assistant_message", text: "done" },
|
||||
]);
|
||||
} finally {
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed history replay leaves the committed timeline intact", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-replay-fail-"));
|
||||
const store = new RecordingTimelineStore();
|
||||
let failReplay = false;
|
||||
class FlakyHistorySession extends TestAgentSession {
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "continue" },
|
||||
};
|
||||
if (failReplay) {
|
||||
throw new Error("history stream closed");
|
||||
}
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
};
|
||||
}
|
||||
}
|
||||
class FlakyHistoryClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new FlakyHistorySession(config);
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new FlakyHistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
|
||||
}
|
||||
}
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new FlakyHistoryClient() },
|
||||
durableTimelineStore: store,
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
|
||||
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
|
||||
await manager.flush();
|
||||
const committed = await manager.getTimelineRows(agent.id);
|
||||
|
||||
failReplay = true;
|
||||
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
|
||||
await expect(
|
||||
manager.hydrateTimelineFromProvider(agent.id, { broadcast: true }),
|
||||
).rejects.toThrow("history stream closed");
|
||||
await manager.flush();
|
||||
|
||||
expect(await manager.getTimelineRows(agent.id)).toEqual(committed);
|
||||
|
||||
// A retry once the provider recovers replaces that history rather than stacking on it.
|
||||
failReplay = false;
|
||||
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
|
||||
await manager.flush();
|
||||
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
|
||||
{ type: "user_message", text: "continue" },
|
||||
{ type: "assistant_message", text: "done" },
|
||||
]);
|
||||
} finally {
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retries provider history hydration after a stream failure", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-retry-"));
|
||||
let attempts = 0;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentResumePurpose,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
@@ -139,6 +140,28 @@ export type AgentRunCancellationResult =
|
||||
| { status: "settled" }
|
||||
| { status: "refused" };
|
||||
|
||||
/** A session that will run in a directory needs that directory to be there. */
|
||||
async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
|
||||
try {
|
||||
const stats = await stat(cwd);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: ${cwd}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
throw new Error(`Working directory does not exist: ${cwd}`, { cause: error });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to access working directory: ${cwd}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparedSessionConfig {
|
||||
storedConfig: AgentSessionConfig;
|
||||
launchConfig: AgentSessionConfig;
|
||||
@@ -148,6 +171,8 @@ interface PreparedSessionConfig {
|
||||
interface NormalizeConfigOptions {
|
||||
resolveDefaultModel?: boolean;
|
||||
env?: Record<string, string>;
|
||||
/** Defaults to interactive. A history load reads persisted state and runs nothing. */
|
||||
purpose?: AgentResumePurpose;
|
||||
}
|
||||
|
||||
interface TimeoutOptions {
|
||||
@@ -1229,7 +1254,7 @@ export class AgentManager {
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
config,
|
||||
resolvedAgentId,
|
||||
options?.env,
|
||||
{ env: options?.env },
|
||||
);
|
||||
this.requireEnabledProvider(storedConfig.provider);
|
||||
const client = await this.requireAvailableClient({
|
||||
@@ -1331,17 +1356,21 @@ export class AgentManager {
|
||||
...overrides,
|
||||
provider: handle.provider,
|
||||
} as AgentSessionConfig;
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
);
|
||||
|
||||
// Decide residency from durable state inside the lifecycle lane. A loader may
|
||||
// have read the record before a queued archive or restore completed.
|
||||
// have read the record before a queued archive or restore completed. Residency is
|
||||
// settled before the config is prepared, because a history load reads an archived
|
||||
// agent whose working directory may be gone.
|
||||
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
|
||||
const currentResumeOptions = record
|
||||
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
|
||||
: resumeOptions;
|
||||
const purpose = currentResumeOptions?.purpose ?? "interactive";
|
||||
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
{ purpose },
|
||||
);
|
||||
const client = this.requireClient(handle.provider);
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
@@ -1358,7 +1387,7 @@ export class AgentManager {
|
||||
undefined,
|
||||
{
|
||||
reason: "resume",
|
||||
purpose: currentResumeOptions?.purpose ?? "interactive",
|
||||
purpose,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
},
|
||||
);
|
||||
@@ -3996,23 +4025,17 @@ export class AgentManager {
|
||||
broadcast: boolean | (() => boolean),
|
||||
): Promise<void> {
|
||||
const deferredBroadcast = typeof broadcast === "function";
|
||||
const timelineEvents: Array<{
|
||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
row: AgentTimelineRow;
|
||||
}> = [];
|
||||
const providerSubagentEvents: AgentManagerEvent[] = [];
|
||||
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
||||
const historySubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
|
||||
agent.historyPrimed = false;
|
||||
try {
|
||||
// Collect the whole replay before touching either store. A stream that fails
|
||||
// halfway then leaves the committed timeline as it was, instead of a partial
|
||||
// copy the next attempt would append to.
|
||||
for await (const rawEvent of agent.session.streamHistory()) {
|
||||
const event = limitAgentStreamEventContent(rawEvent);
|
||||
if (event.type === "provider_subagent") {
|
||||
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
|
||||
if (deferredBroadcast) {
|
||||
providerSubagentEvents.push(managerEvent);
|
||||
} else if (broadcast) {
|
||||
this.dispatch(managerEvent);
|
||||
}
|
||||
historySubagentEvents.push(event);
|
||||
continue;
|
||||
}
|
||||
if (event.type !== "timeline") {
|
||||
@@ -4021,25 +4044,47 @@ export class AgentManager {
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
continue;
|
||||
}
|
||||
const row = this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (deferredBroadcast) {
|
||||
timelineEvents.push({ event, row });
|
||||
} else if (broadcast) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
historyEvents.push(event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history");
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The replay is the timeline, so drop the rows a previous hydration committed.
|
||||
// Keeping them would leave getTimelineRows reading one copy per hydration.
|
||||
await this.deleteCommittedTimeline(agent.id);
|
||||
|
||||
const timelineEvents: Array<{
|
||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
row: AgentTimelineRow;
|
||||
}> = [];
|
||||
const providerSubagentEvents: AgentManagerEvent[] = [];
|
||||
for (const event of historySubagentEvents) {
|
||||
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
|
||||
if (deferredBroadcast) {
|
||||
providerSubagentEvents.push(managerEvent);
|
||||
} else if (broadcast) {
|
||||
this.dispatch(managerEvent);
|
||||
}
|
||||
}
|
||||
for (const event of historyEvents) {
|
||||
const row = this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (deferredBroadcast) {
|
||||
timelineEvents.push({ event, row });
|
||||
} else if (broadcast) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
agent.historyPrimed = true;
|
||||
|
||||
if (typeof broadcast !== "function" || !broadcast()) {
|
||||
@@ -4999,23 +5044,11 @@ export class AgentManager {
|
||||
// Always resolve cwd to absolute path for consistent history file lookup
|
||||
if (normalized.cwd) {
|
||||
normalized.cwd = resolve(normalized.cwd);
|
||||
try {
|
||||
const cwdStats = await stat(normalized.cwd);
|
||||
if (!cwdStats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: ${normalized.cwd}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
throw new Error(`Working directory does not exist: ${normalized.cwd}`, { cause: error });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to access working directory: ${normalized.cwd}`, { cause: error });
|
||||
// Only a session that will run in the directory needs it to still be there. Reading
|
||||
// an archived agent's history runs nothing, and must survive the worktree it ran in
|
||||
// being removed when its workspace was archived.
|
||||
if (options.purpose !== "history") {
|
||||
await assertUsableWorkingDirectory(normalized.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5088,9 +5121,12 @@ export class AgentManager {
|
||||
private async prepareSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
env?: Record<string, string>,
|
||||
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
|
||||
env: options.env,
|
||||
purpose: options.purpose,
|
||||
});
|
||||
const paseoToolPolicy = this.paseoToolsEnabled
|
||||
? this.resolvePaseoToolPolicy(storedConfig.provider)
|
||||
: { enabled: false };
|
||||
|
||||
@@ -89,6 +89,75 @@ async function createMcpClient(url: string, authToken?: string): Promise<McpClie
|
||||
return { callTool: boundCallTool, close: () => rawClient.close() };
|
||||
}
|
||||
|
||||
interface OfflineMcpDaemon {
|
||||
client: McpClient;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startOfflineMcpDaemon(): Promise<OfflineMcpDaemon> {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const removeDirectories = async () => {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
};
|
||||
const port = await getAvailablePort();
|
||||
const daemon = await createPaseoDaemon(
|
||||
{
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
},
|
||||
pino({ level: "silent" }),
|
||||
).catch(async (error: unknown) => {
|
||||
await removeDirectories();
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await daemon.start();
|
||||
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
|
||||
return {
|
||||
client,
|
||||
stop: async () => {
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await removeDirectories();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await daemon.stop();
|
||||
await removeDirectories();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
type WorkspaceCreation = { workspaceId: unknown } | { error: unknown };
|
||||
|
||||
async function createLocalWorkspace(client: McpClient, cwd: string): Promise<WorkspaceCreation> {
|
||||
const result = await client.callTool({
|
||||
name: "create_workspace",
|
||||
args: { isolation: "local", path: cwd },
|
||||
});
|
||||
if (result.isError) {
|
||||
const content = result.content?.[0];
|
||||
return { error: content && "text" in content ? content.text : undefined };
|
||||
}
|
||||
return { workspaceId: getStructuredContent(result)?.workspaceId };
|
||||
}
|
||||
|
||||
async function listWorkspaces(client: McpClient): Promise<WorkspaceCreation[]> {
|
||||
const result = await client.callTool({ name: "list_workspaces", args: {} });
|
||||
const workspaces = getStructuredContent(result)?.workspaces;
|
||||
if (!Array.isArray(workspaces)) return [];
|
||||
return workspaces.map((workspace: StructuredContent) => ({ workspaceId: workspace.workspaceId }));
|
||||
}
|
||||
|
||||
interface LaunchRecorder {
|
||||
recordedLaunches: AgentSessionConfig[];
|
||||
}
|
||||
@@ -235,6 +304,27 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("create_workspace with local isolation adopts only an existing directory", async () => {
|
||||
const daemon = await startOfflineMcpDaemon();
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-workspace-"));
|
||||
const missingPath = path.join(root, "does-not-exist");
|
||||
const filePath = path.join(root, "regular-file");
|
||||
await writeFile(filePath, "not a directory\n", "utf8");
|
||||
try {
|
||||
expect(await createLocalWorkspace(daemon.client, missingPath)).toEqual({
|
||||
error: expect.stringContaining(`Directory not found: ${missingPath}`),
|
||||
});
|
||||
expect(await createLocalWorkspace(daemon.client, filePath)).toEqual({
|
||||
error: expect.stringContaining(`Directory not found: ${filePath}`),
|
||||
});
|
||||
const created = await createLocalWorkspace(daemon.client, root);
|
||||
expect(await listWorkspaces(daemon.client)).toEqual([created]);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("password-protected daemon authorizes the agent MCP via the capability token", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
|
||||
@@ -643,10 +643,13 @@ export interface AgentCreateSessionOptions {
|
||||
persistSession?: boolean;
|
||||
}
|
||||
|
||||
/** What a resumed session is for: driving the agent, or reading what it already did. */
|
||||
export type AgentResumePurpose = "interactive" | "history";
|
||||
|
||||
/** Runtime-only intent for a persisted-session resume. Never persist this option. */
|
||||
export interface AgentResumeSessionOptions {
|
||||
/** Defaults to interactive. History loading may be read-only for archived native sessions. */
|
||||
purpose?: "interactive" | "history";
|
||||
purpose?: AgentResumePurpose;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const workspaceAutoName = new WorkspaceAutoName({
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
ProviderInput,
|
||||
ProviderRegistration,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import { PluginAgentClientRegistry } from "./plugin-provider.js";
|
||||
import {
|
||||
isStaleProviderSessionError,
|
||||
@@ -25,6 +26,7 @@ interface ProviderHarnessOptions {
|
||||
capabilities?: ProviderConnection["capabilities"];
|
||||
completeTurn?: boolean;
|
||||
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
@@ -43,6 +45,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
capabilities,
|
||||
async send(input) {
|
||||
inputs.push(input);
|
||||
if (await options.handleInput?.(input, emit)) return;
|
||||
if (input.type === "catalog") {
|
||||
emit({
|
||||
type: "catalog",
|
||||
@@ -278,6 +281,59 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
|
||||
}
|
||||
|
||||
describe("PluginAgentClientRegistry", () => {
|
||||
test.each([false, true])(
|
||||
"contains a failed session open while send is pending: %s",
|
||||
async (pendingSend) => {
|
||||
let listener: ((event: ProviderEvent) => void) | undefined;
|
||||
const unhandled: unknown[] = [];
|
||||
const observeUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([
|
||||
{
|
||||
id: "failing-provider",
|
||||
label: "Failing provider",
|
||||
async connect() {
|
||||
return {
|
||||
version: 1,
|
||||
capabilities: ["session.persistence"],
|
||||
async send(input) {
|
||||
if (input.type !== "session.open") return;
|
||||
listener?.({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "OMP persistent session registration is in progress" },
|
||||
});
|
||||
// Keep acceptance pending across a Node event-loop turn, as IPC can do.
|
||||
if (pendingSend) await nextTurn();
|
||||
},
|
||||
onEvent(nextListener) {
|
||||
listener = nextListener;
|
||||
return () => {
|
||||
listener = undefined;
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
process.on("unhandledRejection", observeUnhandled);
|
||||
try {
|
||||
await expect(
|
||||
registry.clients()["failing-provider"]!.createSession({
|
||||
provider: "failing-provider",
|
||||
cwd: "/workspace",
|
||||
}),
|
||||
).rejects.toThrow("OMP persistent session registration is in progress");
|
||||
await nextTurn();
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
process.off("unhandledRejection", observeUnhandled);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves nested provider child ownership during opening", async () => {
|
||||
const harness = createProviderHarness({ openChildren: openNestedChildren });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
@@ -540,3 +596,167 @@ describe("PluginAgentClientRegistry", () => {
|
||||
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
|
||||
});
|
||||
});
|
||||
|
||||
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
|
||||
|
||||
async function requestFromClient(client: AgentClient, kind: RequestKind): Promise<unknown> {
|
||||
if (kind === "catalog") return client.fetchCatalog({ scope: "global" });
|
||||
const session = await client.createSession({ provider: client.provider, cwd: "/workspace" });
|
||||
if (kind === "session.open") return session;
|
||||
if (kind === "session.configure") return session.setMode("build");
|
||||
return session.startTurn("hello", { clientMessageId: "pending-message" });
|
||||
}
|
||||
|
||||
async function withObservedProvider(
|
||||
options: ProviderHarnessOptions,
|
||||
run: (client: AgentClient, registry: PluginAgentClientRegistry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const harness = createProviderHarness(options);
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
const unhandled: unknown[] = [];
|
||||
const observe = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", observe);
|
||||
registry.replace([harness.registration]);
|
||||
try {
|
||||
await run(registry.clients()[harness.registration.id]!, registry);
|
||||
await registry.shutdown();
|
||||
await nextTurn();
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
process.off("unhandledRejection", observe);
|
||||
}
|
||||
}
|
||||
|
||||
describe("pending provider responses", () => {
|
||||
test.each<RequestKind>(["session.open", "catalog", "session.configure", "session.prompt"])(
|
||||
"preserves the send error for %s",
|
||||
async (kind) => {
|
||||
const failure = new Error("Provider send failed");
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input) {
|
||||
if (input.type === kind) throw failure;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(requestFromClient(client, kind)).rejects.toBe(failure);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[
|
||||
"catalog",
|
||||
expect.objectContaining({
|
||||
models: [expect.objectContaining({ id: "plugin-model", isDefault: true })],
|
||||
defaultModeId: "build",
|
||||
}),
|
||||
],
|
||||
["session.configure", undefined],
|
||||
] as const)(
|
||||
"contains an early request.failed for %s and permits a successful retry",
|
||||
async (kind, result) => {
|
||||
let failed = false;
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input, emit) {
|
||||
if (input.type !== kind || !("requestId" in input) || failed) return false;
|
||||
failed = true;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "Provider request failed", code: "busy", diagnostic: "retry" },
|
||||
});
|
||||
await nextTurn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(requestFromClient(client, kind)).rejects.toMatchObject({
|
||||
message: "Provider request failed",
|
||||
code: "busy",
|
||||
diagnostic: "retry",
|
||||
});
|
||||
await expect(requestFromClient(client, kind)).resolves.toEqual(result);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["session.open", "Provider session closed"],
|
||||
["session.configure", "Provider session closed"],
|
||||
["session.prompt", StaleProviderSessionError],
|
||||
] as const)("contains session closure while accepting %s", async (kind, error) => {
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input, emit) {
|
||||
if (input.type !== kind || !("sessionId" in input)) return false;
|
||||
emit({
|
||||
type: "session.closed",
|
||||
sessionId: input.sessionId,
|
||||
error: { message: "Provider session closed" },
|
||||
});
|
||||
await nextTurn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["session.open", "Provider connection closed"],
|
||||
["catalog", "Provider closed"],
|
||||
["session.configure", "Provider closed"],
|
||||
["session.prompt", StaleProviderSessionError],
|
||||
] as const)("contains connection shutdown while accepting %s", async (kind, error) => {
|
||||
let disconnect!: () => Promise<void>;
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input) {
|
||||
if (input.type !== kind) return false;
|
||||
await disconnect();
|
||||
await nextTurn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client, registry) => {
|
||||
disconnect = () => registry.shutdown();
|
||||
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"preserves cancellation with acceptance pending: %s",
|
||||
async (pending) => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("Catalog refresh cancelled");
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input) {
|
||||
if (input.type !== "catalog") return false;
|
||||
if (pending) {
|
||||
controller.abort(reason);
|
||||
await nextTurn();
|
||||
} else {
|
||||
setImmediate(() => controller.abort(reason));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "global" }, { signal: controller.signal }),
|
||||
).rejects.toBe(reason);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,6 +92,9 @@ function deferred<Value>(): Deferred<Value> {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
// Provider events can reject before send() settles and the caller awaits this promise.
|
||||
// Observe that interval without replacing the rejecting promise returned to the caller.
|
||||
void promise.catch(() => undefined);
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("provider-owned option schemas", () => {
|
||||
ClaudeProviderOptionsSchema.parse({
|
||||
allowedTools: ["Read"],
|
||||
disallowedTools: ["Bash(rm *)"],
|
||||
extraArgs: { chrome: null, model: "x" },
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
@@ -63,7 +64,26 @@ describe("provider-owned option schemas", () => {
|
||||
},
|
||||
settings: { permissions: { ask: ["Bash(*)"], deny: ["Edit(.env)"] } },
|
||||
}),
|
||||
).toMatchObject({ sandbox: { enabled: true, failIfUnavailable: true } });
|
||||
).toMatchObject({
|
||||
extraArgs: { chrome: null, model: "x" },
|
||||
sandbox: { enabled: true, failIfUnavailable: true },
|
||||
});
|
||||
});
|
||||
|
||||
test.each([true, 42, ["x"]])("rejects invalid Claude extra argument values: %j", (value) => {
|
||||
expect(() =>
|
||||
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
|
||||
extraArgs: { chrome: value },
|
||||
}),
|
||||
).toThrow("providerOptions.extraArgs.chrome");
|
||||
});
|
||||
|
||||
test("rejects a raw argument list for the SDK argument map", () => {
|
||||
expect(() =>
|
||||
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
|
||||
extraArgs: ["--chrome"],
|
||||
}),
|
||||
).toThrow("providerOptions.extraArgs");
|
||||
});
|
||||
|
||||
test("reports the exact invalid Claude option path", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
RequestError,
|
||||
ndJsonStream,
|
||||
type Agent,
|
||||
type CreateTerminalRequest,
|
||||
PermissionOption,
|
||||
PromptResponse,
|
||||
RequestPermissionRequest,
|
||||
@@ -128,7 +129,13 @@ interface ACPConfiguredOverrideInternals {
|
||||
applyConfiguredOverrides(): Promise<void>;
|
||||
}
|
||||
|
||||
function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession {
|
||||
function createSession(
|
||||
options: {
|
||||
terminateProcess?: ProcessTerminator;
|
||||
launchEnv?: Record<string, string>;
|
||||
} = {},
|
||||
): ACPAgentSession {
|
||||
const { terminateProcess, launchEnv } = options;
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "claude-acp",
|
||||
@@ -148,6 +155,7 @@ function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession {
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
...(terminateProcess ? { terminateProcess } : {}),
|
||||
...(launchEnv ? { launchEnv } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -660,6 +668,114 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Terminals the daemon opens for an agent are siblings of the agent process, so
|
||||
// they inherit nothing from it. They carry the agent's identity only if the
|
||||
// session puts its launch environment on them, the way its transport spawn does.
|
||||
describe("agent launch environment", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
const readLaunchIdentityArgs = [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({ id: process.env.PASEO_AGENT_ID ?? null, cwd: process.env.PASEO_AGENT_CWD ?? null, extra: process.env.PASEO_TEST_EXTRA ?? null }))",
|
||||
];
|
||||
|
||||
async function readLaunchIdentity(
|
||||
session: ACPAgentSession,
|
||||
params: Partial<CreateTerminalRequest> = {},
|
||||
): Promise<{ id: string | null; cwd: string | null; extra: string | null }> {
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: process.execPath,
|
||||
args: readLaunchIdentityArgs,
|
||||
cwd: process.cwd(),
|
||||
...params,
|
||||
});
|
||||
await session.waitForTerminalExit({
|
||||
sessionId: "session-1",
|
||||
terminalId: terminal.terminalId,
|
||||
});
|
||||
const { output } = await session.terminalOutput({
|
||||
sessionId: "session-1",
|
||||
terminalId: terminal.terminalId,
|
||||
});
|
||||
return JSON.parse(output.trim());
|
||||
}
|
||||
|
||||
test("gives the terminal the agent's identity", async () => {
|
||||
// The daemon itself does not run inside a Paseo agent.
|
||||
vi.stubEnv("PASEO_AGENT_ID", undefined);
|
||||
vi.stubEnv("PASEO_AGENT_CWD", undefined);
|
||||
const session = createSession({
|
||||
launchEnv: {
|
||||
PASEO_AGENT_ID: "agent-1",
|
||||
PASEO_AGENT_CWD: "/repo",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readLaunchIdentity(session)).resolves.toMatchObject({
|
||||
id: "agent-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers the agent's identity over the daemon's own environment", async () => {
|
||||
// A daemon started from inside another Paseo agent carries that agent's id.
|
||||
vi.stubEnv("PASEO_AGENT_ID", "daemon-host-agent");
|
||||
vi.stubEnv("PASEO_AGENT_CWD", "/elsewhere");
|
||||
const session = createSession({
|
||||
launchEnv: {
|
||||
PASEO_AGENT_ID: "agent-1",
|
||||
PASEO_AGENT_CWD: "/repo",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readLaunchIdentity(session)).resolves.toMatchObject({
|
||||
id: "agent-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("lets the requested terminal environment win over the launch environment", async () => {
|
||||
const session = createSession({
|
||||
launchEnv: {
|
||||
PASEO_AGENT_ID: "agent-1",
|
||||
PASEO_TEST_EXTRA: "from-launch",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
readLaunchIdentity(session, {
|
||||
env: [{ name: "PASEO_TEST_EXTRA", value: "from-request" }],
|
||||
}),
|
||||
).resolves.toMatchObject({ id: "agent-1", extra: "from-request" });
|
||||
});
|
||||
|
||||
test("carries the agent's identity into single-string shell commands", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession({ launchEnv: { PASEO_AGENT_ID: "agent-1" } });
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "paseo heartbeat create --every 5m",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
envOverlay: expect.objectContaining({
|
||||
PASEO_AGENT_ID: "agent-1",
|
||||
BASH_ENV: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
@@ -3327,7 +3443,7 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
|
||||
test("close() terminates the main child process via the process tree", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
const session = createSession({ terminateProcess: terminator.terminate });
|
||||
const internals = asInternals<ACPCloseInternals>(session);
|
||||
|
||||
const child = createTerminalChildStub();
|
||||
@@ -3345,7 +3461,7 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
|
||||
test("close() terminates running terminal child processes", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
const session = createSession({ terminateProcess: terminator.terminate });
|
||||
|
||||
const terminalChild = createTerminalChildStub();
|
||||
await startTerminal(session, terminalChild);
|
||||
@@ -3358,7 +3474,7 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
|
||||
test("close() terminates terminal child processes in parallel", async () => {
|
||||
const terminator = new FakeTerminator("deferred");
|
||||
const session = createSession(terminator.terminate);
|
||||
const session = createSession({ terminateProcess: terminator.terminate });
|
||||
|
||||
const firstChild = createTerminalChildStub();
|
||||
const secondChild = createTerminalChildStub();
|
||||
@@ -3376,7 +3492,7 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
|
||||
test("killTerminal terminates the terminal process tree without a direct SIGTERM", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
const session = createSession({ terminateProcess: terminator.terminate });
|
||||
|
||||
const child = createTerminalChildStub();
|
||||
const terminalId = await startTerminal(session, child);
|
||||
@@ -3389,7 +3505,7 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
|
||||
test("releaseTerminal terminates and removes a running terminal", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const session = createSession(terminator.terminate);
|
||||
const session = createSession({ terminateProcess: terminator.terminate });
|
||||
|
||||
const child = createTerminalChildStub();
|
||||
const terminalId = await startTerminal(session, child);
|
||||
|
||||
@@ -144,6 +144,10 @@ function isACPError(value: unknown): value is ACPError {
|
||||
return isRecord(value) && typeof value.message === "string" && typeof value.code === "number";
|
||||
}
|
||||
|
||||
function isACPInvalidParams(error: unknown): boolean {
|
||||
return isACPError(error) && error.code === -32602;
|
||||
}
|
||||
|
||||
function extractACPErrorDataMessage(data: unknown): string | null {
|
||||
if (!isRecord(data)) {
|
||||
return null;
|
||||
@@ -2277,7 +2281,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
|
||||
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
|
||||
if (!option) {
|
||||
throw new Error(`${this.provider} does not expose ACP feature '${featureId}'`);
|
||||
throw new Error(this.featureUnavailableMessage(featureId));
|
||||
}
|
||||
|
||||
const requestedValue = normalizeConfigFeatureValue(value);
|
||||
@@ -2614,8 +2618,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||
);
|
||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
||||
// The terminal is a sibling of the agent process, not a child, so it inherits
|
||||
// nothing from it. Carry the agent's launch environment the way spawnProcess
|
||||
// does, keeping the requested terminal environment on top.
|
||||
const commandEnvOverlays =
|
||||
terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env];
|
||||
terminalCommand.shell === false
|
||||
? [this.launchEnv, env, createStringCommandShellEnvOverlay()]
|
||||
: [this.launchEnv, env];
|
||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
||||
cwd: params.cwd ?? this.config.cwd,
|
||||
...createProviderEnvSpec({
|
||||
@@ -2814,6 +2823,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
await this.setModeWithSelection({ modeId: configuredModeId, selection });
|
||||
}
|
||||
const configuredModelId = this.config.model;
|
||||
let switchedModel = false;
|
||||
if (configuredModelId && configuredModelId !== this.currentModel) {
|
||||
const selection = resolveACPModelSelection({
|
||||
modelId: configuredModelId,
|
||||
@@ -2822,6 +2832,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
try {
|
||||
await this.setModelWithSelection({ modelId: configuredModelId, selection });
|
||||
switchedModel = true;
|
||||
} catch (error) {
|
||||
if (!this.isModelSelectionUnavailableError(error)) {
|
||||
throw error;
|
||||
@@ -2840,10 +2851,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
||||
continue;
|
||||
}
|
||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
try {
|
||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
} catch (error) {
|
||||
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.warn(
|
||||
{ err: error, featureId: featureOption.id, model: this.currentModel },
|
||||
`${this.provider} cannot apply ACP feature '${featureOption.id}' to the current model; using the provider default`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored feature value is a preference carried over from whichever model the user
|
||||
* last configured, so the session it lands on may have no such option. Paseo's own
|
||||
* guard says so when the session's options are accurate. A model switch answers with
|
||||
* an empty response, leaving Paseo holding the previous model's options, and then the
|
||||
* provider is the one that rejects the write as invalid params. Outside those two
|
||||
* cases the write failed for a reason the user needs to see.
|
||||
*/
|
||||
private isStaleFeatureValueError(
|
||||
error: unknown,
|
||||
featureId: string,
|
||||
switchedModel: boolean,
|
||||
): boolean {
|
||||
if (this.isFeatureUnavailableError(error, featureId)) {
|
||||
return true;
|
||||
}
|
||||
return switchedModel && isACPInvalidParams(error);
|
||||
}
|
||||
|
||||
private warnInvalidSelection(value: string, message: string): void {
|
||||
this.logger.warn({ value }, message);
|
||||
}
|
||||
@@ -2856,6 +2896,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return error instanceof Error && error.message === this.modelSelectionUnavailableMessage();
|
||||
}
|
||||
|
||||
private featureUnavailableMessage(featureId: string): string {
|
||||
return `${this.provider} does not expose ACP feature '${featureId}'`;
|
||||
}
|
||||
|
||||
private isFeatureUnavailableError(error: unknown, featureId: string): boolean {
|
||||
return error instanceof Error && error.message === this.featureUnavailableMessage(featureId);
|
||||
}
|
||||
|
||||
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
||||
if (update.sessionUpdate === "user_message_chunk") {
|
||||
return this.handleUserMessageChunk(update);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentSession } from "../../agent-sdk-types.js";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
|
||||
/** Claude Code replies to a turn either with an assistant message or with nothing at all. */
|
||||
type TurnOutcome = { assistantMessageId: string; subagentMessageId?: string } | "no-response";
|
||||
|
||||
function initMessage(): Record<string, unknown> {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: SESSION_ID,
|
||||
permissionMode: "default",
|
||||
model: "claude-sonnet-4-6",
|
||||
};
|
||||
}
|
||||
|
||||
/** Claude Code echoes the user message back under the uuid Paseo minted for it. */
|
||||
function userEcho(uuid: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "user",
|
||||
uuid,
|
||||
session_id: SESSION_ID,
|
||||
message: { role: "user", content: [{ type: "text", text: "prompt" }] },
|
||||
};
|
||||
}
|
||||
|
||||
function assistantReply(uuid: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "assistant",
|
||||
uuid,
|
||||
session_id: SESSION_ID,
|
||||
message: { id: uuid, role: "assistant", content: [{ type: "text", text: "ok" }] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A message from a Task subagent. The CLI streams these on the same query as the main
|
||||
* conversation, and their uuids live on the subagent's sidechain, not on the session's
|
||||
* own message chain, so `forkSession` cannot resolve one.
|
||||
*/
|
||||
function subagentReply(uuid: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "assistant",
|
||||
uuid,
|
||||
session_id: SESSION_ID,
|
||||
parent_tool_use_id: "toolu_subagent_1",
|
||||
message: { id: uuid, role: "assistant", content: [{ type: "text", text: "subagent work" }] },
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(): Record<string, unknown> {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
uuid: "result-ok",
|
||||
session_id: SESSION_ID,
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 1,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "",
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
||||
permission_denials: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A turn that dies upstream before the model emits its first token. */
|
||||
function failedResult(): Record<string, unknown> {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "error_during_execution",
|
||||
uuid: "result-failed",
|
||||
session_id: SESSION_ID,
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 1,
|
||||
is_error: true,
|
||||
num_turns: 1,
|
||||
errors: ["upstream gateway returned 503"],
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 0 },
|
||||
permission_denials: [],
|
||||
};
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
queryFactory: ReturnType<typeof vi.fn>;
|
||||
/** The uuid Paseo minted for each prompt, in the order the turns ran. */
|
||||
userMessageIds: string[];
|
||||
}
|
||||
|
||||
function createConversation(outcomes: TurnOutcome[]): Conversation {
|
||||
const userMessageIds: string[] = [];
|
||||
const queryFactory = vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const queued: Array<Record<string, unknown>> = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
const closedRef = { value: false };
|
||||
let turnIndex = 0;
|
||||
|
||||
const wake = () => waiters.shift()?.();
|
||||
const enqueue = (message: Record<string, unknown>) => {
|
||||
queued.push(message);
|
||||
wake();
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
for await (const sent of prompt) {
|
||||
const userMessageId = String((sent as { uuid?: unknown }).uuid);
|
||||
userMessageIds.push(userMessageId);
|
||||
const outcome = outcomes[turnIndex];
|
||||
turnIndex += 1;
|
||||
if (turnIndex === 1) {
|
||||
enqueue(initMessage());
|
||||
}
|
||||
enqueue(userEcho(userMessageId));
|
||||
if (outcome === "no-response" || !outcome) {
|
||||
enqueue(failedResult());
|
||||
continue;
|
||||
}
|
||||
enqueue(assistantReply(outcome.assistantMessageId));
|
||||
if (outcome.subagentMessageId) {
|
||||
enqueue(subagentReply(outcome.subagentMessageId));
|
||||
}
|
||||
enqueue(successResult());
|
||||
}
|
||||
closedRef.value = true;
|
||||
wake();
|
||||
})();
|
||||
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
while (queued.length === 0 && !closedRef.value) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
if (queued.length === 0) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return { done: false, value: queued.shift() };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => {
|
||||
closedRef.value = true;
|
||||
wake();
|
||||
return undefined;
|
||||
}),
|
||||
close: vi.fn(() => {
|
||||
closedRef.value = true;
|
||||
wake();
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => []),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
});
|
||||
return { queryFactory, userMessageIds };
|
||||
}
|
||||
|
||||
async function createSession(
|
||||
conversation: Conversation,
|
||||
rewindSdk: FakeClaudeSdk,
|
||||
): Promise<AgentSession> {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: conversation.queryFactory as never,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
rewindSdk,
|
||||
});
|
||||
return client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
}
|
||||
|
||||
async function runTurns(session: AgentSession, count: number): Promise<void> {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
for await (const _ of streamSession(session, `turn ${index + 1}`)) {
|
||||
// drain to the terminal event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Claude rewind across a turn that produced no response", () => {
|
||||
test("forks at the most recent turn that did produce a response", async () => {
|
||||
const conversation = createConversation([
|
||||
{ assistantMessageId: "assistant-1" },
|
||||
"no-response",
|
||||
{ assistantMessageId: "assistant-3" },
|
||||
]);
|
||||
const rewindSdk = new FakeClaudeSdk();
|
||||
const session = await createSession(conversation, rewindSdk);
|
||||
|
||||
try {
|
||||
await runTurns(session, 3);
|
||||
await session.revertConversation?.({ messageId: conversation.userMessageIds[2] });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
|
||||
});
|
||||
|
||||
test("starts a fresh session when no earlier turn produced a response", async () => {
|
||||
const conversation = createConversation(["no-response", { assistantMessageId: "assistant-2" }]);
|
||||
const rewindSdk = new FakeClaudeSdk();
|
||||
const session = await createSession(conversation, rewindSdk);
|
||||
|
||||
try {
|
||||
await runTurns(session, 2);
|
||||
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(rewindSdk.recordedForks).toEqual([]);
|
||||
});
|
||||
|
||||
test("still forks at the previous turn when every turn produced a response", async () => {
|
||||
const conversation = createConversation([
|
||||
{ assistantMessageId: "assistant-1" },
|
||||
{ assistantMessageId: "assistant-2" },
|
||||
]);
|
||||
const rewindSdk = new FakeClaudeSdk();
|
||||
const session = await createSession(conversation, rewindSdk);
|
||||
|
||||
try {
|
||||
await runTurns(session, 2);
|
||||
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude rewind after a turn whose last assistant message came from a subagent", () => {
|
||||
test("forks at the turn's own reply, not at the subagent message", async () => {
|
||||
const conversation = createConversation([
|
||||
{ assistantMessageId: "assistant-1", subagentMessageId: "subagent-1" },
|
||||
{ assistantMessageId: "assistant-2" },
|
||||
]);
|
||||
const rewindSdk = new FakeClaudeSdk();
|
||||
const session = await createSession(conversation, rewindSdk);
|
||||
|
||||
try {
|
||||
await runTurns(session, 2);
|
||||
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,14 @@ import {
|
||||
} from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentSession,
|
||||
AgentTimelineItem,
|
||||
AgentStreamEvent,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
@@ -778,6 +785,29 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("passes extra Claude Code CLI arguments to the SDK", async () => {
|
||||
const { queryFactory, launches } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
providerOptions: {
|
||||
extraArgs: { chrome: null, model: "x" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(session.startTurn("hello")).resolves.toEqual({
|
||||
turnId: expect.stringMatching(/^foreground-turn-/),
|
||||
});
|
||||
|
||||
expect(launches[0]?.options.extraArgs).toEqual({ chrome: null, model: "x" });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("lists fast mode only for supported Opus models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
@@ -866,6 +896,97 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
async function captureSdkUserMessage(prompt: AgentPromptInput): Promise<SDKUserMessage> {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
let resolveSent: ((message: SDKUserMessage) => void) | null = null;
|
||||
const sent = new Promise<SDKUserMessage>((resolve) => {
|
||||
resolveSent = resolve;
|
||||
});
|
||||
queryFactory.mockImplementation((input: { prompt: AsyncIterable<SDKUserMessage> }) => {
|
||||
void (async () => {
|
||||
for await (const message of input.prompt) {
|
||||
resolveSent?.(message);
|
||||
break;
|
||||
}
|
||||
})();
|
||||
return queryMock;
|
||||
});
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
try {
|
||||
await session.startTurn(prompt);
|
||||
return await sent;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
const issueAttachment: AgentAttachment = {
|
||||
type: "forge_issue",
|
||||
mimeType: "application/paseo-forge-issue",
|
||||
forge: "github",
|
||||
number: 12,
|
||||
title: "Fake issue for QA",
|
||||
url: "https://example.invalid/acme/app/issues/12",
|
||||
body: "This is an attached issue body.",
|
||||
};
|
||||
|
||||
// Claude Code expands a slash command only when it is the last content block of the user
|
||||
// message, so an auto-attached issue or a pasted screenshot must not be appended after it.
|
||||
test("sends a typed slash command last when an attachment follows it", async () => {
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("/hello please", undefined, [issueAttachment]),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
|
||||
{ type: "text", text: "/hello please" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends a typed slash command last when an image follows it", async () => {
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("/hello please", [{ data: "aGk=", mimeType: "image/png" }], undefined),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "aGk=" } },
|
||||
{ type: "text", text: "/hello please" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps typed text before attachments when it is not a slash command", async () => {
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("please look at this issue", undefined, [issueAttachment]),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: "please look at this issue" },
|
||||
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
|
||||
]);
|
||||
});
|
||||
|
||||
test("moves the typed slash command, not an attachment that reads like one", async () => {
|
||||
const chatHistory: AgentAttachment = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "/earlier command quoted from another chat",
|
||||
};
|
||||
const message = await captureSdkUserMessage(
|
||||
buildAgentPrompt("/hello please", undefined, [chatHistory, issueAttachment]),
|
||||
);
|
||||
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: chatHistory.text },
|
||||
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
|
||||
{ type: "text", text: "/hello please" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps Ultracode to xhigh effort and Claude ultracode settings", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -76,7 +76,12 @@ import {
|
||||
} from "./options.js";
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
||||
import {
|
||||
realClaudeRewindSdk,
|
||||
revertClaudeConversation,
|
||||
revertClaudeFiles,
|
||||
type ClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
@@ -405,6 +410,7 @@ interface ClaudeAgentClientOptions {
|
||||
resolveBinary?: () => Promise<string>;
|
||||
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
|
||||
configDir?: string;
|
||||
rewindSdk?: ClaudeRewindSdk;
|
||||
}
|
||||
|
||||
interface ClaudeAgentSessionOptions {
|
||||
@@ -417,6 +423,7 @@ interface ClaudeAgentSessionOptions {
|
||||
logger: Logger;
|
||||
queryFactory?: ClaudeQueryFactory;
|
||||
resolveBinary: () => Promise<string>;
|
||||
rewindSdk?: ClaudeRewindSdk;
|
||||
}
|
||||
|
||||
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
@@ -1500,6 +1507,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
|
||||
private readonly configDir?: string;
|
||||
private readonly rewindSdk: ClaudeRewindSdk;
|
||||
|
||||
constructor(options: ClaudeAgentClientOptions) {
|
||||
this.defaults = options.defaults;
|
||||
@@ -1511,6 +1519,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
options.resolveVersion ??
|
||||
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
|
||||
this.configDir = options.configDir;
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
}
|
||||
|
||||
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||
@@ -1532,6 +1541,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
resolveBinary: this.resolveBinary,
|
||||
rewindSdk: this.rewindSdk,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1560,6 +1570,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
resolveBinary: this.resolveBinary,
|
||||
rewindSdk: this.rewindSdk,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2105,6 +2116,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private userMessageIds: string[] = [];
|
||||
private readonly emittedUserMessageIds = new Set<string>();
|
||||
private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = [];
|
||||
private readonly rewindSdk: ClaudeRewindSdk;
|
||||
private pendingFreshSessionId: string | null = null;
|
||||
private recentStderr = "";
|
||||
private closed = false;
|
||||
@@ -2120,6 +2132,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.logger = options.logger.child({ agentId: this.agentId });
|
||||
this.queryFactory = options.queryFactory;
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
this.contextUsage = new ClaudeContextUsageState(
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
);
|
||||
@@ -2760,7 +2773,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sdk: this.rewindSdk,
|
||||
sessionId: this.claudeSessionId,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
@@ -3041,6 +3054,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
// Subagent frames ride the same stream as the conversation, but their uuids live on the
|
||||
// subagent's sidechain, so forkSession cannot resolve one. Anchoring a turn on one leaves
|
||||
// rewind throwing "Message <uuid> not found in session". The persisted history path already
|
||||
// skips sidechain entries; the live stream has to skip them too.
|
||||
if (readClaudeParentToolUseId(message)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === "user" &&
|
||||
!isSyntheticUserEntry(message) &&
|
||||
@@ -3076,17 +3096,18 @@ class ClaudeAgentSession implements AgentSession {
|
||||
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return { kind: "fresh-session" };
|
||||
// A turn the model never answered — an instant gateway error, or an abort before the first
|
||||
// token — leaves its anchor without an assistant id, and carries no conversation state worth
|
||||
// preserving. Fork at the most recent turn before the target that did answer. Reading only
|
||||
// the immediately preceding anchor let one such turn fail every later rewind in the
|
||||
// conversation.
|
||||
for (let previous = index - 1; previous >= 0; previous -= 1) {
|
||||
const assistantMessageId = this.rewindTurnAnchors[previous]?.assistantMessageId;
|
||||
if (assistantMessageId) {
|
||||
return { kind: "fork", messageId: assistantMessageId };
|
||||
}
|
||||
}
|
||||
|
||||
const previousTurn = this.rewindTurnAnchors[index - 1];
|
||||
if (!previousTurn?.assistantMessageId) {
|
||||
throw new Error(
|
||||
`Claude rewind cannot preserve turn ${index} because its assistant response id was not observed`,
|
||||
);
|
||||
}
|
||||
return { kind: "fork", messageId: previousTurn.assistantMessageId };
|
||||
return { kind: "fresh-session" };
|
||||
}
|
||||
|
||||
private async ensureQuery(): Promise<Query> {
|
||||
@@ -3374,9 +3395,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
> = [];
|
||||
// Claude Code expands a slash command only when it is the last content block of the user
|
||||
// message. buildAgentPrompt places the typed text ahead of images and rendered attachments,
|
||||
// so a "/command" sent with a pasted link or a screenshot would otherwise reach the model as
|
||||
// literal text instead of the command it names.
|
||||
let typedSlashCommandIndex = -1;
|
||||
if (Array.isArray(prompt)) {
|
||||
for (const chunk of prompt) {
|
||||
if (chunk.type === "text") {
|
||||
// Attachments rendered as text always carry a mimeType; the composer's typed text does
|
||||
// not. Only the typed block can be a slash command the user meant to invoke.
|
||||
if (!("mimeType" in chunk) && this.parseSlashCommandInput(chunk.text)) {
|
||||
typedSlashCommandIndex = content.length;
|
||||
}
|
||||
content.push({ type: "text", text: chunk.text });
|
||||
} else if (chunk.type === "image") {
|
||||
if (isImageMimeType(chunk.mimeType)) {
|
||||
@@ -3396,6 +3427,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
} else {
|
||||
content.push({ type: "text", text: prompt });
|
||||
}
|
||||
if (typedSlashCommandIndex >= 0 && typedSlashCommandIndex < content.length - 1) {
|
||||
const [slashCommand] = content.splice(typedSlashCommandIndex, 1);
|
||||
content.push(slashCommand);
|
||||
}
|
||||
|
||||
const messageId = randomUUID();
|
||||
this.rememberUserMessageId(messageId);
|
||||
|
||||
@@ -210,6 +210,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
env: {
|
||||
ANTHROPIC_MODEL: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "ollama/qwen3-coder",
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL: "claude-fable-5-1[1M]",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-from-env",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
|
||||
@@ -244,6 +245,12 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
label: "ollama/qwen3-coder",
|
||||
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5-1[1M]",
|
||||
label: "claude-fable-5-1[1M]",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "bedrock-opus-from-env",
|
||||
@@ -263,6 +270,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
},
|
||||
]);
|
||||
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
|
||||
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to hardcoded models when settings.json is missing", async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
|
||||
@@ -48,6 +48,7 @@ export const ClaudeProviderOptionsSchema = z
|
||||
allowedTools: z.array(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
additionalDirectories: z.array(z.string()).optional(),
|
||||
extraArgs: z.record(z.string(), z.string().nullable()).optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -99,6 +99,8 @@ async function createConnectedSession(
|
||||
describe("Codex app-server provider features", () => {
|
||||
test.each([
|
||||
"gpt-6-astra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
|
||||
@@ -744,6 +744,94 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Default Permissions pins the user as approvals reviewer on thread/start", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const session = createSession(
|
||||
{ modeId: "auto", thinkingOptionId: "medium" },
|
||||
{ autoReviewEnabled: true },
|
||||
);
|
||||
session.currentThreadId = null;
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = {
|
||||
request: vi.fn(async (method: string, params: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "default-mode-thread" } };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}),
|
||||
};
|
||||
|
||||
await session.startTurn("trigger thread creation");
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall?.params).toMatchObject({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
test("switching from auto-review back to Default returns approvals to the user", async () => {
|
||||
const session = createSession({ modeId: "auto-review" }, { autoReviewEnabled: true });
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
|
||||
await session.setMode("auto");
|
||||
await session.startTurn("needs approval");
|
||||
|
||||
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Read-only pins the user as approvals reviewer on thread/start", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const session = createSession(
|
||||
{ modeId: "read-only", thinkingOptionId: "medium" },
|
||||
{ autoReviewEnabled: true },
|
||||
);
|
||||
session.currentThreadId = null;
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = {
|
||||
request: vi.fn(async (method: string, params: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "read-only-thread" } };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}),
|
||||
};
|
||||
|
||||
await session.startTurn("trigger thread creation");
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall?.params).toMatchObject({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "user",
|
||||
});
|
||||
});
|
||||
|
||||
test("setMode and setThinkingOption return a next-turn notice while a turn is active", async () => {
|
||||
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
|
||||
|
||||
@@ -834,6 +922,7 @@ describe("Codex app-server provider", () => {
|
||||
const turnStart = request.mock.calls.find(([method]) => method === "turn/start")?.[1];
|
||||
expect(turnStart).not.toHaveProperty("approvalPolicy");
|
||||
expect(turnStart).not.toHaveProperty("sandboxPolicy");
|
||||
expect(turnStart).not.toHaveProperty("approvalsReviewer");
|
||||
});
|
||||
|
||||
test("carries the complete native workspace-write policy including writable roots", async () => {
|
||||
|
||||
@@ -273,17 +273,19 @@ interface CodexAppServerAgentDeps {
|
||||
interface CodexModePreset {
|
||||
approvalPolicy: string;
|
||||
sandbox: string;
|
||||
approvalsReviewer?: "auto_review";
|
||||
approvalsReviewer: "user" | "auto_review";
|
||||
}
|
||||
|
||||
const MODE_PRESETS: Record<string, CodexModePreset> = {
|
||||
"read-only": {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
auto: {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
"auto-review": {
|
||||
approvalPolicy: "on-request",
|
||||
@@ -293,6 +295,7 @@ const MODE_PRESETS: Record<string, CodexModePreset> = {
|
||||
"full-access": {
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -300,15 +303,6 @@ function isAutoReviewReviewer(value: string | undefined): boolean {
|
||||
return value === "auto_review" || value === "guardian_subagent";
|
||||
}
|
||||
|
||||
function applyApprovalsReviewerParam(
|
||||
params: Record<string, unknown>,
|
||||
preset: CodexModePreset,
|
||||
): void {
|
||||
if (preset.approvalsReviewer) {
|
||||
params.approvalsReviewer = preset.approvalsReviewer;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldPromoteThreadResponseToAutoReview(params: {
|
||||
approvalsReviewer: string | undefined;
|
||||
approvalPolicy: string;
|
||||
@@ -4151,7 +4145,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
: toSandboxPolicy(sandboxPolicyType, workspaceWrite);
|
||||
}
|
||||
if (this.hasWorkflowModeOverride) {
|
||||
applyApprovalsReviewerParam(params, preset);
|
||||
params.approvalsReviewer = preset.approvalsReviewer;
|
||||
}
|
||||
return { approvalPolicy, sandboxPolicyType };
|
||||
}
|
||||
@@ -5205,7 +5199,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
...(this.ephemeral ? { ephemeral: true } : {}),
|
||||
};
|
||||
if (this.hasWorkflowModeOverride) {
|
||||
applyApprovalsReviewerParam(params, preset);
|
||||
params.approvalsReviewer = preset.approvalsReviewer;
|
||||
}
|
||||
return { params, approvalPolicy, sandbox };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
|
||||
// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models.
|
||||
const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([
|
||||
"gpt-6-astra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ACPAgentSession } from "./acp-agent.js";
|
||||
import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js";
|
||||
import type { AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
function captureWarnings(): {
|
||||
logger: ReturnType<typeof createTestLogger>;
|
||||
messages: () => string[];
|
||||
} {
|
||||
const logged: string[] = [];
|
||||
const logger = createTestLogger();
|
||||
const child = {
|
||||
trace: vi.fn(),
|
||||
warn: (_context: unknown, message: string) => logged.push(message),
|
||||
};
|
||||
vi.spyOn(logger, "child").mockReturnValue(
|
||||
child as unknown as ReturnType<typeof createTestLogger>,
|
||||
);
|
||||
return { logger, messages: () => logged };
|
||||
}
|
||||
|
||||
function fastConfigOption(currentValue: "false" | "true"): SessionConfigOption {
|
||||
return {
|
||||
id: "fast",
|
||||
name: "Fast",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: "false", name: "Off" },
|
||||
{ value: "true", name: "Fast" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("CursorACPAgentClient model discovery", () => {
|
||||
function fastConfigOption(currentValue: "false" | "true") {
|
||||
return {
|
||||
id: "fast",
|
||||
name: "Fast",
|
||||
type: "select" as const,
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: "false", name: "Off" },
|
||||
{ value: "true", name: "Fast" },
|
||||
],
|
||||
};
|
||||
}
|
||||
class TestCursorACPAgentClient extends CursorACPAgentClient {
|
||||
constructor(response: SessionStateResponse) {
|
||||
super({
|
||||
@@ -181,3 +201,157 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CursorACPAgentClient session start", () => {
|
||||
// Kimi K3 exposes reasoning but no fast option.
|
||||
const KIMI_K3_CONFIG_OPTIONS: SessionConfigOption[] = [
|
||||
{
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: "max",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "high", name: "High" },
|
||||
{ value: "max", name: "Max" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function createCursorSession(
|
||||
config: Partial<AgentSessionConfig>,
|
||||
session: {
|
||||
currentModelId: string;
|
||||
configOptions: SessionConfigOption[];
|
||||
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>;
|
||||
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||
logger = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
class StubbedCursorSession extends ACPAgentSession {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
models: {
|
||||
currentModelId: session.currentModelId,
|
||||
availableModels: [
|
||||
{ modelId: "composer-2.5", name: "Composer 2.5", description: null },
|
||||
{ modelId: "kimi-k3", name: "Kimi K3", description: null },
|
||||
],
|
||||
},
|
||||
configOptions: session.configOptions,
|
||||
}),
|
||||
// cursor-agent switches the model without returning refreshed config options.
|
||||
unstable_setSessionModel: vi.fn().mockResolvedValue(undefined),
|
||||
setSessionConfigOption:
|
||||
session.setSessionConfigOption ??
|
||||
vi.fn().mockResolvedValue({ configOptions: session.configOptions }),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as SpawnedACPProcess;
|
||||
}
|
||||
}
|
||||
|
||||
return new StubbedCursorSession(
|
||||
{ provider: "acp", cwd: "/tmp/cursor", ...config },
|
||||
{
|
||||
provider: "acp",
|
||||
logger,
|
||||
defaultCommand: ["cursor-agent", "acp"],
|
||||
defaultModes: [],
|
||||
capabilities: { supportsStreaming: true, supportsSessionPersistence: true },
|
||||
configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("starts on a model without a fast variant while Fast is still stored", async () => {
|
||||
// The composer stored Fast while a fast-capable model was selected, and it is
|
||||
// still stored after the draft moved to a model that has no fast variant.
|
||||
const warn = captureWarnings();
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||
warn.logger,
|
||||
);
|
||||
|
||||
await session.initializeNewSession();
|
||||
|
||||
expect(session.id).toBe("session-1");
|
||||
expect(warn.messages()).toContain(
|
||||
"acp cannot apply ACP feature 'fast' to the current model; using the provider default",
|
||||
);
|
||||
});
|
||||
|
||||
test("starts when the CLI rejects a stored feature the switched-to model dropped", async () => {
|
||||
// The CLI keeps the pre-switch config options for the session, so Fast is
|
||||
// still listed after the model moves to one that has no fast variant.
|
||||
const warn = captureWarnings();
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||
setSessionConfigOption: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("Invalid params"), {
|
||||
code: -32602,
|
||||
data: { message: "Unknown model config option: fast" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
warn.logger,
|
||||
);
|
||||
|
||||
await session.initializeNewSession();
|
||||
|
||||
expect(session.id).toBe("session-1");
|
||||
expect(warn.messages()).toContain(
|
||||
"acp cannot apply ACP feature 'fast' to the current model; using the provider default",
|
||||
);
|
||||
});
|
||||
|
||||
test("still fails session start when a stored feature write fails for another reason", async () => {
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||
setSessionConfigOption: vi.fn().mockRejectedValue(new Error("write EPIPE")),
|
||||
},
|
||||
);
|
||||
|
||||
await expect(session.initializeNewSession()).rejects.toThrow("write EPIPE");
|
||||
});
|
||||
|
||||
test("reports the failure when the user turns Fast on for a model without it", async () => {
|
||||
const session = createCursorSession({ model: "kimi-k3" });
|
||||
await session.initializeNewSession();
|
||||
|
||||
await expect(session.setFeature(CURSOR_FAST_FEATURE_OPTION.id, "true")).rejects.toThrow(
|
||||
"acp does not expose ACP feature 'fast'",
|
||||
);
|
||||
});
|
||||
|
||||
test("still fails session start when the provider rejects a write it should accept", async () => {
|
||||
// No model switch, so the session's options came straight from session/new and the
|
||||
// provider disagreeing about a feature it just advertised is a real failure.
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false")],
|
||||
setSessionConfigOption: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("Invalid params"), {
|
||||
code: -32602,
|
||||
data: { message: "sessionId is required" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
await expect(session.initializeNewSession()).rejects.toThrow("Invalid params");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +268,41 @@ describe("JsonlRpcProcess", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("requestStopWork resolves once the child has exited", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(transport.requestStopWork({ type: "abort" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// A dying child's stdin pipe breaks a fraction before its exit event, so deciding at the
|
||||
// moment of the write failure would refuse a stop the runtime did honor.
|
||||
test("requestStopWork resolves when stdin fails just before the child exits", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
const stopped = transport.requestStopWork({ type: "abort" });
|
||||
child.stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
|
||||
await expect(stopped).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// `close()` and a spawn error dispose the transport before the child is known to be
|
||||
// gone, and a child can outlive SIGKILL, so a disposed transport is not proof that the
|
||||
// requested work stopped.
|
||||
test("requestStopWork keeps failing while the transport is disposed but the child has not exited", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
child.emit("error", new Error("spawn failed"));
|
||||
|
||||
await expect(transport.requestStopWork({ type: "abort" })).rejects.toThrow(
|
||||
"JSONL RPC process is closed",
|
||||
);
|
||||
});
|
||||
|
||||
test("stdin error events close the transport instead of becoming uncaught exceptions", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
|
||||
@@ -81,6 +81,8 @@ export class JsonlRpcProcess {
|
||||
private stderrBuffer = "";
|
||||
private nextRequestId = 1;
|
||||
private disposed = false;
|
||||
private exited = false;
|
||||
private closing: Promise<void> | null = null;
|
||||
private readonly frameDecoder: JsonlFrameDecoder;
|
||||
|
||||
constructor(private readonly options: JsonlRpcProcessOptions) {
|
||||
@@ -111,6 +113,7 @@ export class JsonlRpcProcess {
|
||||
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
this.child.on("exit", (code, signal) => {
|
||||
this.exited = true;
|
||||
const error = new Error(
|
||||
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
|
||||
);
|
||||
@@ -175,6 +178,36 @@ export class JsonlRpcProcess {
|
||||
return this.startRequest(command, timeoutMs).promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command whose goal an exited process has already met — stopping a turn,
|
||||
* clearing a queue. Resolves once the child has exited instead of rejecting,
|
||||
* because nothing is queued and nothing is running, which is what the caller
|
||||
* asked for. Use `request` when the caller needs an answer from a live process.
|
||||
*
|
||||
* Keyed on an observed exit rather than on `disposed`: `close()` disposes the
|
||||
* transport before termination completes, and a child that outlives SIGKILL can
|
||||
* still be working, so a disposed transport is not proof the work stopped.
|
||||
*/
|
||||
async requestStopWork(
|
||||
command: { type: string; [key: string]: unknown },
|
||||
timeoutMs?: number | null,
|
||||
): Promise<void> {
|
||||
if (this.exited) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.request(command, timeoutMs);
|
||||
} catch (error) {
|
||||
// A dying child breaks its stdin pipe a fraction before it emits `exit`, which
|
||||
// fails this request and starts a termination. Wait for that termination to reach
|
||||
// an answer rather than refusing a stop the runtime went on to honor.
|
||||
await this.closing?.catch(() => undefined);
|
||||
if (!this.exited) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send(message: Record<string, unknown>): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
@@ -193,6 +226,11 @@ export class JsonlRpcProcess {
|
||||
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.failAll(error);
|
||||
this.closing = this.terminate();
|
||||
await this.closing;
|
||||
}
|
||||
|
||||
private async terminate(): Promise<void> {
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { PaseoToolCatalog } from "../../tools/types.js";
|
||||
import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderParams } from "./provider-config.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
|
||||
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
|
||||
"turn_started",
|
||||
"turn_completed",
|
||||
"turn_failed",
|
||||
"turn_canceled",
|
||||
]);
|
||||
|
||||
function isTurnLifecycle(type: AgentStreamEvent["type"]): boolean {
|
||||
return TURN_LIFECYCLE_EVENTS.has(type);
|
||||
}
|
||||
|
||||
// What OMP reports for a turn the user stopped: an error message on a terminal
|
||||
// response whose stop reason says the request was aborted.
|
||||
const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
provider: "ai-harness-omp",
|
||||
model: "glm-5.3-flash-high",
|
||||
responseId: "chatcmpl-aborted",
|
||||
stopReason: "aborted",
|
||||
errorMessage: "Interrupted by user",
|
||||
};
|
||||
|
||||
test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => {
|
||||
expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({
|
||||
readyTimeoutMs: 20_000,
|
||||
@@ -351,6 +376,25 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("does not complete a turn when a custom message arrives before its user message", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
await omp.requireStartTurn("hello OMP");
|
||||
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptCustomMessage("startup notice");
|
||||
|
||||
expect(omp.completedTurnCount()).toBe(0);
|
||||
|
||||
runtime.acceptPrompt("hello OMP", "user-1");
|
||||
runtime.streamAssistantText("model turn completed");
|
||||
runtime.finishTurn();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("omits live custom messages when display is false", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
@@ -624,6 +668,74 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("an interrupt that OMP reports as an aborted turn cancels instead of failing", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurn("do something long");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
args: { command: "sleep 30" },
|
||||
});
|
||||
runtime.streamAssistantText("working on it");
|
||||
// OMP ends the turn with an aborted terminal response before answering the abort.
|
||||
runtime.onAbort = () => {
|
||||
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
|
||||
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
|
||||
};
|
||||
|
||||
await omp.interrupt();
|
||||
await waitForImmediate();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("an aborted turn that settles after the interrupt cancels exactly once", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurn("do something long");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.streamAssistantText("working on it");
|
||||
// The provider-idle check is still in flight when the abort is acknowledged.
|
||||
const releaseState = runtime.holdStateRequests();
|
||||
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
|
||||
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
|
||||
|
||||
await omp.interrupt();
|
||||
releaseState();
|
||||
await waitForImmediate();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
|
||||
});
|
||||
|
||||
test("an autonomous OMP turn aborted with no client turn id cancels", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.streamAssistantText("autonomous work");
|
||||
runtime.onAbort = () => {
|
||||
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
|
||||
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
|
||||
};
|
||||
|
||||
await omp.interrupt();
|
||||
await waitForImmediate();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
|
||||
});
|
||||
|
||||
test("a resumed session does not re-emit replayed events as live timeline items", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.resume({
|
||||
|
||||
@@ -526,6 +526,11 @@ function latestOmpErrorMessage(messages: OmpAgentMessage[]): string | null {
|
||||
return formatOmpErrorMessage(latestAssistant);
|
||||
}
|
||||
|
||||
function isOmpAbortedTerminalResponse(messages: OmpAgentMessage[]): boolean {
|
||||
const latestAssistant = messages.findLast((message) => message.role === "assistant");
|
||||
return latestAssistant?.stopReason?.toLowerCase() === "aborted";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -2028,9 +2033,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!this.activeTurnHasUserMessage) {
|
||||
this.completeTurn(turnId, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2133,6 +2135,19 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnHasUserMessage = false;
|
||||
this.clearNoTurnBuffers();
|
||||
// OMP reports a stopped turn as a terminal response carrying its interrupt
|
||||
// text as an error. That is the user's own Stop, not a failed turn.
|
||||
if (isOmpAbortedTerminalResponse(messages)) {
|
||||
this.usagePoller.stopTurn();
|
||||
this.terminalizeActiveWork();
|
||||
this.emit({
|
||||
type: "turn_canceled",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
reason: "interrupted",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const errorMessage = latestOmpErrorMessage(messages);
|
||||
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
||||
this.usagePoller.stopTurn();
|
||||
@@ -2161,6 +2176,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
try {
|
||||
const state = await this.runtimeSession.getState();
|
||||
this.state = state;
|
||||
if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) {
|
||||
// An interrupt settled this turn while the state check was in flight.
|
||||
return;
|
||||
}
|
||||
if (!state.isStreaming && !state.isCompacting) {
|
||||
this.completeTurn(turnId, messages);
|
||||
return;
|
||||
|
||||
@@ -66,10 +66,7 @@ function createRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: OmpChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
function onOmpCommand(child: OmpChild, handler: (command: Record<string, unknown>) => void): void {
|
||||
let buffer = "";
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
@@ -78,17 +75,34 @@ function replyToCommands(
|
||||
if (newlineIndex === -1) break;
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const command = JSON.parse(line) as Record<string, unknown>;
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
handler(JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: OmpChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
onOmpCommand(child, (command) => {
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */
|
||||
function exitOnCommand(child: OmpChild, type: string): void {
|
||||
onOmpCommand(child, (command) => {
|
||||
if (command.type === type) {
|
||||
child.emit("exit", 1, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -418,4 +432,24 @@ describe("OMP CLI runtime", () => {
|
||||
expect(result).toHaveLength(2_000);
|
||||
expect(result[0]).toEqual(expect.objectContaining({ id: "model-0" }));
|
||||
});
|
||||
|
||||
// A dead runtime owns no turn, so aborting it is already satisfied. Rejecting here
|
||||
// makes AgentManager treat the interrupt as unacknowledged and refuse the stop, which
|
||||
// pins the agent at `running` until the daemon restarts. See issue #3749.
|
||||
test("abort resolves once the OMP process has exited", async () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(session.abort()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("abort resolves when the OMP process exits while the abort is in flight", async () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
exitOnCommand(child, "abort");
|
||||
|
||||
await expect(session.abort()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user