mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b56cbfe8 | ||
|
|
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 |
@@ -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
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ npm run typecheck
|
||||
|
||||
## スポンサー
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援するか、月 500 ドルで[スポンサー枠を購入](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)して、貴社のロゴをここと [paseo.sh のホームページ](https://paseo.sh/sponsor#spot)に掲載できます。
|
||||
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 -->
|
||||
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ npm run typecheck
|
||||
|
||||
## 스폰서
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원하거나, 월 500달러에 [스폰서 자리를 구매](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)하여 회사 로고를 이곳과 [paseo.sh 홈페이지](https://paseo.sh/sponsor#spot)에 게재할 수 있습니다.
|
||||
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 -->
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ 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), or [sponsor a spot](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00) for $500 a month to put your company's logo here and on the [paseo.sh homepage](https://paseo.sh/sponsor#spot).
|
||||
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 -->
|
||||
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ npm run typecheck
|
||||
|
||||
## 赞助
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作,或者以每月 500 美元[赞助一个展示位](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00),把贵公司的 logo 放在这里和 [paseo.sh 首页](https://paseo.sh/sponsor#spot)上。
|
||||
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 -->
|
||||
|
||||
|
||||
@@ -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
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,8 @@
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"resourceClass": "large"
|
||||
"resourceClass": "large",
|
||||
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,11 +25,4 @@ describe("timelineItemIdentity", () => {
|
||||
).toBe("review/row-1");
|
||||
expect(timelineItemIdentity({ type: "reasoning", text: "hi" })).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies an assistant message by its provider message id", () => {
|
||||
expect(
|
||||
timelineItemIdentity({ type: "assistant_message", text: "hi", messageId: "msg-1" }),
|
||||
).toBe("message:msg-1");
|
||||
expect(timelineItemIdentity({ type: "assistant_message", text: "hi" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,5 @@ import type { AgentTimelineItem } from "./agent-types.js";
|
||||
export function timelineItemIdentity(item: AgentTimelineItem): string | null {
|
||||
if (item.type === "tool_call") return item.callId;
|
||||
if (item.type === "plugin") return `${item.pluginId}/${item.id}`;
|
||||
// Streamed chunks of one provider assistant message share its id, so they
|
||||
// stay one item even when another row is recorded between two chunks.
|
||||
if (item.type === "assistant_message" && item.messageId) return `message:${item.messageId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -897,74 +1028,6 @@ test("orders buffered pre-steer output before an immediately accepted steer", as
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one assistant message whole when a steer lands between its chunks", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-split-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
// Zero window: every streamed chunk flushes on the coalescer's leading edge,
|
||||
// so the timeline reaches its next state without waiting on a timer.
|
||||
agentStreamCoalesceWindowMs: 0,
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const run = manager.streamAgent(agent.id, "initial");
|
||||
void (async () => {
|
||||
for await (const _event of run) {
|
||||
}
|
||||
})();
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
|
||||
const conversation = () =>
|
||||
manager
|
||||
.fetchTimeline(agent.id, { limit: 0 })
|
||||
.rows.filter(
|
||||
(row) => row.item.type === "assistant_message" || row.item.type === "user_message",
|
||||
)
|
||||
.map((row) => row.item);
|
||||
|
||||
session.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "active-turn-1",
|
||||
item: { type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
|
||||
});
|
||||
await expect
|
||||
.poll(conversation)
|
||||
.toMatchObject([
|
||||
{ type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
|
||||
]);
|
||||
await expect(
|
||||
manager.steerAgentRun(agent.id, "keep going", { clientMessageId: "keep-going-client" }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
session.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "active-turn-1",
|
||||
item: { type: "assistant_message", text: "ted changes.", messageId: "msg-1" },
|
||||
});
|
||||
|
||||
await expect.poll(conversation).toMatchObject([
|
||||
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
|
||||
{ type: "user_message", text: "keep going" },
|
||||
]);
|
||||
} finally {
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders a concurrent replacement after a pending accepted steer", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
|
||||
@@ -4025,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") {
|
||||
@@ -4050,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()) {
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -2,27 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
describe("InMemoryAgentTimelineStore", () => {
|
||||
it("keeps a streaming assistant message whole when a steering prompt is recorded mid-stream", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1");
|
||||
store.append("agent-1", {
|
||||
type: "assistant_message",
|
||||
text: "with the uncommit",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
store.append("agent-1", { type: "user_message", text: "keep going" });
|
||||
store.append("agent-1", {
|
||||
type: "assistant_message",
|
||||
text: "ted changes.",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(store.getItems("agent-1")).toEqual([
|
||||
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
|
||||
{ type: "user_message", text: "keep going" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps an overshooting before cursor into the bounded tail window", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1", {
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -785,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" });
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,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();
|
||||
|
||||
@@ -2033,9 +2033,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!this.activeTurnHasUserMessage) {
|
||||
this.completeTurn(turnId, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2385,49 +2385,41 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
|
||||
test("sends exact Hub MCP permission grants without approving unrelated tools", async () => {
|
||||
const promptAsync = vi.fn(async () => ({ data: {}, error: undefined }));
|
||||
const fakeClient = {
|
||||
global: {
|
||||
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
||||
stream: {
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<OpenCodeEvent> {
|
||||
yield { type: "server.connected", properties: {} } as OpenCodeEvent;
|
||||
await waitForAbort(signal);
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
session: { promptAsync },
|
||||
} as never;
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/test",
|
||||
providerOptions: { permission: { bash: "ask", hub_reply: "deny" } },
|
||||
cwd,
|
||||
providerOptions: { permission: { bash: "ask", question: "deny" } },
|
||||
toolPolicy: {
|
||||
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
|
||||
},
|
||||
},
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
|
||||
await session.startTurn("finish");
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
permission: [
|
||||
});
|
||||
try {
|
||||
const created = openCode.calls.sessionCreate[0] as {
|
||||
permission?: Array<{ permission: string; pattern: string; action: string }>;
|
||||
};
|
||||
expect(created.permission).toEqual([
|
||||
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "hub_reply", pattern: "*", action: "deny" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual(
|
||||
expect.objectContaining({ permission: "bash", action: "allow" }),
|
||||
);
|
||||
await session.close();
|
||||
{ permission: "question", pattern: "*", action: "deny" },
|
||||
]);
|
||||
expect(created.permission).not.toContainEqual(
|
||||
expect.objectContaining({ permission: "bash", action: "allow" }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("waits for the stop abort and provider idle before starting the next prompt", async () => {
|
||||
@@ -6733,3 +6725,124 @@ describe("OpenCode snapshot summary false-idle regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode session permission rules", () => {
|
||||
const logger = createTestLogger();
|
||||
function permissionConfig(cwd: string): AgentSessionConfig {
|
||||
return {
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
providerOptions: { permission: { bash: "ask", external_directory: "allow" } },
|
||||
toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }] },
|
||||
};
|
||||
}
|
||||
// OpenCode stores permission rules on the session (POST /session, PATCH /session/{id}) and
|
||||
// drops an unknown `permission` field on prompt_async, so rules sent with a prompt never
|
||||
// reach the server.
|
||||
const expectedRules = [
|
||||
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
];
|
||||
|
||||
test("creates the session with the agent's permission rules", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.createSession(permissionConfig(cwd));
|
||||
try {
|
||||
expect(openCode.calls.sessionCreate[0]).toEqual(
|
||||
expect.objectContaining({ permission: expectedRules }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the agent's permission rules to a resumed session", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "ses_resumed_rules", metadata: { cwd } },
|
||||
permissionConfig(cwd),
|
||||
);
|
||||
try {
|
||||
expect(openCode.calls.sessionUpdate[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionID: "ses_resumed_rules",
|
||||
permission: expectedRules,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fails the resume when the rules cannot be applied", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
openCode.sessionUpdateResponse = { error: { data: { message: "session is archived" } } };
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
// Resuming without the rules would run the agent under a more permissive policy than
|
||||
// the one it was configured with, which is the failure this guards.
|
||||
await expect(
|
||||
client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "ses_archived", metadata: { cwd } },
|
||||
permissionConfig(cwd),
|
||||
),
|
||||
).rejects.toThrow("Failed to apply OpenCode session permission rules");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves a session without rules untouched", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.resumeSession({
|
||||
provider: "opencode",
|
||||
sessionId: "ses_resumed_no_rules",
|
||||
metadata: { cwd },
|
||||
});
|
||||
try {
|
||||
expect(openCode.calls.sessionUpdate).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1452,12 +1452,22 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
// OpenCode stores permission rules on the session, so they are set here and on resume
|
||||
// rather than sent with each prompt, which drops them.
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
openCodeConfig.providerOptions,
|
||||
openCodeConfig.toolPolicy,
|
||||
);
|
||||
|
||||
try {
|
||||
// Creating the first session for a directory is part of OpenCode coming up, so it
|
||||
// shares the server startup budget instead of a shorter one that fails agent
|
||||
// creation on contended cold starts.
|
||||
const response = await withTimeout(
|
||||
client.session.create({ directory: openCodeConfig.cwd }),
|
||||
client.session.create({
|
||||
directory: openCodeConfig.cwd,
|
||||
...(permission ? { permission } : {}),
|
||||
}),
|
||||
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
|
||||
`OpenCode session.create timed out after ${Math.round(
|
||||
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
|
||||
@@ -1527,6 +1537,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
});
|
||||
|
||||
try {
|
||||
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext);
|
||||
|
||||
@@ -1550,6 +1561,23 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async applySessionPermissionRules(
|
||||
client: OpencodeClient,
|
||||
config: OpenCodeAgentConfig,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const permission = buildOpenCodePermissionRules(config.providerOptions, config.toolPolicy);
|
||||
if (!permission) return;
|
||||
const response = readOpenCodeRecord(
|
||||
await client.session.update({ sessionID: sessionId, directory: config.cwd, permission }),
|
||||
);
|
||||
if (response?.error) {
|
||||
throw new Error(
|
||||
`Failed to apply OpenCode session permission rules: ${toDiagnosticErrorMessage(response.error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private acquireServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -3510,10 +3538,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
|
||||
const effectiveVariant = this.config.thinkingOptionId ?? undefined;
|
||||
@@ -3525,7 +3549,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
messageID: promptId,
|
||||
parts,
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
@@ -3850,10 +3873,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -3868,7 +3887,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
: {}),
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
|
||||
@@ -11,6 +11,52 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { OpenCodeBridge } from "./opencode/bridge.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
|
||||
test("real OpenCode server persists provider permissions across creation and resume", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
const logger = createTestLogger();
|
||||
const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager });
|
||||
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
|
||||
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
|
||||
|
||||
try {
|
||||
await mkdir(cwd);
|
||||
session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
providerOptions: { permission: { external_directory: "allow" } },
|
||||
});
|
||||
inspection = await manager.acquireCurrent();
|
||||
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd });
|
||||
async function readPermission(sessionId: string) {
|
||||
const response = await sdk.session.get({ sessionID: sessionId, directory: cwd });
|
||||
if (response.error) throw new Error(JSON.stringify(response.error));
|
||||
return response.data?.permission;
|
||||
}
|
||||
const handle = session.describePersistence()!;
|
||||
expect(await readPermission(handle.sessionId)).toEqual([
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
]);
|
||||
|
||||
await session.close();
|
||||
// OpenCode appends session-update rules and the last matching rule wins, so a resume
|
||||
// with a different policy leaves both entries in order.
|
||||
session = await client.resumeSession(handle, {
|
||||
providerOptions: { permission: { external_directory: "deny" } },
|
||||
});
|
||||
expect(await readPermission(handle.sessionId)).toEqual([
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
{ permission: "external_directory", pattern: "*", action: "deny" },
|
||||
]);
|
||||
} finally {
|
||||
await inspection?.release();
|
||||
await session?.close();
|
||||
await manager.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("real OpenCode server shares one process while shell.env stays session-scoped", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-"));
|
||||
const firstCwd = path.join(root, "first");
|
||||
|
||||
@@ -64,33 +64,6 @@ describe("projectTimelineRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps one assistant message whole when a steering user message lands mid-stream", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-02-13T00:00:00.000Z",
|
||||
item: { type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-02-13T00:00:00.100Z",
|
||||
item: { type: "user_message", text: "keep going" },
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: "2026-02-13T00:00:00.200Z",
|
||||
item: { type: "assistant_message", text: "ted changes.", messageId: "msg-1" },
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
|
||||
expect(projected.map((entry) => entry.item)).toEqual([
|
||||
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
|
||||
{ type: "user_message", text: "keep going" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps adjacent assistant chunks with different message ids separate in projected mode", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
|
||||
@@ -155,17 +155,6 @@ function mergeIdentityEntries(existing: WorkingEntry, entry: WorkingEntry): Work
|
||||
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
|
||||
...mergeIdentityMetadata(existing, entry, "tool_lifecycle"),
|
||||
};
|
||||
case "assistant_message":
|
||||
if (existing.item.type !== "assistant_message" || existing.turnId !== entry.turnId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
item: { ...existing.item, text: `${existing.item.text}${entry.item.text}` },
|
||||
timestamp: entry.timestamp,
|
||||
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
|
||||
...mergeIdentityMetadata(existing, entry, "assistant_merge"),
|
||||
};
|
||||
case "plugin":
|
||||
if (existing.item.type !== "plugin") return null;
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { ensureValidJson } from "../../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
@@ -223,6 +224,20 @@ function assertOptionsAbsent(
|
||||
}
|
||||
}
|
||||
|
||||
async function isExistingDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory();
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget {
|
||||
switch (input.mode ?? "branch-off") {
|
||||
case "branch-off":
|
||||
@@ -1225,7 +1240,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Local directory or source checkout. Defaults to your current workspace."),
|
||||
.describe(
|
||||
"Local directory or source checkout. Defaults to your current workspace. Local isolation adopts an existing directory and never creates one.",
|
||||
),
|
||||
projectId: z.string().optional().describe("Existing project id to own the workspace."),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
mode: z
|
||||
@@ -1277,6 +1294,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
let workspace: PersistedWorkspaceRecord;
|
||||
if (isolation === "local") {
|
||||
const cwd = resolveScopedCwd(path, { required: true });
|
||||
if (!(await isExistingDirectory(cwd))) {
|
||||
throw new Error(`Directory not found: ${cwd}`);
|
||||
}
|
||||
assertOptionsAbsent(
|
||||
[
|
||||
["mode", mode],
|
||||
|
||||
@@ -78,11 +78,15 @@ describe("SessionAuthorization", () => {
|
||||
const authorization = new SessionAuthorization(["hub.execute"]);
|
||||
for (const type of [
|
||||
"create_agent_request",
|
||||
"send_agent_message_request",
|
||||
"workspace.title.set.request",
|
||||
"fetch_agents_request",
|
||||
"fetch_agent_request",
|
||||
"agent.timeline.set_subscription.request",
|
||||
"send_agent_message_request",
|
||||
"workspace.recovery.inspect.request",
|
||||
"workspace.recovery.restore.request",
|
||||
"archive_workspace_request",
|
||||
"cancel_agent_request",
|
||||
] as const) {
|
||||
expect(authorization.allowsInbound(inboundMessage(type))).toBe(true);
|
||||
}
|
||||
@@ -90,8 +94,17 @@ describe("SessionAuthorization", () => {
|
||||
"status",
|
||||
"agent_update",
|
||||
"agent_stream",
|
||||
"workspace_update",
|
||||
"rpc_error",
|
||||
"workspace.title.set.response",
|
||||
"fetch_agents_response",
|
||||
"fetch_agent_response",
|
||||
"agent.timeline.set_subscription.response",
|
||||
"send_agent_message_response",
|
||||
"workspace.recovery.inspect.response",
|
||||
"workspace.recovery.restore.response",
|
||||
"archive_workspace_response",
|
||||
"cancel_agent_response",
|
||||
] as const) {
|
||||
expect(authorization.allowsOutbound(outboundMessage(type))).toBe(true);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.script.list.request": "workspace.read",
|
||||
"workspace.script.start.request": "workspace.write",
|
||||
"workspace.script.stop.request": "workspace.write",
|
||||
"workspace.title.set.request": "workspace.manage",
|
||||
"workspace.title.set.request": ["workspace.manage", "hub.execute"],
|
||||
workspace_setup_status_request: "workspace.read",
|
||||
"workspace.setup.run.request": "workspace.write",
|
||||
write_project_config_request: "workspace.write",
|
||||
@@ -430,7 +430,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.script.list.response": "workspace.read",
|
||||
"workspace.script.start.response": "workspace.write",
|
||||
"workspace.script.stop.response": "workspace.write",
|
||||
"workspace.title.set.response": "workspace.manage",
|
||||
"workspace.title.set.response": ["workspace.manage", "hub.execute"],
|
||||
workspace_setup_progress: "workspace.read",
|
||||
workspace_setup_status_response: "workspace.read",
|
||||
"workspace.setup.run.response": "workspace.write",
|
||||
|
||||
@@ -199,6 +199,7 @@ import {
|
||||
type ManagedProcessRegistry,
|
||||
} from "./managed-processes/managed-processes.js";
|
||||
import { terminateWithTreeKill } from "../utils/tree-kill.js";
|
||||
import { withTimeout } from "../utils/promise-timeout.js";
|
||||
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
||||
import {
|
||||
createRequireBearerMiddleware,
|
||||
@@ -470,6 +471,7 @@ export interface PaseoDaemon {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getListenTarget(): ListenTarget | null;
|
||||
getServerId(): string;
|
||||
}
|
||||
|
||||
export interface PaseoDaemonDependencies {
|
||||
@@ -1777,7 +1779,10 @@ export async function createPaseoDaemon(
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
await pluginRuntime.stopAllPlugins();
|
||||
// Stop tracking plugin provider registrations before anything tears plugins
|
||||
// down, so plugin shutdown cannot withdraw a provider from under an agent
|
||||
// that is still open. Plugins themselves are stopped once every session
|
||||
// they serve has been closed, further down.
|
||||
unsubscribePluginProviders();
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
@@ -1790,6 +1795,7 @@ export async function createPaseoDaemon(
|
||||
detachAgentStoragePersistence();
|
||||
await agentStorage.flush().catch(() => undefined);
|
||||
await agentProviderRuntime.shutdown();
|
||||
await pluginRuntime.stopAllPlugins();
|
||||
terminalManager.killAll();
|
||||
await speechService.stop();
|
||||
await scheduleService.stop().catch(() => undefined);
|
||||
@@ -1826,15 +1832,28 @@ export async function createPaseoDaemon(
|
||||
start,
|
||||
stop,
|
||||
getListenTarget: () => boundListenTarget,
|
||||
getServerId: () => serverId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing an agent asks its provider to close the session and waits for the
|
||||
* answer. A provider that never answers must not hold the daemon open, so a
|
||||
* close that outlives this deadline is abandoned; `agentProviderRuntime`
|
||||
* shutdown runs next and rejects the request that was still pending.
|
||||
*/
|
||||
const AGENT_CLOSE_TIMEOUT_MS = 5_000;
|
||||
|
||||
async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise<void> {
|
||||
const agents = agentManager.listAgents();
|
||||
await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
try {
|
||||
await agentManager.closeAgent(agent.id);
|
||||
await withTimeout({
|
||||
promise: agentManager.closeAgent(agent.id),
|
||||
timeoutMs: AGENT_CLOSE_TIMEOUT_MS,
|
||||
label: `close agent ${agent.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, agentId: agent.id }, "Failed to close agent");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir, uptime } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js";
|
||||
import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js";
|
||||
|
||||
// A process cannot have started before the machine booted, so a lock stamped
|
||||
// before this boot names a PID that some unrelated process now holds.
|
||||
function bootedAt(): number {
|
||||
return Date.now() - uptime() * 1000;
|
||||
}
|
||||
|
||||
async function writeLock(paseoHome: string, lock: PidLockInfo): Promise<void> {
|
||||
await writeFile(join(paseoHome, "paseo.pid"), JSON.stringify(lock));
|
||||
}
|
||||
|
||||
function lockFor(pid: number, startedAt: Date): PidLockInfo {
|
||||
return {
|
||||
pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
hostname: "old-host",
|
||||
uid: process.getuid?.() ?? 0,
|
||||
listen: "127.0.0.1:6767",
|
||||
desktopManaged: true,
|
||||
heartbeat: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("daemon instance identity across a reboot", () => {
|
||||
let paseoHome: string;
|
||||
let bystander: ChildProcess | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
bystander?.kill("SIGKILL");
|
||||
bystander = undefined;
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a lock stamped before this boot has no running owner", async () => {
|
||||
await writeLock(paseoHome, lockFor(process.pid, new Date(bootedAt() - 60 * 60_000)));
|
||||
|
||||
expect(await readDaemonInstance(paseoHome)).toBeNull();
|
||||
expect(await isLocked(paseoHome)).toMatchObject({ locked: false });
|
||||
});
|
||||
|
||||
test("a supervisor started during this boot still holds the lock", async () => {
|
||||
await writeLock(paseoHome, lockFor(process.pid, new Date()));
|
||||
|
||||
expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid });
|
||||
expect(await isLocked(paseoHome)).toMatchObject({ locked: true });
|
||||
});
|
||||
|
||||
test("a new supervisor takes over a lock stamped before this boot", async () => {
|
||||
await writeLock(paseoHome, lockFor(process.pid, new Date(bootedAt() - 60 * 60_000)));
|
||||
|
||||
await acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 });
|
||||
|
||||
expect(await getPidLockInfo(paseoHome)).toMatchObject({ pid: process.pid + 10_000 });
|
||||
});
|
||||
|
||||
test("stopping a lock stamped before this boot leaves the process holding that pid alone", async () => {
|
||||
// Records delivery rather than dying of it, so a signal cannot be missed by arriving late.
|
||||
const signalMarker = join(paseoHome, "bystander-signalled");
|
||||
bystander = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`process.on("SIGTERM", () => require("node:fs").writeFileSync(${JSON.stringify(signalMarker)}, "SIGTERM"));` +
|
||||
`setTimeout(() => {}, 120_000);`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
const bystanderPid = bystander.pid;
|
||||
if (bystanderPid === undefined) throw new Error("bystander process did not start");
|
||||
let exited = false;
|
||||
bystander.once("exit", () => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
await writeLock(paseoHome, lockFor(bystanderPid, new Date(bootedAt() - 60 * 60_000)));
|
||||
|
||||
expect(await stopDaemonInstance(paseoHome)).toMatchObject({ action: "not_running" });
|
||||
|
||||
expect(existsSync(signalMarker)).toBe(false);
|
||||
expect(exited).toBe(false);
|
||||
await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import {
|
||||
getPidLockInfo,
|
||||
isPidRunning,
|
||||
isPidLockOwnerRunning,
|
||||
isSamePidLock,
|
||||
releasePidLock,
|
||||
type PidLockInfo,
|
||||
@@ -44,7 +44,7 @@ export class DaemonInstanceError extends Error {
|
||||
|
||||
export async function readDaemonInstance(home: string): Promise<PidLockInfo | null> {
|
||||
const lock = await getPidLockInfo(home);
|
||||
return lock && isPidRunning(lock.pid) ? lock : null;
|
||||
return lock && isPidLockOwnerRunning(lock) ? lock : null;
|
||||
}
|
||||
|
||||
export function daemonLogPath(home: string): string {
|
||||
@@ -160,7 +160,7 @@ export async function stopDaemonInstance(
|
||||
`Supervisor changed for ${home}; refusing to stop PID ${instance.pid}.`,
|
||||
);
|
||||
}
|
||||
if (!instance || !isPidRunning(instance.pid)) {
|
||||
if (!instance || !isPidLockOwnerRunning(instance)) {
|
||||
if (instance)
|
||||
await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt });
|
||||
return {
|
||||
@@ -175,7 +175,7 @@ export async function stopDaemonInstance(
|
||||
let { forced, usedLifecycleRpc } = await requestInstanceStop(home, instance, options);
|
||||
const waitForExit = async (waitMs: number) => {
|
||||
const exitDeadline = Date.now() + waitMs;
|
||||
while (isPidRunning(instance.pid)) {
|
||||
while (isPidLockOwnerRunning(instance)) {
|
||||
const current = await getPidLockInfo(home);
|
||||
if (current && !isSamePidLock(instance, current))
|
||||
throw new DaemonInstanceError(
|
||||
|
||||
@@ -17,6 +17,7 @@ type SupervisorLifecycleMessage =
|
||||
| {
|
||||
type: "paseo:ready";
|
||||
listen: string;
|
||||
serverId: string;
|
||||
}
|
||||
| {
|
||||
type: "paseo:restart";
|
||||
@@ -327,7 +328,11 @@ async function main() {
|
||||
if (!listen) {
|
||||
throw new Error("Daemon did not expose a listen target after startup");
|
||||
}
|
||||
sendSupervisorLifecycleMessage({ type: "paseo:ready", listen });
|
||||
sendSupervisorLifecycleMessage({
|
||||
type: "paseo:ready",
|
||||
listen,
|
||||
serverId: daemon.getServerId(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.fatal({ err }, "Daemon failed to start listening");
|
||||
throw err;
|
||||
|
||||
@@ -320,6 +320,7 @@ test("closing the observer service releases every subscription and rejects reuse
|
||||
nativeTrackedFileCount: 0,
|
||||
pendingEventCount: 0,
|
||||
pendingReconciliationWorkCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
reconciliationCount: 0,
|
||||
scopedReconciliationCount: 0,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface FileObserverDiagnostics {
|
||||
nativeTrackedFileCount: number;
|
||||
pendingEventCount: number;
|
||||
pendingReconciliationWorkCount: number;
|
||||
pendingClassificationCount: number;
|
||||
reconciliationInFlightCount: number;
|
||||
reconciliationCount: number;
|
||||
scopedReconciliationCount: number;
|
||||
@@ -120,6 +121,7 @@ class FileObserverService implements FileObserver {
|
||||
let nativeTrackedFileCount = 0;
|
||||
let pendingEventCount = 0;
|
||||
let pendingReconciliationWorkCount = 0;
|
||||
let pendingClassificationCount = 0;
|
||||
let reconciliationInFlightCount = 0;
|
||||
for (const observation of observations) {
|
||||
const diagnostics = observation.getDiagnostics();
|
||||
@@ -127,6 +129,7 @@ class FileObserverService implements FileObserver {
|
||||
nativeTrackedFileCount += diagnostics.nativeTrackedFileCount;
|
||||
pendingEventCount += diagnostics.pendingEventCount;
|
||||
pendingReconciliationWorkCount += diagnostics.pendingReconciliationWorkCount;
|
||||
pendingClassificationCount += diagnostics.pendingClassificationCount;
|
||||
reconciliationInFlightCount += Number(diagnostics.reconciliationInFlight);
|
||||
}
|
||||
return {
|
||||
@@ -135,6 +138,7 @@ class FileObserverService implements FileObserver {
|
||||
nativeTrackedFileCount,
|
||||
pendingEventCount,
|
||||
pendingReconciliationWorkCount,
|
||||
pendingClassificationCount,
|
||||
reconciliationInFlightCount,
|
||||
...metrics,
|
||||
};
|
||||
@@ -178,6 +182,7 @@ function emptyDiagnostics(): FileObserverDiagnostics {
|
||||
nativeTrackedFileCount: 0,
|
||||
pendingEventCount: 0,
|
||||
pendingReconciliationWorkCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
...createObserverMetrics(),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ObserverMetrics extends Omit<
|
||||
| "nativeTrackedFileCount"
|
||||
| "pendingEventCount"
|
||||
| "pendingReconciliationWorkCount"
|
||||
| "pendingClassificationCount"
|
||||
| "reconciliationInFlightCount"
|
||||
> {}
|
||||
|
||||
@@ -14,6 +15,7 @@ export interface BackendDiagnostics {
|
||||
nativeHandleCount: number;
|
||||
nativeTrackedFileCount: number;
|
||||
pendingReconciliationWorkCount: number;
|
||||
pendingClassificationCount: number;
|
||||
reconciliationInFlight: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { createFileObserver } from "../index.js";
|
||||
import { createLinuxBackend } from "./linux.js";
|
||||
import { createObserverPaths } from "./paths.js";
|
||||
|
||||
// This backend keeps no file-level inventory and runs no periodic full-tree
|
||||
// safety audit (see the comment on MAX_QUEUED_CLASSIFICATIONS in linux.ts), so
|
||||
// a shed classification could never be recovered later the way the native
|
||||
// backend's directory audit recovers one. Overflow must fail the observation
|
||||
// loudly instead of silently dropping a create or delete.
|
||||
test("the linux backend fails the observation when the classification queue overflows", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "linux-classify-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
let failure: Error | null = null;
|
||||
const backend = createLinuxBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: () => {},
|
||||
fail: (error) => {
|
||||
// Mirror the real Observation host: a failure ends the subscription,
|
||||
// so isActive() must flip immediately and further events must not be
|
||||
// processed.
|
||||
active = false;
|
||||
failure = error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_directory, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event: string, onError: (error: Error) => void) => notifications.on(event, onError),
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
// Emitted synchronously: no stat can settle before the last event lands,
|
||||
// so the queue grows monotonically until it overflows. Once it does,
|
||||
// isActive() flips false and the backend's own listener guard skips the
|
||||
// rest of the burst, so it's safe to keep emitting unconditionally.
|
||||
for (let index = 0; index < 20_000; index += 1) {
|
||||
notifications.emit("change", "rename", `module-${index}.js`);
|
||||
}
|
||||
expect(active).toBe(false);
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure?.message).toMatch(/exceeded \d+ queued classifications/);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an ignore update drains classifications queued below the new excluded root", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "linux-ignore-queue-"));
|
||||
const ignored = join(root, "generated");
|
||||
await mkdir(ignored);
|
||||
const tracked = join(root, "tracked.txt");
|
||||
await writeFile(tracked, "tracked");
|
||||
// The backend is injected for deterministic queue admission, but its real
|
||||
// temporary root uses the host filesystem's path format.
|
||||
const paths = createObserverPaths(process.platform);
|
||||
let onRootChange: ((eventType: string, filename: string | null) => void) | null = null;
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
let ignoreGenerated = false;
|
||||
const delivered: string[] = [];
|
||||
const backend = createLinuxBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: (path) => ignoreGenerated && paths.isInside(ignored, path),
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => {
|
||||
if (type === "create") delivered.push(path);
|
||||
},
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(directory, listener) => {
|
||||
if (directory === root) onRootChange = listener;
|
||||
return {
|
||||
close: () => {},
|
||||
on: () => {},
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
delivered.length = 0;
|
||||
for (let index = 0; index < 6_000; index += 1) {
|
||||
onRootChange?.("rename", `generated/file-${index}.js`);
|
||||
}
|
||||
onRootChange?.("rename", "tracked.txt");
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeGreaterThan(0);
|
||||
ignoreGenerated = true;
|
||||
await backend.updateIgnore();
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(1);
|
||||
await expect.poll(() => delivered.includes(tracked)).toBe(true);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -7,22 +7,46 @@ import { isMissingPathError, toError } from "./paths.js";
|
||||
|
||||
const MAX_WATCHED_DIRECTORIES = 5_000;
|
||||
const RECONCILIATION_DELAY_MS = 50;
|
||||
// fs.stat runs on the libuv threadpool, which is four threads and shared by the
|
||||
// whole daemon. One stat per native event pins it and unrelated filesystem work
|
||||
// stalls. The concurrency limit bounds that.
|
||||
//
|
||||
// Unlike the native backend, this backend keeps no file-level inventory
|
||||
// (nativeTrackedFileCount is always 0, and its own reconcile() diffs
|
||||
// directories, never files) and runs no periodic full-tree safety audit. A
|
||||
// classification shed here can never be recovered later, so overflow must
|
||||
// fail the observation loudly instead of silently dropping a create or
|
||||
// delete. WorkspaceGit already handles this failure mode: it catches the
|
||||
// existing MAX_WATCHED_DIRECTORIES overflow and falls back to bounded
|
||||
// polling. The cap is generous — it should only trip under genuine
|
||||
// pathology, not an ordinary large burst.
|
||||
const MAX_CONCURRENT_CLASSIFICATIONS = 32;
|
||||
const MAX_QUEUED_CLASSIFICATIONS = 16_384;
|
||||
|
||||
interface DirectorySnapshot {
|
||||
directories: Set<string>;
|
||||
filesDiscoveredBeforeCoverage: Set<string>;
|
||||
}
|
||||
|
||||
type WatchDirectory = (
|
||||
directory: string,
|
||||
listener: (eventType: string, filename: string | null) => void,
|
||||
) => FSWatcher;
|
||||
|
||||
const watchDirectory: WatchDirectory = (directory, listener) => watch(directory, listener);
|
||||
|
||||
export function createLinuxBackend(
|
||||
host: ObservationHost,
|
||||
paths: ObserverPaths,
|
||||
observe: WatchDirectory = watchDirectory,
|
||||
): ObservationBackend {
|
||||
return new LinuxBackend(host, paths);
|
||||
return new LinuxBackend(host, paths, observe);
|
||||
}
|
||||
|
||||
class LinuxBackend implements ObservationBackend {
|
||||
private readonly watchers = new Map<string, FSWatcher>();
|
||||
private readonly classifications = new Set<Promise<void>>();
|
||||
private readonly classificationQueue: Array<{ path: string }> = [];
|
||||
private readonly pendingScopes = new Set<string>();
|
||||
private reconcileTail: Promise<void> = Promise.resolve();
|
||||
private queueDepth = 0;
|
||||
@@ -33,6 +57,7 @@ class LinuxBackend implements ObservationBackend {
|
||||
constructor(
|
||||
private readonly host: ObservationHost,
|
||||
private readonly paths: ObserverPaths,
|
||||
private readonly observe: WatchDirectory,
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -41,6 +66,8 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
async updateIgnore(): Promise<void> {
|
||||
this.discardIgnoredClassifications();
|
||||
const inFlight = [...this.classifications];
|
||||
for (const [directory, watcher] of this.watchers) {
|
||||
if (directory !== this.host.root && this.host.isIgnored(directory)) {
|
||||
watcher.close();
|
||||
@@ -49,6 +76,7 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
this.cancelQueued();
|
||||
await this.enqueue([this.host.root]);
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
@@ -67,14 +95,17 @@ class LinuxBackend implements ObservationBackend {
|
||||
pendingReconciliationWorkCount:
|
||||
this.pendingScopes.size +
|
||||
this.classifications.size +
|
||||
this.classificationQueue.length +
|
||||
this.queueDepth +
|
||||
Number(this.timer !== null),
|
||||
pendingClassificationCount: this.classifications.size + this.classificationQueue.length,
|
||||
reconciliationInFlight: this.inFlight,
|
||||
};
|
||||
}
|
||||
|
||||
private async finishClose(): Promise<void> {
|
||||
await this.reconcileTail;
|
||||
this.classificationQueue.length = 0;
|
||||
await Promise.allSettled(this.classifications);
|
||||
for (const watcher of this.watchers.values()) watcher.close();
|
||||
this.watchers.clear();
|
||||
@@ -90,7 +121,7 @@ class LinuxBackend implements ObservationBackend {
|
||||
`Recursive file observation exceeded ${MAX_WATCHED_DIRECTORIES} directories under ${this.host.root}`,
|
||||
);
|
||||
}
|
||||
const watcher = watch(directory, (eventType, filename) => {
|
||||
const watcher = this.observe(directory, (eventType, filename) => {
|
||||
if (!this.host.isActive()) return;
|
||||
const path = filename ? resolve(directory, filename.toString()) : directory;
|
||||
if (!this.host.isIgnored(path)) {
|
||||
@@ -247,6 +278,45 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
private classify(path: string): void {
|
||||
if (this.classificationQueue.length >= MAX_QUEUED_CLASSIFICATIONS) {
|
||||
// This backend has no file-level inventory and no full-tree safety
|
||||
// audit to fall back on (see the comment on MAX_QUEUED_CLASSIFICATIONS
|
||||
// above), so a shed here would silently and permanently lose this
|
||||
// create/delete. Fail loudly instead, the same degradation path as
|
||||
// MAX_WATCHED_DIRECTORIES overflow.
|
||||
this.host.fail(
|
||||
new Error(
|
||||
`Recursive file observation exceeded ${MAX_QUEUED_CLASSIFICATIONS} queued classifications under ${this.host.root}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.classificationQueue.push({ path });
|
||||
this.pumpClassifications();
|
||||
}
|
||||
|
||||
private discardIgnoredClassifications(): void {
|
||||
let kept = 0;
|
||||
for (const classification of this.classificationQueue) {
|
||||
if (!this.host.isIgnored(classification.path)) {
|
||||
this.classificationQueue[kept++] = classification;
|
||||
}
|
||||
}
|
||||
this.classificationQueue.length = kept;
|
||||
}
|
||||
|
||||
private pumpClassifications(): void {
|
||||
while (
|
||||
this.classifications.size < MAX_CONCURRENT_CLASSIFICATIONS &&
|
||||
this.classificationQueue.length > 0
|
||||
) {
|
||||
const next = this.classificationQueue.shift();
|
||||
if (!next) return;
|
||||
this.startClassification(next.path);
|
||||
}
|
||||
}
|
||||
|
||||
private startClassification(path: string): void {
|
||||
this.host.metrics.nativeClassificationCount += 1;
|
||||
let classification!: Promise<void>;
|
||||
classification = stat(path)
|
||||
@@ -258,7 +328,10 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
this.host.fail(toError(error));
|
||||
})
|
||||
.finally(() => this.classifications.delete(classification));
|
||||
.finally(() => {
|
||||
this.classifications.delete(classification);
|
||||
if (this.host.isActive()) this.pumpClassifications();
|
||||
});
|
||||
this.classifications.add(classification);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -129,3 +130,360 @@ test("a file announced only as changed remains visible to coalesced deletion sca
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a native ignore update drains classifications queued below the new excluded root", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-ignore-queue-"));
|
||||
const ignored = join(root, "generated");
|
||||
await mkdir(ignored);
|
||||
const tracked = join(root, "tracked.txt");
|
||||
await writeFile(tracked, "tracked");
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
let ignoreGenerated = false;
|
||||
const delivered: string[] = [];
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: (path) => ignoreGenerated && paths.isInside(ignored, path),
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => {
|
||||
if (type === "create") delivered.push(path);
|
||||
},
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
notifications.emit("change", "rename", join(ignored, `file-${index}.js`));
|
||||
}
|
||||
notifications.emit("change", "rename", tracked);
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeGreaterThan(0);
|
||||
ignoreGenerated = true;
|
||||
await backend.updateIgnore();
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(1);
|
||||
await expect.poll(() => delivered.includes(tracked)).toBe(true);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// One fs.stat per native event is how a single dependency install pins the
|
||||
// libuv threadpool and the daemon stops answering.
|
||||
test("a rename burst does not spawn one stat per event", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-classify-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: () => {},
|
||||
fail: () => {},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
// Emitted synchronously: no stat can settle before the last event lands.
|
||||
for (let index = 0; index < 5_000; index += 1) {
|
||||
notifications.emit("change", "rename", join(root, `package-${index}`, "index.js"));
|
||||
}
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(2_080);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The shed path is only safe because this backend has a real files/entries
|
||||
// inventory to diff against. Prove it does the work: force a shed onto an
|
||||
// already-tracked directory and confirm the create still surfaces.
|
||||
test("a classification shed onto the scoped audit still recovers the file", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-shed-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const shedDirectory = join(root, "shed");
|
||||
await mkdir(shedDirectory);
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start(); // The initial full audit tracks "shed" as known.
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
|
||||
// Jam the queue exactly as in the burst test above: 32 in flight, 2,048
|
||||
// queued, all against a directory that never existed.
|
||||
const fillerDirectory = join(root, "filler");
|
||||
for (let index = 0; index < 2_048 + 32; index += 1) {
|
||||
notifications.emit("change", "rename", join(fillerDirectory, `file-${index}.js`));
|
||||
}
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBe(2_080);
|
||||
|
||||
// A real file lands in the already-tracked "shed" directory while the
|
||||
// queue is full, so this event must be shed rather than stat-ed. Written
|
||||
// synchronously so no queued stat can settle and free a slot before this
|
||||
// event is classified -- that would let it enqueue normally instead of
|
||||
// exercising the shed branch.
|
||||
const shedPath = join(shedDirectory, "recovered.txt");
|
||||
writeFileSync(shedPath, "content");
|
||||
notifications.emit("change", "rename", shedPath);
|
||||
|
||||
// 2s clears the mandatory-audit deadline (500ms quiet / 5s max-dirty)
|
||||
// that the shed forces by adding "shed" to localScopes, but stays well
|
||||
// under the optional-only deadline (8s) that the ambient per-event
|
||||
// change-scope audit alone would use. Every event -- shed or not --
|
||||
// already schedules that ambient audit, so advancing 8s would recover
|
||||
// this file even if the shed branch were a no-op. The short window is
|
||||
// what actually proves the shed's own requestAudit call did the work.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect
|
||||
.poll(() => events.some((event) => event.type === "create" && event.path === shedPath))
|
||||
.toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// C1 regression: removeSubtree unlinks its root from the parent's
|
||||
// DirectoryEntry as part of tearing down the old subtree. reconcileSubtree
|
||||
// re-populates the index below `directory` via mergeInventory but must also
|
||||
// re-link `directory` itself back into its parent -- walkSubtree only
|
||||
// descends through that link, so without the re-link a later ancestor
|
||||
// removal can no longer reach anything below the reconciled directory.
|
||||
test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const deepDirectory = join(root, "a", "b", "c");
|
||||
const deepFile = join(deepDirectory, "deep.txt");
|
||||
await mkdir(deepDirectory, { recursive: true });
|
||||
await writeFile(deepFile, "content");
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
const settled = () =>
|
||||
expect
|
||||
.poll(
|
||||
() => {
|
||||
const diagnostics = backend.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight
|
||||
);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
try {
|
||||
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
|
||||
|
||||
// A rename event landing on the already-known directory a/b produces a
|
||||
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
|
||||
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
|
||||
// still a directory, a recursive scope on a/b itself -- the same "known
|
||||
// directory changes in place" shape the directory-churn benchmark
|
||||
// exercises via atomic replace.
|
||||
notifications.emit("change", "rename", join(root, "a", "b"));
|
||||
await settled();
|
||||
|
||||
// Remove the ancestor while the native event names only a deleted file.
|
||||
// The scoped scan must report its missing directory as well as that file.
|
||||
await rm(join(root, "a"), { recursive: true, force: true });
|
||||
notifications.emit("change", "rename", deepFile);
|
||||
await settled();
|
||||
|
||||
expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain(
|
||||
deepFile,
|
||||
);
|
||||
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test("a full native inventory audit reports a removed directory", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-full-audit-"));
|
||||
const directory = join(root, "removed");
|
||||
await mkdir(directory);
|
||||
await writeFile(join(directory, "tracked.txt"), "before");
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const events: FileChange[] = [];
|
||||
const observer = createFileObserver();
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => true,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
() => ({ close: () => {}, on: () => {} }),
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await backend.updateIgnore(); // Uses the same full inventory diff as the safety audit.
|
||||
expect(events).toContainEqual({ type: "delete", path: directory });
|
||||
} finally {
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// I2 regression: startClassification queues an unconditional "create" event
|
||||
// before onPresent runs. When a file's parent directory has no entries
|
||||
// bucket yet (a coalesced directory-creation event that never arrived),
|
||||
// declining to track the path at all announces a create it can never
|
||||
// retract -- no future scan can rediscover a path that is already gone from
|
||||
// disk to emit its matching delete.
|
||||
test("a file classified with no parent bucket yet still gets its delete emitted", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-orphan-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const newDirectory = join(root, "newdir");
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start(); // Tracks the (empty) root only.
|
||||
|
||||
// A brand-new directory with no directory-created event processed for
|
||||
// it yet (coalesced away) has no entries bucket: entries.get(scope) is
|
||||
// undefined for a file inside it.
|
||||
await mkdir(newDirectory);
|
||||
const filePath = join(newDirectory, "orphan.txt");
|
||||
await writeFile(filePath, "content");
|
||||
notifications.emit("change", "rename", filePath);
|
||||
|
||||
// The file must stay tracked so a later removal can still find and
|
||||
// retract the create that startClassification already queued.
|
||||
await expect.poll(() => backend.getDiagnostics().nativeTrackedFileCount).toBe(1);
|
||||
expect(events.some((event) => event.type === "create" && event.path === filePath)).toBe(true);
|
||||
|
||||
// Remove the whole directory before any audit ever scans it. Nothing
|
||||
// will see filePath as "on disk and gone from a known listing" through
|
||||
// the normal directory-scan diff, since newdir never had one.
|
||||
await rm(newDirectory, { recursive: true, force: true });
|
||||
|
||||
await expect
|
||||
.poll(() => events.some((event) => event.type === "delete" && event.path === filePath), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true);
|
||||
expect(backend.getDiagnostics().nativeTrackedFileCount).toBe(0);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@ import type { ObserverPaths } from "./paths.js";
|
||||
import { isMissingPathError, toError } from "./paths.js";
|
||||
|
||||
const MAX_TRACKED_ENTRIES = 250_000;
|
||||
// fs.stat runs on the libuv threadpool, which is four threads and shared by the
|
||||
// whole daemon. One stat per native event pins it and unrelated filesystem work
|
||||
// stalls. Beyond the queue bound, fall back to a directory scan, which finds the
|
||||
// same paths at a fraction of the cost.
|
||||
const MAX_CONCURRENT_CLASSIFICATIONS = 32;
|
||||
const MAX_QUEUED_CLASSIFICATIONS = 2_048;
|
||||
const AUDIT_QUIET_MS = 500;
|
||||
const AUDIT_MAX_DIRTY_MS = 5_000;
|
||||
const OPTIONAL_AUDIT_QUIET_MS = 8_000;
|
||||
@@ -47,9 +53,20 @@ export function createNativeRecursiveBackend(
|
||||
class NativeRecursiveBackend implements ObservationBackend {
|
||||
private watcher: ReturnType<WatchDirectory> | null = null;
|
||||
private files = new Set<string>();
|
||||
// Files classified before their parent directory has an entries bucket.
|
||||
// walkSubtree can only reach files through that bucket, so these stay
|
||||
// tracked directly until a scoped audit builds the bucket and adopts them.
|
||||
// Bounded by every file classified within one full-audit window (up to
|
||||
// FULL_AUDIT_MAX_DIRTY_MS, 5 minutes) whose parent never got a bucket, not
|
||||
// by the size of the tracked set.
|
||||
private orphanFiles = new Set<string>();
|
||||
private directories = new Set<string>();
|
||||
private entries = new Map<string, DirectoryEntry>();
|
||||
private readonly classifications = new Set<Promise<void>>();
|
||||
private readonly classificationQueue: Array<{
|
||||
path: string;
|
||||
onPresent: (isDirectory: boolean) => void;
|
||||
}> = [];
|
||||
private readonly localScopes = new Set<string>();
|
||||
private readonly changeScopes = new Set<string>();
|
||||
private readonly recursiveScopes = new Set<string>();
|
||||
@@ -85,11 +102,14 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
async updateIgnore(): Promise<void> {
|
||||
this.discardIgnoredClassifications();
|
||||
const inFlight = [...this.classifications];
|
||||
this.generation += 1;
|
||||
this.cancelAudits();
|
||||
this.fullAuditRequested = true;
|
||||
this.auditQueued = true;
|
||||
await this.enqueueAudit(true, this.generation);
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
@@ -110,18 +130,22 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
this.changeScopes.size +
|
||||
this.recursiveScopes.size +
|
||||
this.classifications.size +
|
||||
this.classificationQueue.length +
|
||||
this.queueDepth +
|
||||
Number(this.auditTimer !== null || this.auditQueued || this.auditDirty),
|
||||
pendingClassificationCount: this.classifications.size + this.classificationQueue.length,
|
||||
reconciliationInFlight: this.inFlight,
|
||||
};
|
||||
}
|
||||
|
||||
private async finishClose(): Promise<void> {
|
||||
await this.reconcileTail;
|
||||
this.classificationQueue.length = 0;
|
||||
await Promise.allSettled(this.classifications);
|
||||
this.watcher?.close();
|
||||
this.watcher = null;
|
||||
this.files.clear();
|
||||
this.orphanFiles.clear();
|
||||
this.directories.clear();
|
||||
this.entries.clear();
|
||||
}
|
||||
@@ -154,10 +178,21 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
this.classify(path, (isDirectory) => {
|
||||
if (!isDirectory) {
|
||||
// Remember files as soon as we announce them. A coalesced delete must
|
||||
// still be found by the next audit, even before the first directory scan.
|
||||
this.files.add(path);
|
||||
this.entries.get(scope)?.files.add(path);
|
||||
const entry = this.entries.get(scope);
|
||||
if (entry) {
|
||||
// Remember files as soon as we announce them. A coalesced delete must
|
||||
// still be found by the next audit, even before the first directory scan.
|
||||
this.files.add(path);
|
||||
entry.files.add(path);
|
||||
} else {
|
||||
// No inventory for the parent yet. startClassification already
|
||||
// announced this as a create, so it must stay findable for a
|
||||
// matching delete: track it directly until a scoped audit builds
|
||||
// the bucket and adopts it (see orphanFiles above).
|
||||
this.files.add(path);
|
||||
this.orphanFiles.add(path);
|
||||
this.requestAudit(scope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (knownDirectory) this.requestAudit(path, true);
|
||||
@@ -203,7 +238,10 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (fullAudit) {
|
||||
const next = await this.scanTree(this.host.root);
|
||||
if (!this.canCommit(generation)) return;
|
||||
if (emitDiff) this.queueDiff(next.files, this.files);
|
||||
if (emitDiff) {
|
||||
this.queueDiff(next.files, this.files);
|
||||
this.queueRemovedDirectories(next.directories, this.directories);
|
||||
}
|
||||
this.replaceInventory(next);
|
||||
this.lastFullAuditAt = performance.now();
|
||||
this.host.metrics.fullReconciliationCount += 1;
|
||||
@@ -278,10 +316,12 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
for (const directory of new Set(localScopes)) {
|
||||
await this.reconcileDirectory(directory, generation);
|
||||
if (!this.canCommit(generation)) return;
|
||||
await new Promise<void>((done) => setImmediate(done));
|
||||
}
|
||||
for (const directory of new Set(recursiveScopes)) {
|
||||
await this.reconcileSubtree(directory, generation);
|
||||
if (!this.canCommit(generation)) return;
|
||||
await new Promise<void>((done) => setImmediate(done));
|
||||
}
|
||||
// Parent scans are shallow, so a queued parent cannot cover its children.
|
||||
for (const directory of new Set(changeScopes)) {
|
||||
@@ -291,6 +331,7 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (alreadyCovered) continue;
|
||||
await this.reconcileDirectory(directory, generation);
|
||||
if (!this.canCommit(generation)) return;
|
||||
await new Promise<void>((done) => setImmediate(done));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +364,7 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (!next.directories.has(path)) this.removeSubtree(path, true);
|
||||
}
|
||||
for (const path of next.files) {
|
||||
this.orphanFiles.delete(path);
|
||||
if (!previous.files.has(path)) {
|
||||
this.files.add(path);
|
||||
this.host.queueEvent("create", path);
|
||||
@@ -377,11 +419,23 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (!this.directories.has(directory) || this.host.isIgnored(directory)) return;
|
||||
const inventory = await this.scanTree(directory);
|
||||
if (!this.canCommit(generation)) return;
|
||||
const previousFiles = new Set(
|
||||
[...this.files].filter((path) => this.host.isPathInside(directory, path)),
|
||||
);
|
||||
const previousFiles = new Set<string>();
|
||||
for (const tracked of this.walkSubtree(directory)) {
|
||||
const entry = this.entries.get(tracked);
|
||||
if (!entry) continue;
|
||||
for (const file of entry.files) previousFiles.add(file);
|
||||
}
|
||||
this.queueDiff(inventory.files, previousFiles);
|
||||
this.queueRemovedDirectories(inventory.directories, this.walkSubtree(directory));
|
||||
this.removeSubtree(directory, false);
|
||||
// removeSubtree unlinks `directory` from its own parent's directories set
|
||||
// as part of tearing down the old subtree. Mirror reconcileUnknownSubtree
|
||||
// and re-link it, guarded on the directory still existing -- it can
|
||||
// vanish between the `this.directories.has` check above and this scan --
|
||||
// or walkSubtree can no longer reach anything under it from the root.
|
||||
if (inventory.directories.has(directory)) {
|
||||
this.entries.get(dirname(directory))?.directories.add(directory);
|
||||
}
|
||||
this.mergeInventory(inventory, false);
|
||||
}
|
||||
|
||||
@@ -394,8 +448,16 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
}
|
||||
|
||||
private queueRemovedDirectories(next: Set<string>, previous: Iterable<string>): void {
|
||||
for (const directory of previous) {
|
||||
if (!next.has(directory)) this.host.queueEvent("delete", directory);
|
||||
}
|
||||
}
|
||||
|
||||
private replaceInventory(inventory: Inventory): void {
|
||||
this.files = inventory.files;
|
||||
// A full scan gives every existing file a directory-index home.
|
||||
this.orphanFiles.clear();
|
||||
this.directories = inventory.directories;
|
||||
this.entries = inventory.entries;
|
||||
}
|
||||
@@ -404,6 +466,7 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
for (const directory of inventory.directories) this.directories.add(directory);
|
||||
for (const [directory, entry] of inventory.entries) this.entries.set(directory, entry);
|
||||
for (const path of inventory.files) {
|
||||
this.orphanFiles.delete(path);
|
||||
const added = !this.files.has(path);
|
||||
this.files.add(path);
|
||||
if (emitCreates && added) this.host.queueEvent("create", path);
|
||||
@@ -411,17 +474,50 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
this.assertWithinLimit(this.files.size + this.directories.size);
|
||||
}
|
||||
|
||||
// Depends on the invariant that every tracked directory has an `entries`
|
||||
// bucket, and that `this.directories` and `this.entries` are written and
|
||||
// cleared together. A root with no bucket yields nothing here, which makes
|
||||
// `removeSubtree` a near no-op for that root — it deletes nothing from
|
||||
// `entries`/`directories` and only cleans up matching `orphanFiles` — so
|
||||
// nothing enforces the invariant beyond this comment.
|
||||
private *walkSubtree(root: string): Generator<string> {
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const directory = stack.pop();
|
||||
if (directory === undefined) continue;
|
||||
const entry = this.entries.get(directory);
|
||||
if (!entry) continue;
|
||||
yield directory;
|
||||
for (const child of entry.directories) stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
private removeSubtree(root: string, emitDeletes: boolean): void {
|
||||
for (const path of this.files) {
|
||||
if (!this.host.isPathInside(root, path)) continue;
|
||||
this.files.delete(path);
|
||||
if (emitDeletes) this.host.queueEvent("delete", path);
|
||||
}
|
||||
for (const directory of this.directories) {
|
||||
if (!this.host.isPathInside(root, directory)) continue;
|
||||
this.directories.delete(directory);
|
||||
for (const directory of this.walkSubtree(root)) {
|
||||
const entry = this.entries.get(directory);
|
||||
if (entry) {
|
||||
for (const file of entry.files) {
|
||||
if (!this.files.delete(file)) continue;
|
||||
if (emitDeletes) this.host.queueEvent("delete", file);
|
||||
}
|
||||
// A native rename can name only a deleted file. The scoped scan still
|
||||
// knows its parent disappeared, so report that topology change too.
|
||||
if (emitDeletes) this.host.queueEvent("delete", directory);
|
||||
}
|
||||
this.entries.delete(directory);
|
||||
this.directories.delete(directory);
|
||||
}
|
||||
// Orphaned files have no directory-index home, so walkSubtree cannot
|
||||
// find them. This set is bounded by every file classified within one
|
||||
// full-audit window (up to FULL_AUDIT_MAX_DIRTY_MS, 5 minutes) whose
|
||||
// parent never got a bucket, not by the tracked-set size.
|
||||
for (const path of this.orphanFiles) {
|
||||
if (!this.host.isPathInside(root, path)) continue;
|
||||
this.orphanFiles.delete(path);
|
||||
if (this.files.delete(path) && emitDeletes) this.host.queueEvent("delete", path);
|
||||
}
|
||||
// `root` can be a tracked file rather than a directory.
|
||||
if (this.files.delete(root) && emitDeletes) this.host.queueEvent("delete", root);
|
||||
const parent = this.entries.get(dirname(root));
|
||||
parent?.directories.delete(root);
|
||||
parent?.files.delete(root);
|
||||
@@ -578,6 +674,38 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
private classify(path: string, onPresent: (isDirectory: boolean) => void): void {
|
||||
if (this.classificationQueue.length >= MAX_QUEUED_CLASSIFICATIONS) {
|
||||
// Shed load onto the scoped audit, which reads the directory once instead
|
||||
// of stat-ing every entry in it.
|
||||
this.requestAudit(dirname(path));
|
||||
return;
|
||||
}
|
||||
this.classificationQueue.push({ path, onPresent });
|
||||
this.pumpClassifications();
|
||||
}
|
||||
|
||||
private discardIgnoredClassifications(): void {
|
||||
let kept = 0;
|
||||
for (const classification of this.classificationQueue) {
|
||||
if (!this.host.isIgnored(classification.path)) {
|
||||
this.classificationQueue[kept++] = classification;
|
||||
}
|
||||
}
|
||||
this.classificationQueue.length = kept;
|
||||
}
|
||||
|
||||
private pumpClassifications(): void {
|
||||
while (
|
||||
this.classifications.size < MAX_CONCURRENT_CLASSIFICATIONS &&
|
||||
this.classificationQueue.length > 0
|
||||
) {
|
||||
const next = this.classificationQueue.shift();
|
||||
if (!next) return;
|
||||
this.startClassification(next.path, next.onPresent);
|
||||
}
|
||||
}
|
||||
|
||||
private startClassification(path: string, onPresent: (isDirectory: boolean) => void): void {
|
||||
this.host.metrics.nativeClassificationCount += 1;
|
||||
let classification!: Promise<void>;
|
||||
classification = stat(path)
|
||||
@@ -593,7 +721,10 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
this.host.fail(toError(error));
|
||||
})
|
||||
.finally(() => this.classifications.delete(classification));
|
||||
.finally(() => {
|
||||
this.classifications.delete(classification);
|
||||
if (this.host.isActive()) this.pumpClassifications();
|
||||
});
|
||||
this.classifications.add(classification);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user