mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f5224122a | ||
|
|
af27e28054 | ||
|
|
c906c2f4a4 | ||
|
|
fffd76e8fb | ||
|
|
d2e2154a8a | ||
|
|
cb9654a65c | ||
|
|
3dc17b7f30 | ||
|
|
76a9781ba5 | ||
|
|
a272a22d7e | ||
|
|
26f227bed9 | ||
|
|
aa3ffaeb6c | ||
|
|
513f2a9ea2 | ||
|
|
8cd989529e | ||
|
|
04c3e003f8 | ||
|
|
e68553f759 | ||
|
|
dc9799f6fc | ||
|
|
43a2a7969c | ||
|
|
90d978ab68 | ||
|
|
a048094da4 | ||
|
|
315803688d | ||
|
|
bf4dc22c2f | ||
|
|
5c9f767d51 | ||
|
|
84304b5532 | ||
|
|
28507224dc | ||
|
|
db4fd33406 | ||
|
|
e3c853df58 | ||
|
|
ea7a741851 | ||
|
|
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 |
@@ -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
|
||||
|
||||
@@ -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,7 +10,7 @@ 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. 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.
|
||||
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. `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.
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
+21
-8
@@ -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).
|
||||
@@ -60,7 +69,7 @@ model; it does not override the model list returned by a resolver.
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
@@ -86,9 +95,13 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
|
||||
|
||||
Generated
+266
-51
@@ -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": [
|
||||
@@ -42,6 +42,13 @@
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||
@@ -8271,6 +8278,84 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -8644,6 +8729,48 @@
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/client": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
|
||||
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "2.0.10",
|
||||
"@opencode/schema": "2.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/protocol": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
|
||||
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "2.0.10",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/schema": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
|
||||
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -14263,13 +14390,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
@@ -18622,6 +18742,16 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-rc.112",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
|
||||
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-check": "^4.9.0",
|
||||
"msgpackr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
@@ -21866,6 +21996,28 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
|
||||
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
@@ -28141,6 +28293,37 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
@@ -28466,6 +28649,21 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -30141,6 +30339,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -33127,23 +33341,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-length": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
|
||||
@@ -33318,6 +33515,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -36196,7 +36410,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 +37476,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 +37728,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 +37742,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +37986,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 +38882,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 +38911,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 +38928,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 +38951,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 +38978,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,19 +39196,20 @@
|
||||
},
|
||||
"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",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -39895,7 +40110,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": [
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectCreateProfileFromModelRow,
|
||||
expectComposerModel,
|
||||
expectModelRowProfileActionBesideRow,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
selectModelRow,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -115,4 +118,34 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-row-action-",
|
||||
title: "Agent profiles row action",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
const oneMinute = {
|
||||
provider: "mock",
|
||||
modelId: "one-minute-stream",
|
||||
modelLabel: "One minute stream",
|
||||
};
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectModelRowProfileActionBesideRow(page, oneMinute);
|
||||
await expectCreateProfileFromModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await openModelPicker(page);
|
||||
await selectModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, oneMinute.modelLabel);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
@@ -25,3 +28,39 @@ test("settings restart waits for a replacement worker while retaining the superv
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID(), {
|
||||
paseoHome: await createHomeWithSlowProvider(1_800),
|
||||
});
|
||||
try {
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
|
||||
const provider = path.join(paseoHome, "slow-provider");
|
||||
await writeFile(
|
||||
provider,
|
||||
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
|
||||
})}\n`,
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
@@ -120,3 +121,75 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
|
||||
const MANY_THEMES_COUNT = 40;
|
||||
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
|
||||
|
||||
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
||||
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
|
||||
plugin.addTheme({
|
||||
id: "pack-" + index,
|
||||
name: "Pack theme " + index,
|
||||
appearance: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
raised: "#313244",
|
||||
control: "#45475a",
|
||||
border: "#45475a",
|
||||
accent: "#cba6f7",
|
||||
mutedForeground: "#a6adc8",
|
||||
ring: "#6c7086",
|
||||
},
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
||||
await firstTheme.hover();
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await scrollThemeMenuToLastTheme(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
@@ -108,7 +110,207 @@ async function withPaseoOwnedWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
async function readCachedRowIds(
|
||||
page: Page,
|
||||
rowKind: "workspace" | "checkpoint",
|
||||
): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
async ({ serverId, kind }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readonly");
|
||||
const request = transaction.objectStore("rows").getAll();
|
||||
request.onsuccess = () =>
|
||||
resolve(
|
||||
request.result
|
||||
.filter(
|
||||
(row: { serverId: string; kind: string }) =>
|
||||
row.serverId === serverId && row.kind === kind,
|
||||
)
|
||||
.map((row: { id: string }) => row.id)
|
||||
.sort(),
|
||||
);
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), kind: rowKind },
|
||||
);
|
||||
}
|
||||
|
||||
async function simulateDamagedLegacyDirectoryCache(
|
||||
page: Page,
|
||||
workspaceIdToRemove: string,
|
||||
): Promise<void> {
|
||||
// Stop the app before altering storage so no pending app write can repair the fixture.
|
||||
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
|
||||
await page.route(fixtureUrl, (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
|
||||
);
|
||||
await page.goto(fixtureUrl);
|
||||
await page.evaluate(
|
||||
async ({ serverId, missingWorkspaceId }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readwrite");
|
||||
const store = transaction.objectStore("rows");
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result as Array<{
|
||||
serverId: string;
|
||||
kind: string;
|
||||
id: string;
|
||||
payload: string;
|
||||
}>;
|
||||
const checkpoint = rows.find(
|
||||
(row) => row.serverId === serverId && row.kind === "checkpoint",
|
||||
);
|
||||
if (!checkpoint) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(checkpoint.payload);
|
||||
// Accept either writer so this fixture also reproduces on the unfixed client.
|
||||
const cursors = stored.version === 1 ? stored.cursors : stored;
|
||||
if (!cursors.workspaces?.generation) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.serverId === serverId &&
|
||||
row.kind === "workspace" &&
|
||||
row.id === missingWorkspaceId
|
||||
)
|
||||
store.delete([serverId, row.kind, row.id]);
|
||||
}
|
||||
};
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("abort", () =>
|
||||
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
|
||||
);
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
|
||||
);
|
||||
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
|
||||
}
|
||||
|
||||
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
server.onMessage(async (message) => {
|
||||
await ready;
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
async function createPinnedSiblingWorkspace(
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
): Promise<string> {
|
||||
const sibling = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
||||
title: "Pinned sibling",
|
||||
});
|
||||
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
|
||||
await project.client.setWorkspacePinned(sibling.workspace.id, true);
|
||||
return sibling.workspace.id;
|
||||
}
|
||||
|
||||
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await waitForSidebarWorkspace(page, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
}
|
||||
|
||||
async function reopenDamagedCacheBeforeHostResponds(
|
||||
page: Page,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<() => void> {
|
||||
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
|
||||
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
|
||||
await gotoAppShell(page);
|
||||
return releaseHost;
|
||||
}
|
||||
|
||||
async function expectDamagedCacheHydrated(
|
||||
page: Page,
|
||||
retainedWorkspaceId: string,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
await waitForSidebarWorkspace(page, retainedWorkspaceId);
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
|
||||
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectRecoveredWorkspacesAfterReload(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
testInfo: TestInfo,
|
||||
): Promise<void> {
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
await page.reload();
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
|
||||
await testInfo.attach("recovered-workspaces", {
|
||||
path: testInfo.outputPath("recovered-workspaces.png"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
|
||||
try {
|
||||
const siblingId = await createPinnedSiblingWorkspace(project);
|
||||
const ids = [project.workspaceId, siblingId].sort();
|
||||
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
|
||||
|
||||
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
|
||||
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
|
||||
await test.step("keep cached workspace and discard stale checkpoint", () =>
|
||||
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
|
||||
|
||||
releaseHost();
|
||||
await test.step("recover missing workspace and retain both after reload", () =>
|
||||
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
|
||||
@@ -482,6 +482,50 @@ export async function expectModelRowSelected(
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
function modelRow(page: Page, input: { provider: string; modelId: string }): Locator {
|
||||
return pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A model row's profile action is its own button. On web, a button inside the
|
||||
* row's button is invalid HTML that React reports on every render.
|
||||
*/
|
||||
export async function expectModelRowProfileActionBesideRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const row = modelRow(page, input);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pickerViewport(page).locator("button button")).toHaveCount(0);
|
||||
await expect(row.getByRole("button")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The hover-revealed plus on an unprofiled model row opens a seeded profile form. */
|
||||
export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
input.modelLabel,
|
||||
);
|
||||
await expect(modal.getByRole("button", { name: `Model (${input.modelLabel})` })).toBeVisible();
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(modal).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function selectModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).click();
|
||||
}
|
||||
|
||||
// ─── Cross-provider model search ───────────────────────────────────────────
|
||||
|
||||
export async function searchAllModels(page: Page, query: string): Promise<void> {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -625,13 +625,6 @@ function ModelRowProfileAction({
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const visible = hovered || isNative || isCompact;
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
},
|
||||
[onPress],
|
||||
);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.rowIconButton,
|
||||
@@ -645,7 +638,7 @@ function ModelRowProfileAction({
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
@@ -778,7 +771,6 @@ function ModelRow({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.browserRow,
|
||||
styles.browserModelRow,
|
||||
Boolean(hovered) && styles.browserRowHovered,
|
||||
pressed && styles.browserRowPressed,
|
||||
],
|
||||
@@ -787,7 +779,7 @@ function ModelRow({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.modelRowHoverBoundary}
|
||||
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -815,10 +807,17 @@ function ModelRow({
|
||||
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
{profileAction}
|
||||
{profileAction ? <View style={styles.rowIconButton} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
</ModelBrowserPressable>
|
||||
{/* The row renders a <button> on web, so its profile action sits beside it,
|
||||
over the slot reserved above, rather than inside it. */}
|
||||
{profileAction ? (
|
||||
<View style={styles.modelRowProfileActionSlot} pointerEvents="box-none">
|
||||
{profileAction}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1590,6 +1589,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
modelRowHoverBoundary: {
|
||||
position: "relative",
|
||||
},
|
||||
modelRowProfileActionSlot: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: isWeb ? theme.spacing[3] : theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
},
|
||||
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
|
||||
browserRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
@@ -197,6 +198,51 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the checkout's cached draft slash commands when its branch changes", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(
|
||||
checkoutStatusQueryKey(serverId, cwd),
|
||||
checkoutStatus({ currentBranch: "chore/build-paseo" }),
|
||||
);
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
const otherCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd: "/repo2", model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
queryClient.setQueryData(otherCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ currentBranch: "main" })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(otherCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("keeps the checkout's draft slash commands when a push leaves its branch unchanged", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), checkoutStatus());
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
@@ -67,7 +68,13 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
||||
const previousStatus = queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, payload.cwd),
|
||||
);
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
||||
if (previousStatus?.currentBranch !== payload.currentBranch) {
|
||||
void resetDraftAgentCommandsForCheckout(queryClient, { serverId, cwd: payload.cwd });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
});
|
||||
|
||||
@@ -38,9 +38,10 @@ describe("agent command query keys", () => {
|
||||
"agentCommands",
|
||||
"server-1",
|
||||
"draft",
|
||||
"codex",
|
||||
"cwd",
|
||||
"/repo",
|
||||
"provider",
|
||||
"codex",
|
||||
"mode",
|
||||
null,
|
||||
"model",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -24,17 +25,24 @@ export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId:
|
||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||
}
|
||||
|
||||
function draftAgentCommandsCheckoutScope(input: { serverId: string; cwd: string }) {
|
||||
return [
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(input.cwd),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function draftAgentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
draftConfig: AgentCommandsDraftConfig;
|
||||
}) {
|
||||
const { draftConfig } = input;
|
||||
return [
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }),
|
||||
"provider",
|
||||
draftConfig.provider,
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(draftConfig.cwd),
|
||||
"mode",
|
||||
draftConfig.modeId ?? null,
|
||||
"model",
|
||||
@@ -46,6 +54,16 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
] as const;
|
||||
}
|
||||
|
||||
// Draft commands include project skills read from the checkout. When the checkout moves to
|
||||
// another branch the cached list describes files that are gone, so it is dropped rather than
|
||||
// shown while a refetch runs.
|
||||
export function resetDraftAgentCommandsForCheckout(
|
||||
queryClient: QueryClient,
|
||||
input: { serverId: string; cwd: string },
|
||||
): Promise<void> {
|
||||
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
}
|
||||
|
||||
export function agentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ class FakeDirectoryClient {
|
||||
projectResult: ProjectListResult | null = null;
|
||||
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
@@ -82,6 +83,14 @@ class FakeDirectoryClient {
|
||||
return complete;
|
||||
}
|
||||
|
||||
holdProjectFetch(): (result: ProjectListResult) => void {
|
||||
let complete!: (result: ProjectListResult) => void;
|
||||
this.pendingProjectFetch = new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
return complete;
|
||||
}
|
||||
|
||||
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
this.fetchAgentsCalls += 1;
|
||||
this.lastAgentOptions = options;
|
||||
@@ -116,6 +125,11 @@ class FakeDirectoryClient {
|
||||
async listProjects(options?: unknown): Promise<ProjectListResult> {
|
||||
this.listProjectsCalls += 1;
|
||||
this.lastProjectOptions = options;
|
||||
if (this.pendingProjectFetch) {
|
||||
const pending = this.pendingProjectFetch;
|
||||
this.pendingProjectFetch = null;
|
||||
return pending;
|
||||
}
|
||||
if (this.projectResult) return this.projectResult;
|
||||
return {
|
||||
requestId: "projects",
|
||||
@@ -227,6 +241,25 @@ function createAgent(serverId: string, id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] {
|
||||
return {
|
||||
id,
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Snapshot project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
|
||||
serverIds.clear();
|
||||
@@ -1115,6 +1148,41 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
|
||||
const serverId = "superseded-workspace-baseline";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true },
|
||||
});
|
||||
directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]);
|
||||
const release = client.holdWorkspaceFetch();
|
||||
const refresh = directory.refreshWorkspaces();
|
||||
const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
|
||||
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 2 },
|
||||
});
|
||||
release({
|
||||
requestId: "old-empty-snapshot",
|
||||
entries: [],
|
||||
emptyProjects: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
});
|
||||
await rejected;
|
||||
expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([
|
||||
"retained",
|
||||
]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
||||
const serverId = "session-wait-reconnect";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
@@ -1182,9 +1250,19 @@ describe("DirectorySync session readiness", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
|
||||
},
|
||||
});
|
||||
completeFetch({
|
||||
requestId: "workspaces",
|
||||
entries: [],
|
||||
entries: [
|
||||
createWorkspaceEntry("removed-workspace"),
|
||||
createWorkspaceEntry("updated-workspace"),
|
||||
],
|
||||
emptyProjects: [
|
||||
{
|
||||
projectId: "snapshot-project",
|
||||
@@ -1207,6 +1285,11 @@ describe("DirectorySync session readiness", () => {
|
||||
expect(projects?.get("workspace-project")).toMatchObject({
|
||||
projectDisplayName: "Project from workspace update",
|
||||
});
|
||||
expect(
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.values()].map(
|
||||
({ id, name }) => ({ id, name }),
|
||||
),
|
||||
).toEqual([{ id: "updated-workspace", name: "Live name" }]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
@@ -1278,6 +1361,8 @@ function createSqliteCache() {
|
||||
return {
|
||||
cache,
|
||||
database,
|
||||
storage,
|
||||
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
|
||||
holdRead: (operation: () => Promise<void>) => {
|
||||
beforeRead = operation;
|
||||
},
|
||||
@@ -1473,3 +1558,198 @@ describe("DirectorySync workspace labels", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["before", "during", "before-metadata", "damaged"] as const)(
|
||||
"retains unchanged workspaces across route/full demand and restart (cache: %s)",
|
||||
async (timing) => {
|
||||
const cacheFirst = timing === "before";
|
||||
const damaged = timing === "damaged";
|
||||
const serverId = `cache-route-overlap-${timing}`;
|
||||
serverIds.add(serverId);
|
||||
const { cache, database, reopen, storage } = createSqliteCache();
|
||||
cache.setHosts([serverId]);
|
||||
const projectEntry: ProjectListResult["projects"][number] = {
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
};
|
||||
const project = normalizeProjectDescriptor(projectEntry);
|
||||
const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({
|
||||
id,
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
}));
|
||||
const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor);
|
||||
const cursor = { generation: "g", afterSeq: 7 };
|
||||
cache.replaceDirectoryBaseline(serverId, {
|
||||
agents: new Map(),
|
||||
projects: new Map([[project.projectId, project]]),
|
||||
workspaces: new Map(
|
||||
(damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]),
|
||||
),
|
||||
checkpoint: { projects: cursor, workspaces: cursor, agents: cursor },
|
||||
});
|
||||
await cache.flush();
|
||||
if (damaged) {
|
||||
await storage.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
{
|
||||
serverId,
|
||||
kind: "checkpoint",
|
||||
id: "singleton",
|
||||
payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// The host keeps A/B unchanged. A current cursor legitimately receives no rows.
|
||||
class UnchangedDirectoryClient extends FakeDirectoryClient {
|
||||
readonly workspaceSyncRequests: unknown[] = [];
|
||||
override async fetchWorkspaces(
|
||||
options?: Parameters<DaemonClient["fetchWorkspaces"]>[0],
|
||||
): Promise<WorkspaceFetchResult> {
|
||||
this.fetchWorkspacesCalls += 1;
|
||||
this.lastWorkspaceOptions = options;
|
||||
this.workspaceSyncRequests.push(options?.sync);
|
||||
const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7;
|
||||
return {
|
||||
requestId: "workspaces",
|
||||
entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]],
|
||||
emptyProjects: [],
|
||||
pageInfo: {
|
||||
hasMore: !changes && !options?.page?.cursor,
|
||||
nextCursor: !changes && !options?.page?.cursor ? "page-2" : null,
|
||||
prevCursor: null,
|
||||
},
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: changes ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const client = new UnchangedDirectoryClient();
|
||||
const releaseAgents = client.holdAgentFetch();
|
||||
const releaseProjects = client.holdProjectFetch();
|
||||
const projectResult: ProjectListResult = {
|
||||
requestId: "projects",
|
||||
projects: [projectEntry],
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
};
|
||||
client.projectResult = projectResult;
|
||||
const callbacks = {
|
||||
onAgentStoppedRunning: () => {},
|
||||
markAgentLoading: () => {},
|
||||
markAgentReady: () => {},
|
||||
markAgentError: () => {},
|
||||
};
|
||||
const directory = new DirectorySync(serverId, callbacks, cache);
|
||||
const publishServerInfo = () => {
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true, projectList: true },
|
||||
});
|
||||
};
|
||||
const initializeSession = (ready = true) => {
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
if (ready) publishServerInfo();
|
||||
};
|
||||
const connection = {
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online" as const,
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
};
|
||||
initializeSession(timing !== "before-metadata");
|
||||
directory.connectionChanged(connection);
|
||||
const visibleIds = () =>
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort();
|
||||
let restarted: DirectorySync | undefined;
|
||||
const restartedCache = reopen();
|
||||
try {
|
||||
if (cacheFirst) await directory.restoreCachedDirectory();
|
||||
directory.setAgentRouteDemand(["agent"]);
|
||||
if (timing === "before-metadata") {
|
||||
await directory.restoreCachedDirectory();
|
||||
publishServerInfo();
|
||||
}
|
||||
await expect.poll(() => client.listProjectsCalls).toBe(1);
|
||||
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
|
||||
directory.setDemand({}, true);
|
||||
await directory.restoreCachedDirectory();
|
||||
expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]);
|
||||
|
||||
releaseProjects(projectResult);
|
||||
releaseAgents({
|
||||
requestId: "agents",
|
||||
entries: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: cacheFirst ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
});
|
||||
await directory.refreshDemand();
|
||||
expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} });
|
||||
expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]);
|
||||
expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]);
|
||||
await cache.flush();
|
||||
expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
|
||||
await directory.refreshAll();
|
||||
await cache.flush();
|
||||
directory.dispose();
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
restartedCache.setHosts([serverId]);
|
||||
const persisted = await restartedCache.readDirectory(serverId);
|
||||
expect(persisted.checkpoint?.workspaces).toEqual(cursor);
|
||||
expect
|
||||
.soft([...persisted.workspaces.keys()].sort(), "persisted baseline")
|
||||
.toEqual(["A", "B"]);
|
||||
initializeSession();
|
||||
restarted = new DirectorySync(serverId, callbacks, restartedCache);
|
||||
restarted.connectionChanged(connection);
|
||||
await restarted.refreshAll();
|
||||
expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]);
|
||||
const beforeUpdate = visibleIds();
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 },
|
||||
});
|
||||
expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort());
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 },
|
||||
});
|
||||
expect(visibleIds()).toEqual(["A", "B"]);
|
||||
} finally {
|
||||
directory.dispose();
|
||||
restarted?.dispose();
|
||||
await cache.flush();
|
||||
await restartedCache.flush();
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -61,6 +61,10 @@ interface AgentSnapshot {
|
||||
syncRemovals: Array<{ id: string; seq: number }>;
|
||||
}
|
||||
|
||||
interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot {
|
||||
readonly requestCursors: Readonly<Pick<DirectoryCheckpoint, "projects" | "workspaces">>;
|
||||
}
|
||||
|
||||
interface AgentPageInfo {
|
||||
hasMore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
@@ -133,7 +137,7 @@ export class DirectorySync {
|
||||
AgentDirectoryDelta
|
||||
>();
|
||||
private readonly workspaceTransactions = new DirectoryTransactionOwner<
|
||||
WorkspaceDirectorySnapshot,
|
||||
WorkspaceRefreshSnapshot,
|
||||
WorkspaceDirectoryDelta
|
||||
>();
|
||||
private readonly agents: AgentDirectoryReplica;
|
||||
@@ -478,14 +482,20 @@ export class DirectorySync {
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) return;
|
||||
const { client, source } = onlineConnection;
|
||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
|
||||
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
}));
|
||||
const transaction = this.workspaceTransactions.begin(source, () => {
|
||||
const baseline = this.workspaces.snapshot();
|
||||
return {
|
||||
workspaces: new Map(baseline.workspaces),
|
||||
projects: new Map(baseline.projects),
|
||||
// Cache hydration can advance the live replica while these requests await responses.
|
||||
// Every page must use the cursors belonging to the maps captured here.
|
||||
requestCursors: { ...this.cursors },
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
@@ -517,7 +527,7 @@ export class DirectorySync {
|
||||
private async fetchWorkspaceSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
@@ -527,7 +537,9 @@ export class DirectorySync {
|
||||
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
|
||||
...(supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
|
||||
: {}),
|
||||
};
|
||||
let payload: FetchWorkspacesPayload;
|
||||
if (subscribe) {
|
||||
@@ -667,7 +679,7 @@ export class DirectorySync {
|
||||
private assertWorkspaceTransactionCurrent(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
|
||||
@@ -724,11 +736,13 @@ export class DirectorySync {
|
||||
private async fetchProjectSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
const payload = await client.listProjects(
|
||||
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
|
||||
supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
|
||||
: undefined,
|
||||
);
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
|
||||
@@ -753,7 +767,7 @@ export class DirectorySync {
|
||||
private completeWorkspaceRefresh(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
||||
|
||||
@@ -256,6 +256,41 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void {
|
||||
}
|
||||
|
||||
describe("ReplicaCache", () => {
|
||||
it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory();
|
||||
writer.replaceDirectoryBaseline(SERVER_ID, baseline);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
|
||||
await writer.flush();
|
||||
await storage.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
kind: "checkpoint",
|
||||
id: "singleton",
|
||||
payload: JSON.stringify(baseline.checkpoint),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const reader = createCache(storage);
|
||||
const restored = await reader.readDirectory(SERVER_ID);
|
||||
expect(restored.checkpoint).toBeUndefined();
|
||||
expect([...restored.workspaces.keys()]).toEqual([...baseline.workspaces.keys()]);
|
||||
expect([...restored.projects.keys()]).toEqual([...baseline.projects.keys()]);
|
||||
expect([...restored.agents.keys()]).toEqual([...baseline.agents.keys()]);
|
||||
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline());
|
||||
expect(await storage.read(SERVER_ID, ["checkpoint"])).toEqual([]);
|
||||
|
||||
reader.replaceDirectoryBaseline(SERVER_ID, baseline);
|
||||
await reader.flush();
|
||||
expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual(
|
||||
baseline.checkpoint,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing until an owner explicitly commits data", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
|
||||
@@ -378,6 +378,21 @@ const DirectoryCheckpointSchema = z.strictObject({
|
||||
agents: DirectoryCursorSchema.optional(),
|
||||
});
|
||||
|
||||
// Old checkpoints could describe a baseline that an overlapping refresh had discarded.
|
||||
// Revalidate directory contents once without evicting cached rows or timelines.
|
||||
const StoredDirectoryCheckpointSchema = z.strictObject({
|
||||
version: z.literal(1),
|
||||
cursors: DirectoryCheckpointSchema,
|
||||
});
|
||||
|
||||
function serializeDirectoryCheckpoint(cursors: DirectoryCheckpoint) {
|
||||
return { version: 1 as const, cursors };
|
||||
}
|
||||
|
||||
function deserializeDirectoryCheckpoint(payload: string): DirectoryCheckpoint {
|
||||
return parseStoredPayload(StoredDirectoryCheckpointSchema, payload).cursors;
|
||||
}
|
||||
|
||||
function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
@@ -849,7 +864,7 @@ function applyDirectoryRow(
|
||||
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
|
||||
throw new Error("Replica checkpoint row id mismatch");
|
||||
}
|
||||
result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload);
|
||||
result.checkpoint = deserializeDirectoryCheckpoint(row.payload);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
@@ -1015,7 +1030,7 @@ export class ReplicaCache {
|
||||
let checkpoint: DirectoryCheckpoint | undefined;
|
||||
if (checkpointRow) {
|
||||
try {
|
||||
checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload);
|
||||
checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload);
|
||||
checkpoint = { ...checkpoint };
|
||||
delete checkpoint[invalidEntity];
|
||||
} catch {
|
||||
@@ -1031,7 +1046,7 @@ export class ReplicaCache {
|
||||
serverId: row.serverId,
|
||||
kind: "checkpoint",
|
||||
id: REPLICA_SINGLETON_ROW_ID,
|
||||
payload: JSON.stringify(checkpoint),
|
||||
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -1057,7 +1072,7 @@ export class ReplicaCache {
|
||||
serverId,
|
||||
kind: "checkpoint",
|
||||
id: REPLICA_SINGLETON_ROW_ID,
|
||||
payload: JSON.stringify(checkpoint),
|
||||
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -1323,7 +1338,7 @@ export class ReplicaCache {
|
||||
if (!value) return null;
|
||||
break;
|
||||
case "checkpoint":
|
||||
value = upsert.value;
|
||||
value = serializeDirectoryCheckpoint(upsert.value);
|
||||
break;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -195,7 +195,7 @@ function ThemeRow({
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
|
||||
{THEME_OPTIONS.map((option, index) => {
|
||||
const previousOption = THEME_OPTIONS[index - 1];
|
||||
return (
|
||||
|
||||
@@ -622,7 +622,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
{
|
||||
restartServer: (reason) => daemonClient.restartServer(reason),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
@@ -766,7 +766,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
void updateDaemonFromSettings(host.serverId, {
|
||||
updateDaemon: () => daemonClient.updateDaemon(requestId),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
|
||||
@@ -377,8 +377,7 @@ export function KeyboardShortcutsSection() {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (key === "Backspace") {
|
||||
if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) {
|
||||
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { page, userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
@@ -40,6 +40,7 @@ interface MountedTerminal {
|
||||
sizes: TerminalSize[];
|
||||
terminalKeys: TerminalKeyRecord[];
|
||||
inputModeChanges: TerminalInputModeState[];
|
||||
openedUrls: string[];
|
||||
}
|
||||
|
||||
const mountedTerminals: MountedTerminal[] = [];
|
||||
@@ -95,6 +96,7 @@ function createTerminalHost(input: {
|
||||
const inputs: string[] = [];
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
@@ -110,6 +112,9 @@ function createTerminalHost(input: {
|
||||
onInputModeChange: (state) => {
|
||||
inputModeChanges.push(state);
|
||||
},
|
||||
onOpenExternalUrl: (url) => {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.mount({
|
||||
@@ -124,7 +129,16 @@ function createTerminalHost(input: {
|
||||
},
|
||||
});
|
||||
|
||||
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
|
||||
const mounted = {
|
||||
host,
|
||||
root,
|
||||
runtime,
|
||||
inputs,
|
||||
sizes,
|
||||
terminalKeys,
|
||||
inputModeChanges,
|
||||
openedUrls,
|
||||
};
|
||||
mountedTerminals.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
@@ -188,7 +202,43 @@ function dispatchTerminalKey(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
|
||||
function hyperlink(input: { url: string; text: string }): string {
|
||||
return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`;
|
||||
}
|
||||
|
||||
function writeLines(mounted: MountedTerminal, lines: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
mounted.runtime.write({
|
||||
data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")),
|
||||
onCommitted: resolve,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clickTerminalLink(input: {
|
||||
host: HTMLElement;
|
||||
row: number;
|
||||
col: number;
|
||||
}): Promise<void> {
|
||||
const terminal = getBrowserTerminal();
|
||||
const screen = input.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
if (!screen) {
|
||||
throw new Error("Expected xterm screen to be mounted");
|
||||
}
|
||||
const position = {
|
||||
x: (input.col + 0.5) * (screen.clientWidth / terminal.cols),
|
||||
y: (input.row + 0.5) * (screen.clientHeight / terminal.rows),
|
||||
};
|
||||
await userEvent.hover(screen, { position });
|
||||
await waitFor({
|
||||
predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null,
|
||||
});
|
||||
await userEvent.click(screen, { position });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const mounted of mountedTerminals.splice(0)) {
|
||||
mounted.runtime.unmount();
|
||||
mounted.root.remove();
|
||||
@@ -526,4 +576,23 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
await writeLines(mounted, [
|
||||
hyperlink({ url: "https://example.com/osc8", text: "example" }),
|
||||
"https://example.com/plain",
|
||||
]);
|
||||
|
||||
await clickTerminalLink({ host: mounted.host, row: 1, col: 4 });
|
||||
await clickTerminalLink({ host: mounted.host, row: 0, col: 2 });
|
||||
|
||||
expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,6 +434,10 @@ export class TerminalEmulatorRuntime {
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
|
||||
const openExternalLink = (event: MouseEvent, uri: string) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
};
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
@@ -441,6 +445,8 @@ export class TerminalEmulatorRuntime {
|
||||
cursorStyle: "bar",
|
||||
fontFamily: resolveTerminalFontFamily(input.fontFamily),
|
||||
fontSize: resolveTerminalFontSize(input.fontSize),
|
||||
// OSC 8 hyperlinks; without a handler xterm prompts and calls window.open().
|
||||
linkHandler: { activate: openExternalLink },
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
@@ -457,12 +463,7 @@ export class TerminalEmulatorRuntime {
|
||||
let imageAddon: ImageAddon | null = null;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
}),
|
||||
);
|
||||
terminal.loadAddon(new WebLinksAddon(openExternalLink));
|
||||
const localFileLinkProvider = terminal.registerLinkProvider(
|
||||
createTerminalLocalFileLinkProvider(terminal, {
|
||||
resolveLink: async (source) => {
|
||||
|
||||
@@ -108,6 +108,17 @@ describe("formatMessageTimestamp", () => {
|
||||
expect(formatted).toMatch(/10:12 PM|22:12/);
|
||||
});
|
||||
|
||||
it("shows the full date for last week's same weekday, even under seven days ago", () => {
|
||||
// 2026-09-18 is a Friday. The reply is 6 days 23h45m old and lands on today's weekday,
|
||||
// so a weekday label would read as today.
|
||||
const now = new Date(2026, 8, 25, 11, 47);
|
||||
const date = new Date(2026, 8, 18, 12, 2);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/Sep|September/);
|
||||
expect(formatted).toMatch(/18/);
|
||||
expect(formatted).not.toMatch(/Friday/);
|
||||
});
|
||||
|
||||
it("includes full date for older timestamps", () => {
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 3, 1, 9, 5);
|
||||
|
||||
@@ -91,12 +91,14 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string
|
||||
return describeCompactTimeAgo(date, now).label;
|
||||
}
|
||||
|
||||
function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
/**
|
||||
* How many local midnights lie between two instants: 0 for the same day, 1 for yesterday.
|
||||
* Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on
|
||||
* today's weekday is 7, and rounded so a DST day of 23 or 25 hours still counts as one.
|
||||
*/
|
||||
function localCalendarDaysBetween(earlier: Date, later: Date): number {
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
return Math.round((startOfDay(later).getTime() - startOfDay(earlier).getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
// Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved
|
||||
@@ -120,19 +122,18 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
||||
/**
|
||||
* Format a chat-message timestamp for hover-revealed UI.
|
||||
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
||||
* - Within ~6 days: "Wednesday 10:11 PM"
|
||||
* - Older: "14 May 2026, 10:11 PM"
|
||||
* - The previous 6 calendar days: "Wednesday 10:11 PM"
|
||||
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
||||
*/
|
||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||
const time = getTimeFormatter().format(date);
|
||||
const daysAgo = localCalendarDaysBetween(date, now);
|
||||
|
||||
if (isSameLocalDay(date, now)) {
|
||||
if (daysAgo === 0) {
|
||||
return time;
|
||||
}
|
||||
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays >= 0 && diffDays < 7) {
|
||||
if (daysAgo > 0 && daysAgo < 7) {
|
||||
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
|
||||
return `${weekday} ${time}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
resolveExistingRunWorkspace,
|
||||
resolveRunCallerAgentId,
|
||||
@@ -8,13 +9,51 @@ import {
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
// Answers fetchAgent the way a daemon does: an unknown id is an error.
|
||||
function daemonWithAgents(...agentIds: string[]) {
|
||||
return {
|
||||
async fetchAgent({ agentId }: { agentId: string }) {
|
||||
if (!agentIds.includes(agentId)) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
return { agent: { id: agentId } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed agent caller context", () => {
|
||||
it("propagates a trimmed PASEO_AGENT_ID", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
|
||||
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents("parent-agent"), {
|
||||
PASEO_AGENT_ID: " parent-agent ",
|
||||
}),
|
||||
).resolves.toBe("parent-agent");
|
||||
});
|
||||
|
||||
it("omits blank caller ids", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
|
||||
it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents("other-agent"), {
|
||||
PASEO_AGENT_ID: "parent-agent",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails instead of dropping the caller when the lookup loses its connection", async () => {
|
||||
const disconnectedDaemon = {
|
||||
async fetchAgent(): Promise<never> {
|
||||
throw new DaemonConnectionError("Connection lost before message could be sent");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
|
||||
).rejects.toBeInstanceOf(DaemonConnectionError);
|
||||
});
|
||||
|
||||
it("omits blank caller ids", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
StructuredAgentResponseError,
|
||||
} from "@getpaseo/server/agent-response";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -526,7 +527,7 @@ export async function resolveExistingRunWorkspace(
|
||||
|
||||
// Workspace policy for `paseo run`. Precedence:
|
||||
// 1. --workspace <id> -> run in that existing workspace
|
||||
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
|
||||
// 2. caller agent -> daemon resolves the caller's workspace
|
||||
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||
// 4. --new-workspace <kind> -> mint a new workspace explicitly
|
||||
// 5. bare run -> mint a new local-backed workspace for cwd
|
||||
@@ -534,6 +535,7 @@ async function resolveRunWorkspace(
|
||||
client: ConnectedDaemonClient,
|
||||
options: AgentRunOptions,
|
||||
cwd: string,
|
||||
callerAgentId: string | undefined,
|
||||
): Promise<RunWorkspace> {
|
||||
const newWorkspace = resolveNewWorkspaceKind(options);
|
||||
const explicit = newWorkspace ? undefined : options.workspace?.trim();
|
||||
@@ -542,7 +544,7 @@ async function resolveRunWorkspace(
|
||||
return resolveExistingRunWorkspace(client, explicit);
|
||||
}
|
||||
|
||||
if (!newWorkspace && resolveRunCallerAgentId()) {
|
||||
if (!newWorkspace && callerAgentId) {
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
@@ -609,9 +611,9 @@ export async function runRunCommand(
|
||||
const env = parseRunEnv(options.env);
|
||||
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
|
||||
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd);
|
||||
const callerAgentId = await resolveRunCallerAgentId(client);
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
|
||||
const workspaceId = workspace.id;
|
||||
const callerAgentId = resolveRunCallerAgentId();
|
||||
const runCwd = workspace.cwd;
|
||||
|
||||
if (outputSchema) {
|
||||
@@ -743,8 +745,28 @@ export async function runRunCommand(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRunCallerAgentId(
|
||||
export interface RunCallerLookupClient {
|
||||
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
|
||||
}
|
||||
|
||||
// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run
|
||||
// sent to another daemon (--host or --home) has no caller there, so it runs as
|
||||
// a top-level agent instead of failing on an unknown caller.
|
||||
export async function resolveRunCallerAgentId(
|
||||
client: RunCallerLookupClient,
|
||||
env: { PASEO_AGENT_ID?: string } = process.env,
|
||||
): string | undefined {
|
||||
return env.PASEO_AGENT_ID?.trim() || undefined;
|
||||
): Promise<string | undefined> {
|
||||
const agentId = env.PASEO_AGENT_ID?.trim();
|
||||
if (!agentId) {
|
||||
return undefined;
|
||||
}
|
||||
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
|
||||
// A daemon without this agent answers with an error. A lost or timed-out
|
||||
// connection is not an answer, so it must not drop the caller.
|
||||
if (error instanceof DaemonConnectionError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return caller?.agent.id === agentId ? agentId : undefined;
|
||||
}
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -57,6 +57,17 @@ function createCommandError(code: string, message: string, details?: string): Co
|
||||
return { code, message, ...(details ? { details } : {}) };
|
||||
}
|
||||
|
||||
function terminalPasswordPrompt(): PromptPassword {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw createCommandError(
|
||||
"PASSWORD_TTY_REQUIRED",
|
||||
"paseo daemon set-password needs a terminal to read the password",
|
||||
"Run it in an interactive terminal, or set PASEO_PASSWORD in the daemon's environment instead.",
|
||||
);
|
||||
}
|
||||
return (message) => passwordPrompt({ message });
|
||||
}
|
||||
|
||||
async function promptForPassword(promptPassword: PromptPassword): Promise<string> {
|
||||
const first = await promptPassword("New daemon password");
|
||||
if (isCancel(first)) {
|
||||
@@ -112,7 +123,7 @@ export async function runSetPasswordCommand(
|
||||
const promptPassword =
|
||||
typeof options.promptPassword === "function"
|
||||
? (options.promptPassword as PromptPassword)
|
||||
: (message: string) => passwordPrompt({ message });
|
||||
: terminalPasswordPrompt();
|
||||
const newPassword = await promptForPassword(promptPassword);
|
||||
const result = await setDaemonPasswordInConfig(newPassword, {
|
||||
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import { isBearerTokenValid } from "@getpaseo/server/auth";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import {
|
||||
runSetPasswordCommand,
|
||||
setDaemonPasswordInConfig,
|
||||
@@ -105,6 +106,27 @@ try {
|
||||
);
|
||||
console.log("✓ command refuses password mismatch\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: piped stdin fails with a message instead of exiting silently");
|
||||
const pipedHome = join(root, "piped");
|
||||
await mkdir(pipedHome, { recursive: true });
|
||||
const run = runLocalPaseo(["daemon", "set-password", "--home", pipedHome, "--json"]);
|
||||
run.stdin.end("x\nx\n");
|
||||
const result = await run;
|
||||
|
||||
assert.strictEqual(result.exitCode, 1, result.stderr);
|
||||
const error = JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
assert.strictEqual(error.code, "PASSWORD_TTY_REQUIRED");
|
||||
assert.match(error.message, /needs a terminal/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
await assert.rejects(readFile(join(pipedHome, "config.json"), "utf-8"));
|
||||
console.log("✓ piped stdin reports that a terminal is required\n");
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Daemon Auth Command Errors ===\n");
|
||||
|
||||
const daemon = await startTestDaemon({
|
||||
env: { PASEO_PASSWORD: "shared-secret" },
|
||||
});
|
||||
|
||||
async function lsError(password: string) {
|
||||
const result = await runLocalPaseo(["ls", "--json"], {
|
||||
PASEO_HOME: daemon.paseoHome,
|
||||
PASEO_HOST: "",
|
||||
PASEO_PASSWORD: password,
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
|
||||
return JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("");
|
||||
assert.strictEqual(error.code, "AUTH_REQUIRED");
|
||||
assert.match(error.message, /Password required/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ missing password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("wrong-secret");
|
||||
assert.strictEqual(error.code, "AUTH_FAILED");
|
||||
assert.match(error.message, /Incorrect password/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ wrong password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== Daemon Auth Command Errors Tests Passed ===");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -14,10 +14,13 @@ const SHORTCUTS_ROW = "show-shortcuts";
|
||||
* daemon is left unmanaged so the app talks to the E2E daemon instead of trying
|
||||
* to start one of its own.
|
||||
*/
|
||||
async function installDesktopBridge(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
async function installDesktopBridge(
|
||||
page: Page,
|
||||
platform: "darwin" | "win32" = "darwin",
|
||||
): Promise<void> {
|
||||
await page.addInitScript((desktopPlatform) => {
|
||||
window.paseoDesktop = {
|
||||
platform: "darwin",
|
||||
platform: desktopPlatform,
|
||||
events: { on: () => () => {} },
|
||||
invoke: async (command: string) => {
|
||||
if (command === "get_desktop_settings") {
|
||||
@@ -29,11 +32,11 @@ async function installDesktopBridge(page: Page): Promise<void> {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
}, platform);
|
||||
}
|
||||
|
||||
async function openShortcutsSettings(page: Page) {
|
||||
await installDesktopBridge(page);
|
||||
async function openShortcutsSettings(page: Page, platform: "darwin" | "win32" = "darwin") {
|
||||
await installDesktopBridge(page, platform);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "shortcuts");
|
||||
@@ -67,6 +70,38 @@ async function closeRowMenu(page: Page) {
|
||||
await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function clearShortcut(page: Page, label: string, action: string) {
|
||||
await page.getByRole("button", { name: `Actions for ${label}` }).click();
|
||||
await page.getByTestId(`shortcut-clear-${action}`).click();
|
||||
}
|
||||
|
||||
async function captureShortcut(page: Page, label: string, action: string, keys: string) {
|
||||
await page.getByRole("button", { name: `Actions for ${label}` }).click();
|
||||
await page.getByTestId(`shortcut-bind-${action}`).click();
|
||||
await page.keyboard.press(keys);
|
||||
}
|
||||
|
||||
async function expectInterruptShortcut(page: Page, chord: string) {
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await expect(row.getByText(chord, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function finishInterruptCapture(page: Page, action: "Done" | "Cancel") {
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await row.getByRole("button", { name: action }).click();
|
||||
}
|
||||
|
||||
async function eraseLastCapturedCombo(page: Page, firstCombo: string, secondCombo: string) {
|
||||
await captureShortcut(page, "Interrupt agent", "agent-interrupt", "Alt+K");
|
||||
await page.keyboard.press("Alt+J");
|
||||
await expectInterruptShortcut(page, firstCombo);
|
||||
await expectInterruptShortcut(page, secondCombo);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expectInterruptShortcut(page, firstCombo);
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await expect(row.getByText(secondCombo, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("unassigning a shortcut leaves it inert until it is reset", async ({ page }) => {
|
||||
await openShortcutsSettings(page);
|
||||
|
||||
@@ -146,3 +181,35 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
for (const { platform, keys, label, firstCombo, secondCombo } of [
|
||||
{
|
||||
platform: "darwin",
|
||||
keys: "Meta+Shift+Backspace",
|
||||
label: "⇧⌘⌫",
|
||||
firstCombo: "⌥K",
|
||||
secondCombo: "⌥J",
|
||||
},
|
||||
{
|
||||
platform: "win32",
|
||||
keys: "Control+Shift+Backspace",
|
||||
label: "Ctrl+Shift+⌫",
|
||||
firstCombo: "Alt+K",
|
||||
secondCombo: "Alt+J",
|
||||
},
|
||||
] as const) {
|
||||
test(`binds modified Backspace and erases a combo with bare Backspace on ${platform}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openShortcutsSettings(page, platform);
|
||||
await clearShortcut(page, "Archive workspace", "archive-workspace");
|
||||
await captureShortcut(page, "Interrupt agent", "agent-interrupt", keys);
|
||||
await expectInterruptShortcut(page, label);
|
||||
await finishInterruptCapture(page, "Done");
|
||||
await expectInterruptShortcut(page, label);
|
||||
|
||||
await eraseLastCapturedCombo(page, firstCombo, secondCombo);
|
||||
await finishInterruptCapture(page, "Cancel");
|
||||
await expectInterruptShortcut(page, label);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -65,8 +65,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -76,8 +76,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
@@ -107,14 +107,15 @@
|
||||
"@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",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
|
||||
@@ -4,22 +4,27 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { build } from "esbuild";
|
||||
|
||||
const sourcePath = fileURLToPath(
|
||||
new URL("../src/server/agent/providers/opencode/bridge-plugin.mjs", import.meta.url),
|
||||
);
|
||||
const outputPath = fileURLToPath(
|
||||
new URL(
|
||||
"../dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await build({
|
||||
entryPoints: [sourcePath],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "neutral",
|
||||
target: "es2022",
|
||||
outfile: outputPath,
|
||||
});
|
||||
for (const directory of ["", "v2/"]) {
|
||||
const sourcePath = fileURLToPath(
|
||||
new URL(
|
||||
`../src/server/agent/providers/opencode/${directory}bridge-plugin.mjs`,
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const outputPath = fileURLToPath(
|
||||
new URL(
|
||||
`../dist/server/server/agent/providers/opencode/${directory}bridge-plugin.bundle.mjs`,
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await build({
|
||||
entryPoints: [sourcePath],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "neutral",
|
||||
mainFields: ["module", "main"],
|
||||
target: "es2022",
|
||||
outfile: outputPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -23,6 +23,10 @@ async function runSupervisorFixture(options: {
|
||||
workerSource: string;
|
||||
restartOnCrash?: boolean;
|
||||
timeoutMs?: number;
|
||||
/** POSIX RLIMIT_FSIZE for the supervisor, in 512-byte blocks: writes past it fail like a full disk. */
|
||||
fileSizeLimitBlocks?: number;
|
||||
/** Occupy the log path with a directory so the supervisor cannot open daemon.log. */
|
||||
blockLogPath?: boolean;
|
||||
}): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
@@ -36,9 +40,12 @@ async function runSupervisorFixture(options: {
|
||||
const workerPath = path.join(tempDir, "worker.mjs");
|
||||
const runnerPath = path.join(tempDir, "runner.mjs");
|
||||
|
||||
if (options.blockLogPath) {
|
||||
await mkdir(logPath);
|
||||
}
|
||||
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,
|
||||
@@ -62,7 +69,18 @@ async function runSupervisorFixture(options: {
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
|
||||
const runnerArgs = [process.execPath, "--import", "tsx", runnerPath];
|
||||
const [command, ...args] =
|
||||
options.fileSizeLimitBlocks === undefined
|
||||
? runnerArgs
|
||||
: [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
`ulimit -f ${options.fileSizeLimitBlocks} && exec "$@"`,
|
||||
"sh",
|
||||
...runnerArgs,
|
||||
];
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -348,4 +366,55 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
},
|
||||
);
|
||||
|
||||
// POSIX-only: RLIMIT_FSIZE is how the test makes daemon.log writes fail.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"keeps supervising the worker when daemon.log can no longer be written",
|
||||
async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
fileSizeLimitBlocks: 128,
|
||||
workerSource: `
|
||||
process.on("message", (message) => {
|
||||
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||
});
|
||||
const line = '{"level":30,"msg":"' + "x".repeat(1000) + '"}\\n';
|
||||
for (let i = 0; i < 200; i += 1) process.stdout.write(line);
|
||||
setTimeout(() => {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "log_write_failure_probe" });
|
||||
}, 1000);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.stderr).not.toContain("Unhandled 'error' event");
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("resumes writing daemon.log once the log path is writable again", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
blockLogPath: true,
|
||||
workerSource: `
|
||||
import { rmdirSync } from "node:fs";
|
||||
|
||||
process.on("message", (message) => {
|
||||
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||
});
|
||||
process.stdout.write("line while daemon.log is blocked\\n");
|
||||
setTimeout(() => {
|
||||
rmdirSync(process.argv[1].replace(/worker\\.mjs$/, "daemon.log"));
|
||||
process.stdout.write("line after daemon.log is writable\\n");
|
||||
setTimeout(() => {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "log_recovery_probe" });
|
||||
}, 200);
|
||||
}, 500);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.log).toContain("line after daemon.log is writable\n");
|
||||
expect(result.log).toContain('"reason":"log_recovery_probe"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { fork, spawn, type ChildProcess } from "child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createStream as createRotatingFileStream } from "rotating-file-stream";
|
||||
import {
|
||||
createStream as createRotatingFileStream,
|
||||
type RotatingFileStream,
|
||||
} from "rotating-file-stream";
|
||||
import { signalProcessTree } from "../src/utils/tree-kill.js";
|
||||
|
||||
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
|
||||
@@ -23,6 +25,7 @@ type WorkerLifecycleMessage =
|
||||
| {
|
||||
type: "paseo:ready";
|
||||
listen: string;
|
||||
serverId: string;
|
||||
}
|
||||
| {
|
||||
type: "paseo:restart";
|
||||
@@ -50,7 +53,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 +81,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;
|
||||
@@ -106,17 +112,58 @@ function toRotatingFileStreamSize(size: string): string {
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
// daemon.log is best effort: a full disk or a replaced log path must not take the
|
||||
// supervisor, and with it the worker, down. A failed stream is dropped and a new one
|
||||
// is opened on the next write, so logging resumes once the file is writable again.
|
||||
function createDurableLog(
|
||||
options: SupervisorLogFileOptions | undefined,
|
||||
reportFailure: (message: string) => void,
|
||||
) {
|
||||
let stream: RotatingFileStream | null = null;
|
||||
let failing = false;
|
||||
|
||||
mkdirSync(path.dirname(options.path), { recursive: true });
|
||||
return createRotatingFileStream(path.basename(options.path), {
|
||||
path: path.dirname(options.path),
|
||||
size: toRotatingFileStreamSize(options.rotate.maxSize),
|
||||
maxFiles: options.rotate.maxFiles,
|
||||
});
|
||||
const open = (logFile: SupervisorLogFileOptions): RotatingFileStream => {
|
||||
const next = createRotatingFileStream(path.basename(logFile.path), {
|
||||
path: path.dirname(logFile.path),
|
||||
size: toRotatingFileStreamSize(logFile.rotate.maxSize),
|
||||
maxFiles: logFile.rotate.maxFiles,
|
||||
});
|
||||
next.on("error", (error) => {
|
||||
if (stream === next) {
|
||||
stream = null;
|
||||
}
|
||||
if (!failing) {
|
||||
failing = true;
|
||||
reportFailure(
|
||||
`Cannot write ${logFile.path}, will retry on the next log line: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
return {
|
||||
write(chunk: string | Buffer): void {
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
stream ??= open(options);
|
||||
stream.write(chunk, (error) => {
|
||||
if (!error) {
|
||||
failing = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
close(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!stream) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
stream.end(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function runSupervisor(options: SupervisorOptions): SupervisorController {
|
||||
@@ -133,10 +180,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
let lifecycleFailed = false;
|
||||
let publication = Promise.resolve();
|
||||
let forceKillTimer: NodeJS.Timeout | null = null;
|
||||
const logStream = createSupervisorLogStream(options.logFile);
|
||||
const durableLog = createDurableLog(options.logFile, (message) => {
|
||||
process.stderr.write(`[${options.name}] ${message}\n`);
|
||||
});
|
||||
|
||||
const writeDurableChunk = (chunk: string | Buffer): void => {
|
||||
logStream?.write(chunk);
|
||||
durableLog.write(chunk);
|
||||
};
|
||||
|
||||
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
|
||||
@@ -157,14 +206,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
writeLifecycleLog(message);
|
||||
};
|
||||
|
||||
const closeLogStream = (): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (!logStream) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
logStream.end(resolve);
|
||||
});
|
||||
const closeLogStream = (): Promise<void> => durableLog.close();
|
||||
|
||||
const exitSupervisor = (code: number): void => {
|
||||
if (exiting) {
|
||||
@@ -289,7 +331,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);
|
||||
|
||||
@@ -11244,3 +11244,129 @@ test("concurrent native restores run once before resuming the same agent", async
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("commits startup notices once on create and after restored history", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-startup-notice-"));
|
||||
const notice: AgentTimelineItem = { type: "notification", level: "info", message: "Runtime v2" };
|
||||
const history: AgentTimelineItem = { type: "assistant_message", text: "Existing conversation" };
|
||||
const toolOutput: AgentTimelineItem = {
|
||||
type: "tool_call",
|
||||
callId: "startup-output",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "print output",
|
||||
output: "x".repeat(1024 * 1024),
|
||||
exitCode: 0,
|
||||
},
|
||||
};
|
||||
const limitedToolOutput: AgentTimelineItem = {
|
||||
...toolOutput,
|
||||
detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 },
|
||||
};
|
||||
class NotifyingSession extends TestAgentSession {
|
||||
readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }];
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: history,
|
||||
timestamp: "2026-09-21T00:00:00.000Z",
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: toolOutput,
|
||||
timestamp: "2026-09-21T01:00:00.000Z",
|
||||
};
|
||||
yield { type: "timeline", provider: "codex", ...this.initialTimeline[0] };
|
||||
}
|
||||
}
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig) {
|
||||
return new NotifyingSession(config);
|
||||
}
|
||||
override async resumeSession() {
|
||||
return new NotifyingSession({ provider: "codex", cwd: workdir });
|
||||
}
|
||||
})();
|
||||
const store = new RecordingTimelineStore();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
durableTimelineStore: store,
|
||||
logger,
|
||||
});
|
||||
const ids: string[] = [];
|
||||
try {
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {});
|
||||
ids.push(created.id);
|
||||
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
|
||||
const resumed = await manager.resumeAgentFromPersistence({
|
||||
provider: "codex",
|
||||
sessionId: "existing",
|
||||
metadata: { cwd: workdir },
|
||||
});
|
||||
ids.push(resumed.id);
|
||||
await manager.hydrateTimelineFromProvider(resumed.id);
|
||||
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
|
||||
history,
|
||||
limitedToolOutput,
|
||||
notice,
|
||||
]);
|
||||
await manager.hydrateTimelineFromProvider(resumed.id, { force: true });
|
||||
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
|
||||
history,
|
||||
limitedToolOutput,
|
||||
notice,
|
||||
]);
|
||||
await manager.flush();
|
||||
expect(store.writes.flat()).toContainEqual(expect.objectContaining({ item: notice }));
|
||||
} finally {
|
||||
for (const id of ids) await manager.closeAgent(id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("failed startup history closes the session without registering an agent", async () => {
|
||||
let closed = false;
|
||||
class FailingHistorySession extends TestAgentSession {
|
||||
readonly initialTimeline = [
|
||||
{
|
||||
item: { type: "notification", level: "info", message: "Runtime v2" } as const,
|
||||
timestamp: "2026-09-22T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "Partial history" },
|
||||
};
|
||||
throw new Error("History unavailable");
|
||||
}
|
||||
override async close() {
|
||||
closed = true;
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async resumeSession() {
|
||||
return new FailingHistorySession({ provider: "codex", cwd: tmpdir() });
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
try {
|
||||
await expect(
|
||||
manager.resumeAgentFromPersistence({
|
||||
provider: "codex",
|
||||
sessionId: "failed-history",
|
||||
metadata: { cwd: tmpdir() },
|
||||
}),
|
||||
).rejects.toThrow("History unavailable");
|
||||
expect({ agents: manager.listAgents(), closed }).toEqual({ agents: [], closed: true });
|
||||
} finally {
|
||||
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3488,11 +3488,31 @@ export class AgentManager {
|
||||
options,
|
||||
});
|
||||
|
||||
// Read history before publishing the agent: a provider failure must leave the
|
||||
// session unregistered so the registration catch closes it.
|
||||
const startupHistory: AgentStreamEvent[] = [];
|
||||
if (session.initialTimeline?.length && !managed.historyPrimed) {
|
||||
for await (const event of session.streamHistory()) {
|
||||
startupHistory.push(limitAgentStreamEventContent(event));
|
||||
}
|
||||
}
|
||||
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
registered = true;
|
||||
// Initialize previousStatus to track transitions
|
||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||
if (session.initialTimeline?.length) {
|
||||
if (!managed.historyPrimed) {
|
||||
// Legacy/imported chats need their existing history before startup rows.
|
||||
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
|
||||
} else {
|
||||
for (const entry of session.initialTimeline) {
|
||||
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
|
||||
}
|
||||
}
|
||||
this.refreshSessionPersistence(managed);
|
||||
}
|
||||
await this.refreshRuntimeInfo(managed, { emit: false });
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
await this.persistSnapshot(managed, {
|
||||
@@ -4023,6 +4043,9 @@ export class AgentManager {
|
||||
private async primeTimelineFromLegacyProviderHistory(
|
||||
agent: ActiveManagedAgent,
|
||||
broadcast: boolean | (() => boolean),
|
||||
history:
|
||||
| AsyncIterable<AgentStreamEvent>
|
||||
| Iterable<AgentStreamEvent> = agent.session.streamHistory(),
|
||||
): Promise<void> {
|
||||
const deferredBroadcast = typeof broadcast === "function";
|
||||
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
||||
@@ -4032,7 +4055,7 @@ export class AgentManager {
|
||||
// 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()) {
|
||||
for await (const rawEvent of history) {
|
||||
const event = limitAgentStreamEventContent(rawEvent);
|
||||
if (event.type === "provider_subagent") {
|
||||
historySubagentEvents.push(event);
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -425,6 +425,11 @@ interface NotifySafelyOptions {
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
// A caller waits on a child through one armed notification. Arming again, such as a
|
||||
// follow-up prompt while the child still runs, replaces the earlier one so the child's
|
||||
// next finish reaches the caller once.
|
||||
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
|
||||
|
||||
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
|
||||
const {
|
||||
agentManager,
|
||||
@@ -440,10 +445,19 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let notificationQueue = Promise.resolve();
|
||||
|
||||
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
|
||||
armedFinishNotifications.set(agentManager, armedByManager);
|
||||
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
|
||||
armedByManager.get(armedKey)?.();
|
||||
armedByManager.set(armedKey, stop);
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unsubscribe?.();
|
||||
if (armedByManager.get(armedKey) === stop) {
|
||||
armedByManager.delete(armedKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function notify(
|
||||
|
||||
@@ -665,6 +665,9 @@ export interface AgentSession {
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { realpathSync, rmSync } from "node:fs";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -13,7 +14,16 @@ import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
|
||||
import {
|
||||
@@ -182,6 +192,16 @@ async function removeTempDir(path: string): Promise<void> {
|
||||
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
async function removeAgentStateDir(
|
||||
agentManager: AgentManager,
|
||||
storage: AgentStorage,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
await agentManager.flush();
|
||||
await storage.flush();
|
||||
await removeTempDir(path);
|
||||
}
|
||||
|
||||
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
|
||||
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
|
||||
|
||||
@@ -3410,7 +3430,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(storedChild?.workspaceId).toBe("wks_parent");
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3664,6 +3684,142 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const HELD_TURN_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsSessionListing: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Provider session that records every prompt it receives. With `holdTurns`, a started
|
||||
* turn stays running until `finishTurn()` so a caller's bounded wait can run out first.
|
||||
*/
|
||||
class HeldTurnAgentSession implements AgentSession {
|
||||
readonly capabilities = HELD_TURN_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
readonly prompts: string[] = [];
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private activeTurnId: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
) {}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
if (!this.holdTurns && this.activeTurnId === turnId) {
|
||||
this.finishTurn();
|
||||
}
|
||||
}, 0);
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
finishTurn(): void {
|
||||
const turnId = this.activeTurnId;
|
||||
if (!turnId) {
|
||||
throw new Error("No held turn to finish");
|
||||
}
|
||||
this.activeTurnId = null;
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private pushEvent(event: AgentStreamEvent): void {
|
||||
for (const callback of this.subscribers) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence() {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
const turnId = this.activeTurnId;
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
this.activeTurnId = null;
|
||||
this.pushEvent({
|
||||
type: "turn_canceled",
|
||||
provider: this.provider,
|
||||
reason: "interrupted",
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class HeldTurnAgentClient implements AgentClient {
|
||||
readonly capabilities = HELD_TURN_CAPABILITIES;
|
||||
readonly sessions: HeldTurnAgentSession[] = [];
|
||||
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(): Promise<AgentSession> {
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async fetchCatalog() {
|
||||
return { models: [], modes: [] };
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
return await this.createSession();
|
||||
}
|
||||
}
|
||||
|
||||
describe("send_agent_prompt MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
const existingCwd = process.cwd();
|
||||
@@ -3807,6 +3963,128 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
expect.objectContaining({ waitForActive: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("notifies the caller when a blocking agent-scoped prompt outlasts the wait", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", true);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await agentManager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "send_agent_prompt");
|
||||
|
||||
vi.useFakeTimers();
|
||||
const pending = invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "Follow up",
|
||||
background: false,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
const response = await pending;
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
status: "running",
|
||||
guidance:
|
||||
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
|
||||
});
|
||||
expect(response.structuredContent.lastMessage).toContain("timed out after 30s");
|
||||
|
||||
childClient.sessions[0]!.finishTurn();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||
expect(parentPrompts).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain(child.id);
|
||||
expect(parentPrompts[0]).toContain("finished");
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
it("notifies the caller once when it prompts a created child that is still running", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-running-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", true);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
|
||||
const created = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Busy Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Run a long command",
|
||||
});
|
||||
const childId = z.object({ agentId: z.string() }).parse(created.structuredContent).agentId;
|
||||
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running"));
|
||||
|
||||
const sent = await invokeToolWithParsedInput(registeredTool(server, "send_agent_prompt"), {
|
||||
agentId: childId,
|
||||
prompt: "Stop and reply instead",
|
||||
});
|
||||
expect(sent.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
status: "running",
|
||||
});
|
||||
const childSession = childClient.sessions[0]!;
|
||||
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2));
|
||||
|
||||
childSession.finishTurn();
|
||||
|
||||
function finishNotifications() {
|
||||
return (parentClient.sessions[0]?.prompts ?? []).filter((prompt) =>
|
||||
prompt.includes(`Agent ${childId} (Busy Child) finished.`),
|
||||
);
|
||||
}
|
||||
await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(finishNotifications()).toHaveLength(1);
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_agent MCP tool", () => {
|
||||
|
||||
@@ -86,6 +86,11 @@ export function resolveRequiredProviderModel(
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentWaitWithTimeoutResult extends WaitForAgentResult {
|
||||
/** The wait gave up while the agent was still working. */
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
|
||||
* Returns a friendly message when timeout occurs, rather than letting
|
||||
@@ -98,7 +103,7 @@ export async function waitForAgentWithTimeout(
|
||||
signal?: AbortSignal;
|
||||
waitForActive?: boolean;
|
||||
},
|
||||
): Promise<WaitForAgentResult> {
|
||||
): Promise<AgentWaitWithTimeoutResult> {
|
||||
const timeoutController = new AbortController();
|
||||
const combinedController = new AbortController();
|
||||
|
||||
@@ -133,7 +138,7 @@ export async function waitForAgentWithTimeout(
|
||||
signal: combinedController.signal,
|
||||
waitForActive: options?.waitForActive,
|
||||
});
|
||||
return result;
|
||||
return { ...result, timedOut: false };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "wait timeout") {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
@@ -150,6 +155,7 @@ export async function waitForAgentWithTimeout(
|
||||
status: snapshot?.lifecycle ?? "idle",
|
||||
permission: null,
|
||||
lastMessage: message,
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { KimiACPAgentClient } from "./providers/kimi-acp-agent.js";
|
||||
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { OpenCodeRuntimeClient } from "./providers/opencode/runtime-client.js";
|
||||
import type { OpenCodeBridge } from "./providers/opencode/bridge.js";
|
||||
import { OmpAgentClient } from "./providers/omp/agent.js";
|
||||
import type { OmpRuntime } from "./providers/omp/runtime.js";
|
||||
@@ -216,7 +216,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
env: runtimeSettings?.env,
|
||||
}),
|
||||
opencode: (logger, runtimeSettings, options) =>
|
||||
new OpenCodeAgentClient(logger, runtimeSettings, {
|
||||
new OpenCodeRuntimeClient(logger, runtimeSettings, {
|
||||
managedProcesses: options?.managedProcesses,
|
||||
bridge: options?.openCodeBridge,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AgentSideConnection,
|
||||
@@ -3521,6 +3524,34 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
});
|
||||
|
||||
describe("ACPAgentSession initialization cleanup", () => {
|
||||
test("rejects a resume whose working directory was deleted instead of crashing", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-deleted-cwd-"));
|
||||
const deletedCwd = path.join(root, "worktree");
|
||||
const terminator = new FakeTerminator();
|
||||
const session = new ACPAgentSession(
|
||||
{ provider: "test-acp", cwd: deletedCwd },
|
||||
{
|
||||
provider: "test-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: [process.execPath, "-e", "process.stdin.resume()"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
},
|
||||
handle: { provider: "test-acp", sessionId: "archived-session" },
|
||||
terminateProcess: terminator.terminate,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(session.initializeResumedSession()).rejects.toThrow("ENOENT");
|
||||
expect(terminator.terminated).toHaveLength(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminates the ACP process when session/new fails", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const child = createProbeChildStub();
|
||||
|
||||
@@ -136,6 +136,17 @@ function assertChildWithPipes(
|
||||
}
|
||||
}
|
||||
|
||||
// Node reports a failed spawn (missing binary or missing cwd) as a child
|
||||
// `error` event; without a listener it becomes an uncaught exception.
|
||||
function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise<never> {
|
||||
return new Promise<never>((_, reject) => {
|
||||
child.once("error", (error) => {
|
||||
const stderr = stderrChunks.join("").trim();
|
||||
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1354,12 +1365,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
stderrChunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
const spawnErrorPromise = new Promise<never>((_, reject) => {
|
||||
child.once("error", (error) => {
|
||||
const stderr = stderrChunks.join("").trim();
|
||||
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
|
||||
});
|
||||
});
|
||||
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
|
||||
const spawnReadyPromise = new Promise<void>((resolve) => {
|
||||
child.once("spawn", () => {
|
||||
resolve();
|
||||
@@ -2732,6 +2738,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderrChunks.push(chunk.toString());
|
||||
});
|
||||
const spawnError = rejectOnSpawnError(child, stderrChunks);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
@@ -2759,14 +2766,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.child = child;
|
||||
this.connection = connection;
|
||||
const initialize = await this.runACPRequest(() =>
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: buildACPClientCapabilities(
|
||||
this.clientCapabilityMeta,
|
||||
this.clientCapabilities,
|
||||
),
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
Promise.race([
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: buildACPClientCapabilities(
|
||||
this.clientCapabilityMeta,
|
||||
this.clientCapabilities,
|
||||
),
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
spawnError,
|
||||
]),
|
||||
);
|
||||
|
||||
return { child, connection, initialize };
|
||||
|
||||
@@ -428,7 +428,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.219",
|
||||
configDir: emptyConfigDir,
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
@@ -475,7 +475,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
resolveVersion: async () => {
|
||||
throw new Error("unrecognized version output");
|
||||
},
|
||||
configDir: emptyConfigDir,
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
@@ -497,7 +497,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.219",
|
||||
configDir: emptyConfigDir,
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { promises } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type AgentDefinition,
|
||||
@@ -83,7 +82,7 @@ import {
|
||||
type ClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
@@ -409,7 +408,6 @@ interface ClaudeAgentClientOptions {
|
||||
queryFactory?: ClaudeQueryFactory;
|
||||
resolveBinary?: () => Promise<string>;
|
||||
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
|
||||
configDir?: string;
|
||||
rewindSdk?: ClaudeRewindSdk;
|
||||
}
|
||||
|
||||
@@ -1506,7 +1504,6 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
|
||||
private readonly configDir?: string;
|
||||
private readonly rewindSdk: ClaudeRewindSdk;
|
||||
|
||||
constructor(options: ClaudeAgentClientOptions) {
|
||||
@@ -1518,7 +1515,6 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.resolveVersion =
|
||||
options.resolveVersion ??
|
||||
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
|
||||
this.configDir = options.configDir;
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
}
|
||||
|
||||
@@ -1592,12 +1588,11 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
|
||||
}
|
||||
const env = this.buildProviderEnv();
|
||||
const models = await runProviderRefreshActivity(context, "settings", () =>
|
||||
getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion),
|
||||
);
|
||||
const modeCatalog = claudeModeCatalog(
|
||||
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
|
||||
getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion),
|
||||
);
|
||||
const modeCatalog = claudeModeCatalog(env);
|
||||
return {
|
||||
models,
|
||||
...modeCatalog,
|
||||
@@ -1605,12 +1600,15 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
|
||||
const env = createProviderEnv({
|
||||
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
|
||||
}
|
||||
|
||||
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
return createProviderEnv({
|
||||
baseEnv: process.env,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
return claudeModeCatalog(env).defaultModeId;
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
@@ -1624,7 +1622,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
const configDir = claudeConfigDir(this.buildProviderEnv());
|
||||
const sessionsRoot = options?.cwd
|
||||
? claudeProjectDirSync(options.cwd, { configDir })
|
||||
: path.join(configDir, "projects");
|
||||
@@ -5051,7 +5049,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private resolveHistoryPath(sessionId: string): string | null {
|
||||
const cwd = this.config.cwd;
|
||||
if (!cwd) return null;
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
const configDir = claudeConfigDir(this.buildSdkEnv());
|
||||
const candidates = [cwd];
|
||||
try {
|
||||
const realCwd = fs.realpathSync(cwd);
|
||||
|
||||
@@ -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,27 @@ 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("reads settings.json from the provider's own CLAUDE_CONFIG_DIR", async () => {
|
||||
const daemonConfigDir = await createClaudeConfigDir({});
|
||||
const providerConfigDir = await createClaudeConfigDir({ model: "glm-5.1" });
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", daemonConfigDir);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => "2.1.280",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
|
||||
});
|
||||
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toContain("glm-5.1");
|
||||
});
|
||||
|
||||
it("falls back to hardcoded models when settings.json is missing", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -14,6 +13,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",
|
||||
@@ -50,7 +50,7 @@ export function findClaudeModel(
|
||||
|
||||
export async function getClaudeModelsWithSettings(
|
||||
logger: Logger,
|
||||
configDir?: string,
|
||||
configDir: string,
|
||||
claudeCodeVersion?: string,
|
||||
): Promise<AgentModelDefinition[]> {
|
||||
const hardcodedModels = getClaudeModels(claudeCodeVersion);
|
||||
@@ -78,9 +78,9 @@ export async function getClaudeModelsWithSettings(
|
||||
|
||||
async function readClaudeSettingsModels(
|
||||
logger: Logger,
|
||||
configDir?: string,
|
||||
configDir: string,
|
||||
): Promise<AgentModelDefinition[]> {
|
||||
const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json");
|
||||
const settingsPath = path.join(configDir, "settings.json");
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -115,10 +115,6 @@ async function readClaudeSettingsModels(
|
||||
return models;
|
||||
}
|
||||
|
||||
function resolveClaudeConfigDir(configDir?: string): string {
|
||||
return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
function addSettingsModel(
|
||||
models: AgentModelDefinition[],
|
||||
value: unknown,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -66,6 +66,11 @@ function hashSuffix(input: string): string {
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
// Claude Code keeps its settings and transcripts here for a process launched with `env`.
|
||||
export function claudeConfigDir(env: NodeJS.ProcessEnv): string {
|
||||
return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
||||
}
|
||||
|
||||
function resolveConfigDir(options?: ClaudeProjectDirOptions): string {
|
||||
return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
||||
return options?.configDir ?? claudeConfigDir(process.env);
|
||||
}
|
||||
|
||||
@@ -171,8 +171,16 @@ function createSession(
|
||||
return session;
|
||||
}
|
||||
|
||||
function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient {
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
function createProviderWithFakeAppServer(
|
||||
appServer: FakeCodexAppServer,
|
||||
options?: {
|
||||
runtimeSettings?: ConstructorParameters<typeof CodexAppServerAgentClient>[1];
|
||||
customProvider?: { id: string; label: string; extends: string };
|
||||
},
|
||||
): CodexAppServerAgentClient {
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
|
||||
customProvider: options?.customProvider,
|
||||
});
|
||||
const internals = castInternals<{
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
@@ -234,6 +242,54 @@ describe("Codex active-turn steering admission", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a steer resolves a Codex skill mentioned after prose into app-server skill input", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"skills/list": () => ({
|
||||
data: [
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
skills: [
|
||||
{
|
||||
name: "paseo-implement",
|
||||
description: "Execute an existing Paseo plan.",
|
||||
path: "/workspace/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
});
|
||||
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn!("Then use /paseo-implement to complete it.", {
|
||||
expectedTurnId: paseoTurnId,
|
||||
}),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
|
||||
const steer = appServer.requests().find((request) => request.method === "turn/steer");
|
||||
expect(steer?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "paseo-implement",
|
||||
path: "/workspace/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Then use $paseo-implement to complete it.",
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a clearing steer denies every pending permission through its provider handler", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
@@ -2225,6 +2281,59 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves a Codex skill mentioned after prose into app-server skill input", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "skills/list") {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
cwd: "/tmp/codex-question-test",
|
||||
skills: [
|
||||
{
|
||||
name: "paseo-implement",
|
||||
description: "Execute an existing Paseo plan.",
|
||||
path: "/tmp/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
|
||||
await session.startTurn("Review the accepted plan, then use /paseo-implement to complete it.");
|
||||
|
||||
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "paseo-implement",
|
||||
path: "/tmp/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Review the accepted plan, then use $paseo-implement to complete it.",
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
|
||||
const commands = await listCommandsFromFakeCodex([
|
||||
{
|
||||
@@ -6212,6 +6321,55 @@ describe("Codex importable sessions", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
// Codex filters thread/list by model provider: with no `modelProviders` it
|
||||
// returns only threads of the provider its own config selects, an empty
|
||||
// list returns every provider, and a list returns only those providers.
|
||||
function providerFilteringThreadListHandler(threads: Array<Record<string, unknown>>) {
|
||||
return (input: unknown) => {
|
||||
const { modelProviders } = (input ?? {}) as { modelProviders?: string[] };
|
||||
const included = (thread: Record<string, unknown>) => {
|
||||
if (modelProviders === undefined) return thread.modelProvider === "openai";
|
||||
if (modelProviders.length === 0) return true;
|
||||
return modelProviders.includes(String(thread.modelProvider));
|
||||
};
|
||||
return { data: threads.filter(included), nextCursor: null };
|
||||
};
|
||||
}
|
||||
|
||||
const threadsByModelProvider = [
|
||||
{ id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 },
|
||||
{ id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 },
|
||||
];
|
||||
|
||||
test("a custom Codex provider lists the threads it created", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer, {
|
||||
runtimeSettings: {
|
||||
env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" },
|
||||
},
|
||||
customProvider: { id: "my-codex", label: "My Codex", extends: "codex" },
|
||||
});
|
||||
|
||||
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("stock Codex keeps listing only its own threads", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer);
|
||||
|
||||
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual(["stock-thread"]);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
|
||||
const allThreads = [
|
||||
{
|
||||
|
||||
@@ -259,7 +259,7 @@ interface CodexAppServerAgentDeps {
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
customCodexConfig?: Record<string, unknown> | null;
|
||||
customCodexConfig?: CodexCustomProviderConfig | null;
|
||||
_createCodexClient?: (
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
logger: Logger,
|
||||
@@ -919,7 +919,7 @@ const CodexModelListResponseSchema = z.object({
|
||||
async function readCodexThreadWindow(
|
||||
client: Pick<CodexAppServerClientLike, "request">,
|
||||
logger: Logger,
|
||||
window: { limit: number; cwd?: string },
|
||||
window: { limit: number; cwd?: string; modelProviders?: string[] },
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
// A thread updated while the scan is paging moves under `updated_at` order
|
||||
// and can come back on a later page, so identify each one and keep it once.
|
||||
@@ -935,6 +935,7 @@ async function readCodexThreadWindow(
|
||||
// Older Codex builds ignore the unknown key and keep that order.
|
||||
sortKey: "updated_at",
|
||||
...(window.cwd ? { cwd: window.cwd } : {}),
|
||||
...(window.modelProviders ? { modelProviders: window.modelProviders } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
}),
|
||||
);
|
||||
@@ -3168,6 +3169,26 @@ function enabledCodexSkills(
|
||||
return Array.from(skillsByName.values());
|
||||
}
|
||||
|
||||
// Composer autocomplete inserts skills as `/name` anywhere in the prompt, while
|
||||
// Codex only injects a skill it receives as a skill input item.
|
||||
const SKILL_MENTION_PATTERN = /(^|\s)\/([^\s/"']+)/g;
|
||||
|
||||
function resolveCodexSkillMentions(
|
||||
text: string,
|
||||
skills: ReadonlyArray<{ name: string; path: string }>,
|
||||
): { skills: CodexSkillPromptBlock[]; text: string } {
|
||||
const mentioned = new Map<string, CodexSkillPromptBlock>();
|
||||
const rewritten = text.replace(SKILL_MENTION_PATTERN, (mention, prefix: string, name: string) => {
|
||||
const skill = skills.find((entry) => entry.name === name);
|
||||
if (!skill) {
|
||||
return mention;
|
||||
}
|
||||
mentioned.set(skill.name, { type: "skill", name: skill.name, path: skill.path });
|
||||
return `${prefix}$${skill.name}`;
|
||||
});
|
||||
return { skills: Array.from(mentioned.values()), text: rewritten };
|
||||
}
|
||||
|
||||
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
|
||||
type CodexPromptInput = string | CodexPromptContentBlock[];
|
||||
interface CodexTextElement {
|
||||
@@ -3278,10 +3299,15 @@ function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
|
||||
return `${withoutTrailingSlashes}/v1`;
|
||||
}
|
||||
|
||||
interface CodexCustomProviderConfig {
|
||||
model_provider: string;
|
||||
model_providers: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function buildCodexCustomProviderConfig(
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
customProvider: CodexAppServerAgentDeps["customProvider"],
|
||||
): Record<string, unknown> | null {
|
||||
): CodexCustomProviderConfig | null {
|
||||
if (customProvider?.extends !== CODEX_PROVIDER) {
|
||||
return null;
|
||||
}
|
||||
@@ -4057,6 +4083,18 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return args ? `$${commandName} ${args}` : `$${commandName}`;
|
||||
}
|
||||
|
||||
private async attachMentionedSkills(prompt: AgentPromptInput): Promise<CodexPromptInput> {
|
||||
if (typeof prompt !== "string" || !prompt.includes("/")) {
|
||||
return prompt;
|
||||
}
|
||||
await this.loadSkills();
|
||||
const resolved = resolveCodexSkillMentions(prompt, this.cachedSkills ?? []);
|
||||
if (resolved.skills.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
return [...resolved.skills, { type: "text", text: resolved.text }];
|
||||
}
|
||||
|
||||
private async buildTurnStartParams(
|
||||
prompt: CodexPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
@@ -4260,7 +4298,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
: await this.attachMentionedSkills(prompt);
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
@@ -4327,7 +4365,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const input = await this.buildUserInput(prompt);
|
||||
const input = await this.buildUserInput(await this.attachMentionedSkills(prompt));
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
@@ -7021,13 +7059,14 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private sessionDeps(): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
customCodexConfig: buildCodexCustomProviderConfig(
|
||||
this.runtimeSettings,
|
||||
this.deps.customProvider,
|
||||
),
|
||||
customCodexConfig: this.customProviderConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
private customProviderConfig(): CodexCustomProviderConfig | null {
|
||||
return buildCodexCustomProviderConfig(this.runtimeSettings, this.deps.customProvider);
|
||||
}
|
||||
|
||||
private resolveGoalsEnabled(): Promise<boolean> {
|
||||
if (!this.goalsEnabledPromise) {
|
||||
this.goalsEnabledPromise = (async () => {
|
||||
@@ -7191,9 +7230,14 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
// filtering since most threads will be from other cwds, then keep the
|
||||
// local realpath-aware filter for symlink-equivalent workspace paths.
|
||||
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
|
||||
// Codex records each thread under the model provider that ran it and,
|
||||
// unless told otherwise, lists only the provider its own config selects.
|
||||
// A custom provider runs under its own id, so ask for exactly that one.
|
||||
const customProviderConfig = this.customProviderConfig();
|
||||
const allThreads = await readCodexThreadWindow(client, this.logger, {
|
||||
limit: listLimit,
|
||||
cwd: options?.cwd,
|
||||
...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}),
|
||||
});
|
||||
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
|
||||
return threads.slice(0, limit).map((thread) => {
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
|
||||
// Runs the real `codex` binary against a local Responses endpoint standing in
|
||||
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
|
||||
|
||||
function sse(events: Array<Record<string, unknown>>): string {
|
||||
return events
|
||||
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function finalAnswer(): string {
|
||||
return sse([
|
||||
{ type: "response.created", response: { id: "resp-1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg-1",
|
||||
content: [{ type: "output_text", text: "done" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp-1",
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
input_tokens_details: null,
|
||||
output_tokens: 0,
|
||||
output_tokens_details: null,
|
||||
total_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function startResponsesEndpoint(): Promise<{
|
||||
baseUrl: string;
|
||||
server: Server;
|
||||
requestCount: () => number;
|
||||
}> {
|
||||
let requests = 0;
|
||||
const server = createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
requests += 1;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.end(finalAnswer());
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("no server address");
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
server,
|
||||
requestCount: () => requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
|
||||
describe("Codex custom provider import list", () => {
|
||||
const cleanups: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
|
||||
});
|
||||
|
||||
test("lists a custom provider's session under that provider and imports it there", async () => {
|
||||
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
|
||||
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
|
||||
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
|
||||
cleanups.push(() => closeServer(server));
|
||||
|
||||
const logger = createTestLogger();
|
||||
const customCodex = new CodexAppServerAgentClient(
|
||||
logger,
|
||||
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
|
||||
{ customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } },
|
||||
);
|
||||
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
|
||||
|
||||
const session = await customCodex.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
model: "mock-model",
|
||||
});
|
||||
try {
|
||||
await session.run("hello from the custom provider");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
const customSessions = await customCodex.listImportableSessions({ cwd });
|
||||
const stockSessions = await stockCodex.listImportableSessions({ cwd });
|
||||
|
||||
expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([
|
||||
"hello from the custom provider",
|
||||
]);
|
||||
expect(stockSessions).toEqual([]);
|
||||
|
||||
const config = { provider: "codex", cwd, model: "mock-model" };
|
||||
const imported = await customCodex.importSession(
|
||||
{ providerHandleId: customSessions[0].providerHandleId, cwd },
|
||||
{ config, storedConfig: config },
|
||||
);
|
||||
const requestsBeforeImportedTurn = requestCount();
|
||||
try {
|
||||
await imported.session.run("hello again after import");
|
||||
} finally {
|
||||
await imported.session.close();
|
||||
}
|
||||
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient slash commands", () => {
|
||||
test("lists commands an agent advertises right after session/new", async () => {
|
||||
await withFakeACPAgent("commands-after-session-new", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command,
|
||||
// Long enough that the update always lands inside the wait, however slowly
|
||||
// the fake agent is scheduled.
|
||||
initialCommandsWaitTimeoutMs: 60_000,
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([
|
||||
{ name: "review", description: "Review the diff", argumentHint: "", kind: "command" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("answers with no commands for an agent that never advertises any", async () => {
|
||||
await withFakeACPAgent("silent", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command,
|
||||
initialCommandsWaitTimeoutMs: 50,
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withFakeACPAgent(
|
||||
mode: "commands-after-session-new" | "silent",
|
||||
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-commands-"));
|
||||
try {
|
||||
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
|
||||
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
|
||||
await run([process.execPath, scriptPath, mode], testDir);
|
||||
} finally {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const fakeACPAgentScript = `
|
||||
const readline = require("node:readline");
|
||||
|
||||
const mode = process.argv[2];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
||||
function write(message) {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
write({
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: message.params?.protocolVersion ?? 1,
|
||||
agentCapabilities: { sessionCapabilities: { close: {} } },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "session/new") {
|
||||
write({ id: message.id, result: { sessionId: "session-1" } });
|
||||
if (mode === "commands-after-session-new") {
|
||||
setTimeout(() => {
|
||||
write({
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "review", description: "Review the diff" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
write({ id: message.id, result: {} });
|
||||
}
|
||||
});
|
||||
`;
|
||||
@@ -62,6 +62,7 @@ describe("GenericACPAgentClient", () => {
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
},
|
||||
waitForInitialCommands: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,9 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
},
|
||||
defaultCommand: options.command,
|
||||
capabilities: buildGenericACPCapabilities(providerParams),
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
// ACP agents advertise slash commands with available_commands_update after
|
||||
// session/new, so the first listCommands() waits for that batch.
|
||||
waitForInitialCommands: options.waitForInitialCommands ?? true,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
clientCapabilities: providerParams.clientCapabilities,
|
||||
clientCapabilityMeta: options.clientCapabilityMeta,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderParams } from "./provider-config.js";
|
||||
import { OmpRuntimeEventSchema } from "./rpc-types.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
|
||||
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
|
||||
@@ -42,7 +43,6 @@ test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both",
|
||||
rpcTimeoutMs: 90_000,
|
||||
});
|
||||
});
|
||||
|
||||
class ManualIdleScheduler implements OmpProviderIdleScheduler {
|
||||
private readonly retries: Array<() => void> = [];
|
||||
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
@@ -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();
|
||||
@@ -563,6 +582,106 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps legacy select options without descriptions and preserves ordinary responses", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
omp.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "select-legacy",
|
||||
method: "select",
|
||||
title: "Choose",
|
||||
options: ["Approve", "Deny"],
|
||||
});
|
||||
|
||||
expect(omp.pendingPermissions()[0]?.input).toMatchObject({
|
||||
questions: [{ options: [{ label: "Approve" }, { label: "Deny" }] }],
|
||||
});
|
||||
await omp.respondToPermission("select-legacy", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Response: "Deny" } },
|
||||
});
|
||||
expect(omp.extensionUiResponses()).toContainEqual({
|
||||
id: "select-legacy",
|
||||
response: { value: "Deny" },
|
||||
});
|
||||
});
|
||||
|
||||
test("maps described and mixed select metadata by option index", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
omp.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "select-described",
|
||||
method: "select",
|
||||
title: "Choose",
|
||||
options: ["First", "Second", "Third"],
|
||||
optionDetails: [{ description: "First detail" }, {}, { description: " \t" }],
|
||||
});
|
||||
|
||||
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([
|
||||
{ label: "First", description: "First detail" },
|
||||
{ label: "Second" },
|
||||
{ label: "Third" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts malformed or misaligned optional metadata and falls back to labels", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
const parsed = OmpRuntimeEventSchema.safeParse({
|
||||
type: "extension_ui_request",
|
||||
id: "select-malformed",
|
||||
method: "select",
|
||||
options: ["First", "Second"],
|
||||
optionDetails: [{ description: 42 }, { description: "\n\t" }, { description: "extra" }],
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (!parsed.success) throw new Error("Expected malformed metadata event to parse");
|
||||
|
||||
omp.emit(parsed.data);
|
||||
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([
|
||||
{ label: "First" },
|
||||
{ label: "Second" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves combined selection descriptions and freeform sentinel behavior", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
omp.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "ask-user-1",
|
||||
toolName: "ask_user",
|
||||
args: { allowComment: true, allowFreeform: true, allowMultiple: false },
|
||||
});
|
||||
omp.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "select-combined",
|
||||
method: "select",
|
||||
title: "Choose",
|
||||
options: ["First", "✏️ Type custom response..."],
|
||||
optionDetails: [{ description: "First detail" }, { description: "ignored" }],
|
||||
});
|
||||
|
||||
const combinedInput = omp.pendingPermissions()[0]?.input;
|
||||
expect(combinedInput?.questions?.[0]?.options).toStrictEqual([
|
||||
{ label: "First", description: "First detail" },
|
||||
]);
|
||||
expect(combinedInput).toMatchObject({
|
||||
questions: [{ allowOther: true }, { header: "Comment" }],
|
||||
});
|
||||
await omp.respondToPermission("select-combined", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Response: "custom", Comment: "note" } },
|
||||
});
|
||||
expect(omp.extensionUiResponses()).toContainEqual({
|
||||
id: "select-combined",
|
||||
response: { value: "✏️ Type custom response..." },
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes OMP modes and commands through the domain session", async () => {
|
||||
const omp = new OmpHarness();
|
||||
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
|
||||
|
||||
@@ -568,6 +568,24 @@ function getInputQuestionTitle(title: string | undefined, placeholder: string |
|
||||
return "Optional response";
|
||||
}
|
||||
|
||||
interface OmpSelectOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function readSelectOptions(options: unknown, optionDetails: unknown): OmpSelectOption[] {
|
||||
const labels = readStringArray(options);
|
||||
const details = Array.isArray(optionDetails) ? optionDetails : [];
|
||||
return labels.map((label, index) => {
|
||||
const detail = details[index];
|
||||
const description =
|
||||
isRecord(detail) && typeof detail.description === "string" && detail.description.trim() !== ""
|
||||
? detail.description
|
||||
: undefined;
|
||||
return description === undefined ? { label } : { label, description };
|
||||
});
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
@@ -586,7 +604,7 @@ function mapExtensionUiRequestToPermission(
|
||||
const label = options.label ?? "OMP";
|
||||
switch (event.method) {
|
||||
case "select": {
|
||||
const selectOptions = readStringArray(event.options);
|
||||
const selectOptions = readSelectOptions(event.options, event.optionDetails);
|
||||
if (options.combineOptionalComment) {
|
||||
return buildCombinedAskUserQuestionPermission(event, {
|
||||
provider,
|
||||
@@ -633,7 +651,7 @@ function mapExtensionUiRequestToPermission(
|
||||
question: [optionalString(event.title), optionalString(event.message)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
options: ["Yes", "No"],
|
||||
options: [{ label: "Yes" }, { label: "No" }],
|
||||
multiSelect: false,
|
||||
});
|
||||
default:
|
||||
@@ -678,7 +696,7 @@ function buildExtensionUiQuestionPermission(
|
||||
provider: AgentProvider;
|
||||
label: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
options: OmpSelectOption[];
|
||||
multiSelect: boolean;
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
@@ -696,7 +714,10 @@ function buildExtensionUiQuestionPermission(
|
||||
{
|
||||
question: input.question,
|
||||
header: QUESTION_RESPONSE_HEADER,
|
||||
options: input.options.map((label) => ({ label })),
|
||||
options: input.options.map((option) => ({
|
||||
label: option.label,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
})),
|
||||
multiSelect: input.multiSelect,
|
||||
...(input.placeholder ? { placeholder: input.placeholder } : {}),
|
||||
...(input.allowEmpty ? { allowEmpty: true } : {}),
|
||||
@@ -717,11 +738,13 @@ function buildCombinedAskUserQuestionPermission(
|
||||
provider: AgentProvider;
|
||||
label: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
options: OmpSelectOption[];
|
||||
allowFreeform: boolean;
|
||||
},
|
||||
): AgentPermissionRequest {
|
||||
const visibleOptions = input.options.filter((option) => !isOmpAskUserFreeformOption(option));
|
||||
const visibleOptions = input.options.filter(
|
||||
(option) => !isOmpAskUserFreeformOption(option.label),
|
||||
);
|
||||
const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length;
|
||||
return {
|
||||
id: event.id,
|
||||
@@ -734,7 +757,10 @@ function buildCombinedAskUserQuestionPermission(
|
||||
{
|
||||
question: input.question,
|
||||
header: QUESTION_RESPONSE_HEADER,
|
||||
options: visibleOptions.map((label) => ({ label })),
|
||||
options: visibleOptions.map((option) => ({
|
||||
label: option.label,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
})),
|
||||
multiSelect: false,
|
||||
...(allowOther ? { allowOther: true } : {}),
|
||||
},
|
||||
@@ -753,7 +779,7 @@ function buildCombinedAskUserQuestionPermission(
|
||||
answerHeader: QUESTION_RESPONSE_HEADER,
|
||||
commentHeader: QUESTION_COMMENT_HEADER,
|
||||
combinedAskUser: COMBINED_ASK_USER_METADATA,
|
||||
selectOptions: visibleOptions,
|
||||
selectOptions: visibleOptions.map((option) => option.label),
|
||||
...(allowOther ? { freeformSentinel: OMP_ASK_USER_FREEFORM_SENTINEL } : {}),
|
||||
},
|
||||
};
|
||||
@@ -2033,9 +2059,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!this.activeTurnHasUserMessage) {
|
||||
this.completeTurn(turnId, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -444,6 +444,7 @@ const OmpExtensionUiRequestSchema = z
|
||||
title: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
optionDetails: z.unknown().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
launchUrl: z.string().optional(),
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type OmpProviderIdleScheduler,
|
||||
} from "../agent.js";
|
||||
import type { OmpUsagePollScheduler } from "../usage-poller.js";
|
||||
import type { OmpAgentMessage, OmpRpcSlashCommand } from "../rpc-types.js";
|
||||
import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js";
|
||||
import { FakeOmp } from "./fake-omp.js";
|
||||
|
||||
const CWD = "/tmp/paseo-omp-agent-test";
|
||||
@@ -422,6 +422,10 @@ export class OmpHarness {
|
||||
this.omp.latestSession().requestToolApproval(input);
|
||||
}
|
||||
|
||||
emit(event: OmpRuntimeEvent): void {
|
||||
this.omp.latestSession().emit(event);
|
||||
}
|
||||
|
||||
pendingPermissions() {
|
||||
return this.requireSession().getPendingPermissions();
|
||||
}
|
||||
|
||||
@@ -169,6 +169,105 @@ test("orders queued live deltas behind an incomplete recovery snapshot and suppr
|
||||
await upstream.close();
|
||||
});
|
||||
|
||||
test("sends the next turn to the new OpenCode server after the old one exits", async () => {
|
||||
const exited = await createRecoveryUpstream();
|
||||
const current = await createRecoveryUpstream();
|
||||
const ports = [exited.port, current.port];
|
||||
const processes: RecoveryServerProcess[] = [];
|
||||
const manager = new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
portAllocator: async () => ports.shift() as number,
|
||||
resolveCommandPrefix: async () => ({ command: "opencode", args: [] }),
|
||||
resolveHomeDir: () => process.cwd(),
|
||||
spawnServerProcess: () => {
|
||||
const serverProcess = new RecoveryServerProcess();
|
||||
processes.push(serverProcess);
|
||||
return serverProcess as unknown as ChildProcess;
|
||||
},
|
||||
terminateProcess: async (serverProcess) => {
|
||||
(serverProcess as unknown as RecoveryServerProcess).exit();
|
||||
return "terminated";
|
||||
},
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: manager,
|
||||
createClient: ({ baseUrl, directory }) => createOpencodeClient({ baseUrl, directory }),
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/workspace" });
|
||||
const observed: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => observed.push(event));
|
||||
await exited.connected(1);
|
||||
exited.send(0, connectedRecord());
|
||||
processes[0]?.exit();
|
||||
await exited.close();
|
||||
|
||||
const turn = session.startTurn("after restart");
|
||||
await current.connected(1);
|
||||
current.send(0, connectedRecord());
|
||||
await turn;
|
||||
await current.dispatched(1);
|
||||
current.send(0, idleRecord());
|
||||
|
||||
await eventually(() => expect(observed.map((event) => event.type)).toContain("turn_completed"));
|
||||
expect(observed.filter((event) => event.type === "turn_failed")).toHaveLength(0);
|
||||
|
||||
await session.close();
|
||||
await manager.shutdown();
|
||||
await current.close();
|
||||
});
|
||||
|
||||
test("adds the session's MCP servers to the new OpenCode server after the old one exits", async () => {
|
||||
const exited = await createRecoveryUpstream();
|
||||
const current = await createRecoveryUpstream();
|
||||
const ports = [exited.port, current.port];
|
||||
const processes: RecoveryServerProcess[] = [];
|
||||
const manager = new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
portAllocator: async () => ports.shift() as number,
|
||||
resolveCommandPrefix: async () => ({ command: "opencode", args: [] }),
|
||||
resolveHomeDir: () => process.cwd(),
|
||||
spawnServerProcess: () => {
|
||||
const serverProcess = new RecoveryServerProcess();
|
||||
processes.push(serverProcess);
|
||||
return serverProcess as unknown as ChildProcess;
|
||||
},
|
||||
terminateProcess: async (serverProcess) => {
|
||||
(serverProcess as unknown as RecoveryServerProcess).exit();
|
||||
return "terminated";
|
||||
},
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: manager,
|
||||
createClient: ({ baseUrl, directory }) => createOpencodeClient({ baseUrl, directory }),
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/workspace",
|
||||
mcpServers: { paseo: { type: "http", url: "http://127.0.0.1:1/mcp" } },
|
||||
});
|
||||
await exited.connected(1);
|
||||
exited.send(0, connectedRecord());
|
||||
const firstTurn = session.startTurn("before restart");
|
||||
await exited.dispatched(1);
|
||||
exited.send(0, idleRecord());
|
||||
await firstTurn;
|
||||
expect(exited.mcpAdds()).toEqual(["paseo"]);
|
||||
processes[0]?.exit();
|
||||
await exited.close();
|
||||
|
||||
const turn = session.startTurn("after restart");
|
||||
await current.connected(1);
|
||||
current.send(0, connectedRecord());
|
||||
await turn;
|
||||
await current.dispatched(1);
|
||||
|
||||
expect(current.mcpAdds()).toEqual(["paseo"]);
|
||||
|
||||
await session.close();
|
||||
await manager.shutdown();
|
||||
await current.close();
|
||||
});
|
||||
|
||||
class RecoveryTiming implements OpenCodeEventConsumerTiming {
|
||||
private resolveWait: (() => void) | null = null;
|
||||
arm(): () => void {
|
||||
@@ -240,6 +339,7 @@ class RecoveryServerProcess extends EventEmitter {
|
||||
async function createRecoveryUpstream() {
|
||||
const streams: ServerResponse[] = [];
|
||||
const dispatchIds: string[] = [];
|
||||
const mcpAddNames: string[] = [];
|
||||
let messages: unknown[] = [];
|
||||
let messageReadCount = 0;
|
||||
let status: "busy" | "idle" = "idle";
|
||||
@@ -254,6 +354,11 @@ async function createRecoveryUpstream() {
|
||||
if (request.method === "POST" && pathname === "/session") {
|
||||
return json(response, { id: "session-1", directory: "/workspace" });
|
||||
}
|
||||
if (request.method === "POST" && pathname === "/mcp") {
|
||||
const body = JSON.parse(await readBody(request)) as { name: string };
|
||||
mcpAddNames.push(body.name);
|
||||
return json(response, {});
|
||||
}
|
||||
if (request.url?.includes("/prompt_async")) {
|
||||
const body = JSON.parse(await readBody(request)) as { messageID: string };
|
||||
dispatchIds.push(body.messageID);
|
||||
@@ -285,6 +390,7 @@ async function createRecoveryUpstream() {
|
||||
return [...dispatchIds];
|
||||
},
|
||||
messageReads: () => messageReadCount,
|
||||
mcpAdds: () => [...mcpAddNames],
|
||||
setMessages(nextMessages: unknown[]) {
|
||||
messages = nextMessages;
|
||||
},
|
||||
|
||||
@@ -1944,6 +1944,75 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("streamHistory hides user text that OpenCode marks synthetic", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
data: { revert: undefined },
|
||||
error: undefined,
|
||||
}),
|
||||
messages: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg_continue", sessionID: "ses_unit_test", role: "user" },
|
||||
parts: [
|
||||
{
|
||||
id: "prt_continue",
|
||||
sessionID: "ses_unit_test",
|
||||
messageID: "msg_continue",
|
||||
type: "text",
|
||||
text: "Summarize the task tool output above and continue with your task.",
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: { id: "msg_user", sessionID: "ses_unit_test", role: "user" },
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user",
|
||||
sessionID: "ses_unit_test",
|
||||
messageID: "msg_user",
|
||||
type: "text",
|
||||
text: "Read the notes",
|
||||
},
|
||||
{
|
||||
id: "prt_resource",
|
||||
sessionID: "ses_unit_test",
|
||||
messageID: "msg_user",
|
||||
type: "text",
|
||||
text: "Reading MCP resource: notes.md",
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
}),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{ provider: "opencode", cwd: "/tmp/test" },
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
}
|
||||
|
||||
expect(history).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "user_message", text: "Read the notes", messageId: "msg_user" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
@@ -2385,49 +2454,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 +6794,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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1318,7 +1318,10 @@ function buildOpenCodeReplayTimelineEvents(
|
||||
}
|
||||
if (info.role === "user") {
|
||||
const text = parts
|
||||
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
|
||||
.filter(
|
||||
(part): part is Extract<OpenCodePart, { type: "text" }> =>
|
||||
part.type === "text" && isUserAuthoredOpenCodeText(part),
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
@@ -1445,19 +1448,26 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.acquireServer(openCodeConfig, launchContext);
|
||||
const { url } = acquisition.server;
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext);
|
||||
const connection = await connectServer();
|
||||
const { client } = connection;
|
||||
|
||||
// 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,
|
||||
@@ -1482,16 +1492,17 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
session.id,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.events,
|
||||
acquisition.release,
|
||||
connection.events,
|
||||
connection.release,
|
||||
options?.persistSession,
|
||||
launchContext?.agentId,
|
||||
url,
|
||||
connection.url,
|
||||
false,
|
||||
unbindBridge,
|
||||
connectServer,
|
||||
);
|
||||
} catch (error) {
|
||||
await acquisition.release();
|
||||
await connection.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1518,15 +1529,14 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
const registeredAcquisition = registeredServerUrl
|
||||
? this.serverManager.acquireExisting(registeredServerUrl)
|
||||
: null;
|
||||
const acquisition =
|
||||
registeredAcquisition ?? (await this.acquireServer(openCodeConfig, launchContext));
|
||||
const { url } = acquisition.server;
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext);
|
||||
const connection = registeredAcquisition
|
||||
? this.toServerConnection(registeredAcquisition, openCodeConfig.cwd)
|
||||
: await connectServer();
|
||||
const { client } = connection;
|
||||
|
||||
try {
|
||||
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext);
|
||||
|
||||
@@ -1536,20 +1546,58 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.events,
|
||||
acquisition.release,
|
||||
connection.events,
|
||||
connection.release,
|
||||
undefined,
|
||||
launchContext?.agentId,
|
||||
url,
|
||||
connection.url,
|
||||
registeredAcquisition !== null,
|
||||
unbindBridge,
|
||||
connectServer,
|
||||
);
|
||||
} catch (error) {
|
||||
await acquisition.release();
|
||||
await connection.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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 async connectServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<OpenCodeServerConnection> {
|
||||
const acquisition = await this.acquireServer(config, launchContext);
|
||||
return this.toServerConnection(acquisition, config.cwd);
|
||||
}
|
||||
|
||||
private toServerConnection(
|
||||
acquisition: OpenCodeServerAcquisition,
|
||||
directory: string,
|
||||
): OpenCodeServerConnection {
|
||||
return {
|
||||
client: this.createOpenCodeClient({ baseUrl: acquisition.server.url, directory }),
|
||||
events: acquisition.events,
|
||||
url: acquisition.server.url,
|
||||
release: acquisition.release,
|
||||
};
|
||||
}
|
||||
|
||||
private acquireServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -2809,6 +2857,11 @@ function shouldSuppressOpenCodeAssistantPart(
|
||||
);
|
||||
}
|
||||
|
||||
// OpenCode marks text it adds to a user message itself with `synthetic` and hides it from its own UI.
|
||||
function isUserAuthoredOpenCodeText(part: { synthetic?: boolean }): boolean {
|
||||
return part.synthetic !== true;
|
||||
}
|
||||
|
||||
function appendOpenCodeTextPart(
|
||||
part: Extract<
|
||||
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||
@@ -2819,7 +2872,11 @@ function appendOpenCodeTextPart(
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (messageRole === "user") {
|
||||
if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) {
|
||||
if (
|
||||
!part.text ||
|
||||
!isUserAuthoredOpenCodeText(part) ||
|
||||
state.emittedUserMessageIds?.has(part.messageID)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.emittedUserMessageIds?.add(part.messageID);
|
||||
@@ -3295,12 +3352,24 @@ async function listOpenCodeChildSessions(
|
||||
return readOpenCodeChildSessionInfosFromResponse(sessionIdResponse) ?? [];
|
||||
}
|
||||
|
||||
/** One OpenCode server generation a session talks to. */
|
||||
interface OpenCodeServerConnection {
|
||||
client: OpencodeClient;
|
||||
events: OpenCodeEventSource;
|
||||
url: string;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
class OpenCodeAgentSession implements AgentSession {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
|
||||
private readonly config: OpenCodeAgentConfig;
|
||||
private readonly client: OpencodeClient;
|
||||
private server: OpenCodeServerConnection;
|
||||
/** Connects to the server generation that is current now. */
|
||||
private readonly connectServer: (() => Promise<OpenCodeServerConnection>) | null;
|
||||
private serverExited = false;
|
||||
private reconnection: Promise<void> | null = null;
|
||||
private readonly sessionId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
|
||||
@@ -3310,8 +3379,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private abortController: AbortController | null = null;
|
||||
private accumulatedUsage: AgentUsage = {};
|
||||
private sessionTotalCostUsd: number | undefined;
|
||||
private mcpConfigured = false;
|
||||
private mcpSetupPromise: Promise<void> | null = null;
|
||||
private mcpSetup: Promise<void> | null = null;
|
||||
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
||||
private pendingUserMessageText: string | null = null;
|
||||
private pendingClientMessageId: string | null = null;
|
||||
@@ -3358,7 +3426,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private childHydrationCompleted = false;
|
||||
private readonly unrelatedSessionIds = new Set<string>();
|
||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||
private releaseServer: (() => Promise<void>) | null;
|
||||
private releaseBridge: (() => void) | null;
|
||||
private ingress = Promise.resolve();
|
||||
private gapRepairRevision = 0;
|
||||
@@ -3373,30 +3440,45 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
private readonly events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE,
|
||||
releaseServer?: () => Promise<void>,
|
||||
events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE,
|
||||
releaseServer: () => Promise<void> = async () => undefined,
|
||||
persistSession = true,
|
||||
private readonly agentId?: string,
|
||||
private readonly serverUrl?: string,
|
||||
serverUrl?: string,
|
||||
private readonly externallyDriven = false,
|
||||
releaseBridge?: () => void,
|
||||
connectServer?: () => Promise<OpenCodeServerConnection>,
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.server = { client, events, url: serverUrl ?? "", release: releaseServer };
|
||||
this.connectServer = connectServer ?? null;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger.child({ agentId: this.agentId });
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.autoAcceptEnabled = !config.toolPolicy && isOpenCodeAutoAcceptEnabled(config);
|
||||
this.releaseServer = releaseServer ?? null;
|
||||
this.releaseBridge = releaseBridge ?? null;
|
||||
this.persistSession = persistSession;
|
||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||
config.model,
|
||||
);
|
||||
this.subscribeServerEvents();
|
||||
}
|
||||
|
||||
private get client(): OpencodeClient {
|
||||
return this.server.client;
|
||||
}
|
||||
|
||||
private get events(): OpenCodeEventSource {
|
||||
return this.server.events;
|
||||
}
|
||||
|
||||
private subscribeServerEvents(): void {
|
||||
this.unsubscribeEvents = this.events.subscribe((input) => {
|
||||
if ("type" in input && input.type === "server-exited")
|
||||
if ("type" in input && input.type === "server-exited") {
|
||||
this.recoveryAbortController.abort(input.error);
|
||||
this.serverExited = true;
|
||||
}
|
||||
this.ingress = this.ingress
|
||||
.then(() => this.consumeEventSourceInput(input))
|
||||
.catch((error) => {
|
||||
@@ -3408,6 +3490,47 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenCode session outlives the server process that served it, and Paseo starts the
|
||||
* next server on a new port. Move to the current server before talking to OpenCode again,
|
||||
* so the session does not keep calling a port nothing listens on.
|
||||
*/
|
||||
private async reconnectIfServerExited(): Promise<void> {
|
||||
if (!this.serverExited || !this.connectServer) return;
|
||||
this.reconnection ??= this.reconnect(this.connectServer).finally(() => {
|
||||
this.reconnection = null;
|
||||
});
|
||||
await this.reconnection;
|
||||
}
|
||||
|
||||
private async reconnect(connectServer: () => Promise<OpenCodeServerConnection>): Promise<void> {
|
||||
const exited = this.server;
|
||||
const next = await connectServer();
|
||||
if (this.closed) {
|
||||
await next.release();
|
||||
return;
|
||||
}
|
||||
this.unsubscribeEvents?.();
|
||||
this.unsubscribeEvents = null;
|
||||
// Let the exited server's queued events, including its exit, settle while no turn is
|
||||
// running, so none of them can fail a turn started on the new server.
|
||||
await this.ingress;
|
||||
if (this.closed) {
|
||||
await next.release();
|
||||
return;
|
||||
}
|
||||
this.server = next;
|
||||
this.serverExited = false;
|
||||
this.recoveryAbortController = new AbortController();
|
||||
this.mcpSetup = null;
|
||||
this.subscribeServerEvents();
|
||||
this.logger.info(
|
||||
{ sessionId: this.sessionId, previousUrl: exited.url, url: next.url },
|
||||
"OpenCode session moved to the current server after its server exited",
|
||||
);
|
||||
await exited.release();
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
@@ -3510,10 +3633,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 +3644,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
messageID: promptId,
|
||||
parts,
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
@@ -3578,6 +3696,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
await this.reconnectIfServerExited();
|
||||
await revertOpenCodeConversationAndFiles({
|
||||
client: this.client,
|
||||
sessionId: this.sessionId,
|
||||
@@ -3702,6 +3821,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
if (this.turnState.status === "running") {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
await this.reconnectIfServerExited();
|
||||
try {
|
||||
await this.awaitRunnerQuiescence();
|
||||
} catch (error) {
|
||||
@@ -3850,10 +3970,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 +3984,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
: {}),
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
@@ -4213,8 +4328,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
if (event.event.cwd) {
|
||||
this.childSessionCwds.set(event.event.id, event.event.cwd);
|
||||
}
|
||||
if (this.serverUrl) {
|
||||
registerOpenCodeChildSessionServerUrl(event.event.id, this.serverUrl);
|
||||
if (this.server.url) {
|
||||
registerOpenCodeChildSessionServerUrl(event.event.id, this.server.url);
|
||||
}
|
||||
} else if (event.event.type === "remove") {
|
||||
unregisterOpenCodeChildSessionServerUrl(event.event.id);
|
||||
@@ -4746,6 +4861,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.reconnectIfServerExited();
|
||||
const sessionResponse = await this.client.session.get({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -4775,6 +4891,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return this.availableModesCache;
|
||||
}
|
||||
|
||||
await this.reconnectIfServerExited();
|
||||
const response = await openCodeMetadataLimit(() =>
|
||||
this.client.app.agents({
|
||||
directory: this.config.cwd,
|
||||
@@ -4793,6 +4910,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
await this.reconnectIfServerExited();
|
||||
return await listOpenCodeCommandsFromSdk(this.client, this.config.cwd);
|
||||
}
|
||||
|
||||
@@ -4910,8 +5028,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
} finally {
|
||||
this.releaseBridge?.();
|
||||
this.releaseBridge = null;
|
||||
await this.releaseServer?.();
|
||||
this.releaseServer = null;
|
||||
await this.server.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4987,25 +5104,18 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async ensureMcpServersConfigured(): Promise<void> {
|
||||
if (this.mcpConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mcpServers = this.config.mcpServers;
|
||||
if (!mcpServers || Object.keys(mcpServers).length === 0) {
|
||||
this.mcpConfigured = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.mcpSetupPromise) {
|
||||
this.mcpSetupPromise = this.configureMcpServers(mcpServers);
|
||||
}
|
||||
|
||||
const setup = (this.mcpSetup ??= this.configureMcpServers(mcpServers));
|
||||
try {
|
||||
await this.mcpSetupPromise;
|
||||
this.mcpConfigured = true;
|
||||
await setup;
|
||||
} catch (error) {
|
||||
this.mcpSetupPromise = null;
|
||||
if (this.mcpSetup === setup) {
|
||||
this.mcpSetup = null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OpenCodeRuntimeClient } from "./opencode/runtime-client.js";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -5,11 +6,64 @@ import path from "node:path";
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { PaseoToolCatalog } from "../tools/types.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js";
|
||||
import { OpenCodeBridge } from "./opencode/bridge.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
import {
|
||||
drainPersistedTimeline,
|
||||
readAssistantText,
|
||||
requireSessionId,
|
||||
} from "./opencode/test-utils/v2-local-e2e-helpers.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-"));
|
||||
@@ -40,7 +94,12 @@ test("real OpenCode server shares one process while shell.env stays session-scop
|
||||
mkdir(secondCwd, { recursive: true }),
|
||||
]);
|
||||
first = await client.createSession(
|
||||
{ provider: "opencode", cwd: firstCwd, model: "opencode/big-pickle", modeId: "build" },
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd: firstCwd,
|
||||
model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
},
|
||||
{
|
||||
agentId: "real-agent-one",
|
||||
env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd },
|
||||
@@ -81,6 +140,22 @@ test("real OpenCode server shares one process while shell.env stays session-scop
|
||||
expect(JSON.stringify(secondShell.data)).toContain(`real-agent-two|${secondCwd}`);
|
||||
expect(inspection.server.url).toMatch(/^http:\/\/127\.0\.0\.1:/);
|
||||
|
||||
// Keep the prompt assertions independent of asynchronous events from the direct shell API.
|
||||
await first.close();
|
||||
first = await client.createSession(
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd: firstCwd,
|
||||
model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
},
|
||||
{
|
||||
agentId: "real-agent-one",
|
||||
env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd },
|
||||
paseoTools: firstTools,
|
||||
},
|
||||
{ persistSession: false },
|
||||
);
|
||||
const agentResult = await first.run(
|
||||
[
|
||||
"Use the bash tool to run: env | grep -E '^(PASEO_AGENT_ID|PASEO_AGENT_CWD)='",
|
||||
@@ -124,11 +199,6 @@ test("real OpenCode server shares one process while shell.env stays session-scop
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
function requireSessionId(session: { id: string | null }): string {
|
||||
if (!session.id) throw new Error("OpenCode session has no id");
|
||||
return session.id;
|
||||
}
|
||||
|
||||
function createCallerCatalog(callerAgentId: string): PaseoToolCatalog {
|
||||
const tool = {
|
||||
name: "report_caller_agent_id",
|
||||
@@ -151,8 +221,142 @@ function createCallerCatalog(callerAgentId: string): PaseoToolCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
function readAssistantText(timeline: ReadonlyArray<{ type: string; text?: string }>): string {
|
||||
return timeline
|
||||
.flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : []))
|
||||
.join("");
|
||||
}
|
||||
test("v2 native tool bridge preserves caller identity", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-"));
|
||||
const logger = createTestLogger();
|
||||
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
|
||||
const catalog = createCallerCatalog("paseo-v2-caller");
|
||||
bridge.setManifestCatalog(catalog);
|
||||
await bridge.start();
|
||||
const client = new OpenCodeV2AgentClient({ logger, bridge });
|
||||
let session: Awaited<ReturnType<typeof client.createSession>> | undefined;
|
||||
try {
|
||||
session = await client.createSession(
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd: root,
|
||||
model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra",
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
{ agentId: "paseo-v2-caller", paseoTools: catalog, env: { PASEO_TEST_SCOPE: "V2_ENV_OK" } },
|
||||
{ persistSession: false },
|
||||
);
|
||||
const result = await session.run(
|
||||
"Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.",
|
||||
);
|
||||
expect(readAssistantText(result.timeline)).toContain("paseo-v2-caller");
|
||||
const environment = await session.run(
|
||||
"Use the shell tool to run printf '%s' \"$PASEO_TEST_SCOPE\", then reply with its output.",
|
||||
);
|
||||
expect(environment.finalText).toContain("V2_ENV_OK");
|
||||
expect(result.timeline).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
name: "paseo_report_caller_agent_id",
|
||||
status: "completed",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await session?.interrupt();
|
||||
await session?.close();
|
||||
await client.shutdown();
|
||||
await bridge.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test.each([
|
||||
{
|
||||
major: 1,
|
||||
package: "opencode-ai@1.14.46",
|
||||
windowsPackage: "opencode-windows",
|
||||
expectedTimeline: [],
|
||||
expectedInitialTimeline: undefined,
|
||||
expectedNotices: undefined,
|
||||
},
|
||||
{
|
||||
major: 2,
|
||||
package: "@opencode/cli@2.0.10",
|
||||
windowsPackage: "@opencode/cli-windows",
|
||||
expectedTimeline: [
|
||||
{ type: "notification", level: "info", message: "This chat uses OpenCode v2." },
|
||||
],
|
||||
expectedInitialTimeline: [
|
||||
expect.objectContaining({
|
||||
item: { type: "notification", level: "info", message: "This chat uses OpenCode v2." },
|
||||
}),
|
||||
],
|
||||
expectedNotices: [expect.objectContaining({ major: 2 })],
|
||||
},
|
||||
])(
|
||||
"versioned runtime v$major discovers models and preserves a native session handle",
|
||||
async ({
|
||||
package: cliPackage,
|
||||
windowsPackage,
|
||||
expectedTimeline,
|
||||
expectedInitialTimeline,
|
||||
expectedNotices,
|
||||
}) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-versioned-"));
|
||||
const client = new OpenCodeRuntimeClient(createTestLogger(), {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: [
|
||||
process.platform === "win32"
|
||||
? path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
`${windowsPackage}-${process.arch}`,
|
||||
"bin",
|
||||
"opencode.exe",
|
||||
)
|
||||
: path.join(root, "node_modules", ".bin", "opencode"),
|
||||
],
|
||||
},
|
||||
env: {
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
},
|
||||
});
|
||||
let original: Awaited<ReturnType<typeof client.createSession>> | undefined;
|
||||
let resumed: Awaited<ReturnType<typeof client.resumeSession>> | undefined;
|
||||
try {
|
||||
await execCommand(
|
||||
"npm",
|
||||
["install", "--prefix", root, "--no-audit", "--no-fund", cliPackage],
|
||||
{
|
||||
cwd: root,
|
||||
timeout: 180_000,
|
||||
},
|
||||
);
|
||||
const catalog = await client.fetchCatalog({ scope: "workspace", cwd: root, force: true });
|
||||
expect(catalog.models.length).toBeGreaterThan(0);
|
||||
expect(catalog.modes.map((mode) => mode.id)).toContain("build");
|
||||
original = await client.createSession(
|
||||
{ provider: "opencode", cwd: root, modeId: "build" },
|
||||
undefined,
|
||||
{ persistSession: false },
|
||||
);
|
||||
const handle = await original.describePersistence();
|
||||
expect(original.initialTimeline).toEqual(expectedInitialTimeline);
|
||||
expect(handle.metadata?.openCodeRuntimeNotices).toEqual(expectedNotices);
|
||||
resumed = await client.resumeSession(handle, { cwd: root });
|
||||
expect((await resumed.describePersistence()).nativeHandle).toBe(handle.nativeHandle);
|
||||
expect(
|
||||
(await drainPersistedTimeline(resumed)).map((event) =>
|
||||
event.type === "timeline" ? event.item : event,
|
||||
),
|
||||
).toEqual(expectedTimeline);
|
||||
} finally {
|
||||
await resumed?.close();
|
||||
await original?.close();
|
||||
await client.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240_000,
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user