mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 06:42:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e94a5731b |
+7
-28
@@ -1,12 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0 - 2026-10-07
|
||||
## 0.11.0-beta.5 - 2026-10-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
|
||||
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
@@ -16,12 +16,9 @@
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
|
||||
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
|
||||
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
|
||||
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
|
||||
@@ -30,9 +27,10 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
|
||||
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
|
||||
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
|
||||
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
|
||||
@@ -48,18 +46,12 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
|
||||
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
|
||||
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
|
||||
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
|
||||
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
|
||||
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
|
||||
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
|
||||
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
|
||||
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
|
||||
- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
|
||||
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
|
||||
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
@@ -74,14 +66,12 @@
|
||||
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
|
||||
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
|
||||
- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
|
||||
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
|
||||
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
|
||||
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
|
||||
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
|
||||
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
|
||||
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
|
||||
- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
|
||||
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
|
||||
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
@@ -95,18 +85,12 @@
|
||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
|
||||
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
|
||||
- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
|
||||
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
|
||||
- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
|
||||
- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
|
||||
- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
|
||||
- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
|
||||
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
|
||||
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
@@ -114,16 +98,12 @@
|
||||
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
|
||||
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
|
||||
- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
|
||||
- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
|
||||
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
|
||||
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
|
||||
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
@@ -143,7 +123,6 @@
|
||||
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
|
||||
@@ -50,8 +50,6 @@ Paseo is a desktop, mobile, web, and CLI app for coding agents. Open the desktop
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
||||
|
||||
@@ -197,11 +197,10 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
|
||||
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
|
||||
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
|
||||
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
|
||||
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
|
||||
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
|
||||
- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
|
||||
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
|
||||
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
|
||||
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36413,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37496,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/server": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37748,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/relay": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37762,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38006,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38902,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38931,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38948,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38998,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39216,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.11.0",
|
||||
"@getpaseo/highlight": "0.11.0",
|
||||
"@getpaseo/plugin": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/relay": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40146,7 +40146,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41754,7 +41754,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -3047,34 +3047,6 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => {
|
||||
const host = makeHost({ serverId: "srv_steer_queue_drain" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_steer_queue_drain",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
|
||||
);
|
||||
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
await fakeClient.waitForSentMessages(1);
|
||||
|
||||
expect(fakeClient.sentAgentMessages[0]?.[2]?.activeTurnBehavior).toBe("steer");
|
||||
sessionStore.clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
|
||||
const host = makeHost({ serverId: "srv_legacy_queue_attachment" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
|
||||
@@ -2426,11 +2426,6 @@ export class HostRuntimeStore {
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
// The queue drains when the agent goes idle, but the agent can start a turn of its own
|
||||
// in that same moment (a wake-up after background work). Steering folds the message
|
||||
// into that turn instead of interrupting it; with no turn running, the daemon starts
|
||||
// a normal one.
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"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.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/server": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/relay": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -70,8 +70,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -81,8 +81,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.11.0",
|
||||
"@getpaseo/highlight": "0.11.0",
|
||||
"@getpaseo/plugin": "0.11.0",
|
||||
"@getpaseo/protocol": "0.11.0",
|
||||
"@getpaseo/relay": "0.11.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -31,7 +31,6 @@ function createQueryMock(events: unknown[]): Query {
|
||||
describe("Claude SDK env", () => {
|
||||
test("forwards launch-context env through Claude process env", async () => {
|
||||
let capturedEnv: Record<string, string | undefined> | undefined;
|
||||
let capturedPerTaskStopAffordance: boolean | undefined;
|
||||
const launchContext: AgentLaunchContext = {
|
||||
env: {
|
||||
PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000201",
|
||||
@@ -40,7 +39,6 @@ describe("Claude SDK env", () => {
|
||||
};
|
||||
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
||||
capturedEnv = options.env;
|
||||
capturedPerTaskStopAffordance = options.perTaskStopAffordance;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
@@ -93,10 +91,6 @@ describe("Claude SDK env", () => {
|
||||
expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout");
|
||||
expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout");
|
||||
expect(session.usageSession?.()?.env).toBe(capturedEnv);
|
||||
// Paseo reads session_state_changed to know when an autonomous turn is over.
|
||||
expect(capturedEnv?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS).toBe("1");
|
||||
// Without it, Stop and replace kill every background subagent along with the turn.
|
||||
expect(capturedPerTaskStopAffordance).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
expect(session.usageSession?.()).toBeNull();
|
||||
|
||||
+20
-60
@@ -91,17 +91,6 @@ function buildUsage() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Claude opening a turn of its own. Every main-session turn starts with one. */
|
||||
function buildTurnInit(sessionId: string) {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: sessionId,
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
};
|
||||
}
|
||||
|
||||
function buildSuccessResult(sessionId: string) {
|
||||
return {
|
||||
type: "result",
|
||||
@@ -164,7 +153,13 @@ function createScriptedQuery(params: {
|
||||
},
|
||||
} satisfies ScriptedQuery;
|
||||
|
||||
scriptedQuery.emit(buildTurnInit(params.sessionId));
|
||||
scriptedQuery.emit({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: params.sessionId,
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
for await (const prompt of params.prompt) {
|
||||
@@ -257,21 +252,6 @@ async function waitFor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paseo interrupts Claude only while a main-session turn is in flight, which it learns from the
|
||||
* init. The pump asks for its next frame only after routing the last one, so a second next() call
|
||||
* proves the init was read.
|
||||
*/
|
||||
async function waitForInitRouted(
|
||||
query: () => { next: ReturnType<typeof vi.fn> } | null | undefined,
|
||||
) {
|
||||
await waitFor(() => (query()?.next.mock.calls.length ?? 0) >= 2);
|
||||
}
|
||||
|
||||
function buildCommandLifecycle(commandUuid: string | null | undefined, state: string) {
|
||||
return { type: "command_lifecycle", command_uuid: commandUuid, state };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
@@ -302,7 +282,6 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
await waitForInitRouted(() => queries[0]);
|
||||
|
||||
await session.interrupt();
|
||||
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
||||
@@ -343,7 +322,6 @@ async function startSteeredTurn(sessionId: string): Promise<{
|
||||
throw new Error("Expected the original Claude turn to start");
|
||||
}
|
||||
await waitFor(() => query?.prompts.length === 1);
|
||||
await waitForInitRouted(() => query);
|
||||
|
||||
const steered = await session.steerActiveTurn!("queued steer", {
|
||||
expectedTurnId: start.value.turnId,
|
||||
@@ -393,8 +371,11 @@ test("interrupt still stops the turn when Claude has already dequeued the steer"
|
||||
test("a steer Claude has already read is no longer discardable on interrupt", async () => {
|
||||
const { session, query, turn } = await startSteeredTurn("queued-steer-completed-session");
|
||||
|
||||
query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started"));
|
||||
query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "completed"));
|
||||
query()?.emit({
|
||||
type: "command_lifecycle",
|
||||
command_uuid: query()?.prompts[1]?.uuid,
|
||||
state: "completed",
|
||||
});
|
||||
// Frames are translated in order, so the marker landing proves the lifecycle frame was read.
|
||||
query()?.emit({ type: "assistant", message: { content: "STEER_READ" } });
|
||||
await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_READ"));
|
||||
@@ -407,23 +388,6 @@ test("a steer Claude has already read is no longer discardable on interrupt", as
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a steer Claude cancelled is no longer withdrawn on interrupt", async () => {
|
||||
const { session, query, turn } = await startSteeredTurn("queued-steer-cancelled-session");
|
||||
|
||||
query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started"));
|
||||
// "cancelled" is terminal: API errors, interrupts and "now" pre-emption all end a message.
|
||||
query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "cancelled"));
|
||||
query()?.emit({ type: "assistant", message: { content: "STEER_CANCELLED" } });
|
||||
await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_CANCELLED"));
|
||||
|
||||
await session.interrupt();
|
||||
await waitFor(() => query()?.interrupt.mock.calls.length === 1);
|
||||
|
||||
expect(query()?.cancelAsyncMessage).not.toHaveBeenCalled();
|
||||
await collectUntilTerminal(turn);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
function buildStoppedTaskNotification(sessionId: string) {
|
||||
return {
|
||||
type: "system",
|
||||
@@ -509,7 +473,6 @@ async function startInterruptedToolTurn(sessionId: string): Promise<{
|
||||
const turn = streamSession(session, "run the slow tool");
|
||||
await turn.next();
|
||||
await waitFor(() => query?.prompts.length === 1);
|
||||
await waitForInitRouted(() => query);
|
||||
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(turn);
|
||||
@@ -560,7 +523,6 @@ test("Claude can still wake into an autonomous turn once the interrupted request
|
||||
|
||||
query()?.emit(buildStoppedTaskNotification(sessionId));
|
||||
query()?.emit(buildAbortedResult(sessionId));
|
||||
query()?.emit(buildTurnInit(sessionId));
|
||||
query()?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
@@ -617,7 +579,6 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
await waitForInitRouted(() => queries[0]);
|
||||
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
@@ -691,7 +652,6 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
||||
const output = createAsyncQueue<Record<string, unknown>>();
|
||||
const prompts: PromptRecord[] = [];
|
||||
let throwAbortOnNext = false;
|
||||
let queryRef: Pick<ScriptedQuery, "next"> | null = null;
|
||||
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = {
|
||||
@@ -725,8 +685,13 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
||||
},
|
||||
} satisfies ScriptedQuery;
|
||||
|
||||
queryRef = scriptedQuery;
|
||||
scriptedQuery.emit(buildTurnInit("interrupt-abort-recovery-session"));
|
||||
scriptedQuery.emit({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "interrupt-abort-recovery-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
for await (const promptMessage of prompt) {
|
||||
@@ -765,7 +730,6 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitForInitRouted(() => queryRef);
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
|
||||
@@ -804,7 +768,6 @@ test("stale abort result after replacement start does not poison the new foregro
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
const firstStarted = await firstTurn.next();
|
||||
await waitFor(() => queryRef?.prompts.length === 1);
|
||||
await waitForInitRouted(() => queryRef);
|
||||
|
||||
await session.interrupt();
|
||||
const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))];
|
||||
@@ -849,7 +812,7 @@ test("stale abort result after replacement start does not poison the new foregro
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("creates an autonomous live turn when Claude starts a turn without a foreground run", async () => {
|
||||
test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
@@ -885,7 +848,6 @@ test("creates an autonomous live turn when Claude starts a turn without a foregr
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit(buildTurnInit("autonomous-live-session"));
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
@@ -954,7 +916,6 @@ test("steers an autonomous turn through its existing query without restarting it
|
||||
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
const autonomousEvents = subscribeToEvents(session);
|
||||
queryRef?.emit(buildTurnInit("autonomous-steer-session"));
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_RESPONSE" },
|
||||
@@ -1038,7 +999,6 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit(buildTurnInit("autonomous-handoff-session"));
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "BACKGROUND_ONLY_RESPONSE" },
|
||||
|
||||
@@ -486,7 +486,6 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
|
||||
} | null;
|
||||
input: { end: () => void } | null;
|
||||
queryRestartNeeded: boolean;
|
||||
mainTurnInFlight: boolean;
|
||||
interruptActiveTurn: () => Promise<void>;
|
||||
} = asInternals(session);
|
||||
const interrupt = vi.fn(async () => undefined);
|
||||
@@ -499,7 +498,6 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
|
||||
};
|
||||
internal.input = { end };
|
||||
internal.queryRestartNeeded = false;
|
||||
internal.mainTurnInFlight = true;
|
||||
|
||||
try {
|
||||
await internal.interruptActiveTurn();
|
||||
@@ -1193,7 +1191,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
|
||||
internal.turnState = "idle";
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
await internal.routeSdkMessageFromPump(
|
||||
{
|
||||
type: "stream_event",
|
||||
@@ -1236,468 +1233,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
await session.close();
|
||||
});
|
||||
|
||||
interface SessionStateInternals {
|
||||
turnState: "idle" | "foreground" | "autonomous";
|
||||
routeSdkMessageFromPump: (
|
||||
message: Record<string, unknown>,
|
||||
activeQuery: QueryMock,
|
||||
) => Promise<void>;
|
||||
autonomousTurn: { id: string } | null;
|
||||
activeForegroundTurnId: string | null;
|
||||
}
|
||||
|
||||
function sessionState(state: "idle" | "running" | "requires_action") {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "session_state_changed",
|
||||
state,
|
||||
uuid: `state-${state}`,
|
||||
session_id: "sess-1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Claude opening a turn of its own. Every main-session turn starts with one. */
|
||||
function claudeTurnInit() {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "sess-1",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
};
|
||||
}
|
||||
|
||||
function successResult() {
|
||||
return { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 };
|
||||
}
|
||||
|
||||
function commandLifecycle(
|
||||
commandUuid: string,
|
||||
state: "queued" | "started" | "completed" | "cancelled",
|
||||
) {
|
||||
return { type: "command_lifecycle", command_uuid: commandUuid, state };
|
||||
}
|
||||
|
||||
function unboundTextDelta(text: string) {
|
||||
return {
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
event: { type: "content_block_delta", delta: { type: "text_delta", text } },
|
||||
};
|
||||
}
|
||||
|
||||
// What a background subagent emits on the main stream with no parent tool use id, so by frame
|
||||
// shape alone each looks like main-session work. Verified on the wire: none arrives inside a turn
|
||||
// Claude opened, and no result follows any of them.
|
||||
const helperTaskStarted = {
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: "helper-task",
|
||||
tool_use_id: "toolu_helper",
|
||||
task_type: "local_agent",
|
||||
subagent_type: "general-purpose",
|
||||
description: "Background helper",
|
||||
is_backgrounded: true,
|
||||
uuid: "uuid-task-started",
|
||||
session_id: "sess-1",
|
||||
};
|
||||
const helperTaskProgress = {
|
||||
type: "system",
|
||||
subtype: "task_progress",
|
||||
task_id: "helper-task",
|
||||
tool_use_id: "toolu_helper",
|
||||
description: "Background helper",
|
||||
usage: { total_tokens: 1200, tool_uses: 3, duration_ms: 900 },
|
||||
last_tool_name: "Bash",
|
||||
uuid: "uuid-task-progress",
|
||||
session_id: "sess-1",
|
||||
};
|
||||
// A subagent's own background Bash job completing.
|
||||
const subagentOwnedTaskNotification = {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "bg-job-of-subagent",
|
||||
tool_use_id: "toolu_subagent_bash",
|
||||
status: "completed",
|
||||
summary: "Background command completed",
|
||||
output_file: "/tmp/bg-job-of-subagent.output",
|
||||
uuid: "uuid-task-notification",
|
||||
session_id: "sess-1",
|
||||
};
|
||||
const helperCompleted = {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "helper-task",
|
||||
tool_use_id: "toolu_helper",
|
||||
status: "completed",
|
||||
summary: "Background helper finished",
|
||||
output_file: "/tmp/helper-task.output",
|
||||
uuid: "uuid-helper-completed",
|
||||
session_id: "sess-1",
|
||||
};
|
||||
const rateLimitEvent = {
|
||||
type: "rate_limit_event",
|
||||
rate_limit_info: { status: "allowed" },
|
||||
uuid: "uuid-rate-limit",
|
||||
session_id: "sess-1",
|
||||
};
|
||||
|
||||
function createPendingQueryMock(): QueryMock {
|
||||
return createBaseQueryMock(vi.fn(() => new Promise<never>(() => {})));
|
||||
}
|
||||
|
||||
test("a main-session frame opens no turn until Claude starts one", async () => {
|
||||
const session = await createSession();
|
||||
const internal: SessionStateInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
|
||||
// Main-session output only ever arrives inside a turn Claude opened with system/init, so a
|
||||
// stray unbound frame is not the start of one.
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("stray"), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
// The wake-up case autonomous turns exist for: a completed background subagent makes the main
|
||||
// session start a turn of its own, with no prompt from the user.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a background subagent's frames never open a turn while the main session is idle", async () => {
|
||||
sdkQueryFactory.mockImplementation(() => createPendingQueryMock());
|
||||
const session = await createSession();
|
||||
const internal: SessionStateInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
|
||||
// Claude Code 2.1.280 reports "running" for as long as any background subagent is alive.
|
||||
await internal.routeSdkMessageFromPump(sessionState("running"), queryMock);
|
||||
for (const frame of [
|
||||
helperTaskStarted,
|
||||
helperTaskProgress,
|
||||
subagentOwnedTaskNotification,
|
||||
rateLimitEvent,
|
||||
helperCompleted,
|
||||
]) {
|
||||
await internal.routeSdkMessageFromPump(frame, queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
}
|
||||
|
||||
// The next user message starts its own foreground turn rather than landing on a phantom one.
|
||||
const { turnId } = await session.startTurn("PING");
|
||||
expect(internal.activeForegroundTurnId).toBe(turnId);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("foreground");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("Claude waking up after a helper finishes gets an autonomous turn that its result closes", async () => {
|
||||
const session = await createSession();
|
||||
const internal: SessionStateInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
// The helper's completion arrives outside any turn. Claude then starts one to react to it.
|
||||
await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock);
|
||||
await internal.routeSdkMessageFromPump(helperCompleted, queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.turnState).toBe("autonomous");
|
||||
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a steer folded into a turn keeps one turn, and a turn Claude starts after it gets its own", async () => {
|
||||
sdkQueryFactory.mockImplementation(() => createPendingQueryMock());
|
||||
const session = await createSession();
|
||||
const internal: SessionStateInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const { turnId } = await session.startTurn("hello");
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
// A message pushed mid-turn is folded in at the next tool boundary: Claude acknowledges it, but
|
||||
// starts no new turn.
|
||||
await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "queued"), queryMock);
|
||||
await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "started"), queryMock);
|
||||
expect(internal.activeForegroundTurnId).toBe(turnId);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.activeForegroundTurnId).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
|
||||
// A message that arrived while Claude wrote its final answer runs as a turn of its own.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
expect(events.filter((event) => event.type === "turn_started")).toHaveLength(2);
|
||||
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(2);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("session state never opens a turn", async () => {
|
||||
const session = await createSession();
|
||||
const internal: SessionStateInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
// "running" covers background subagents too, so it says nothing about the main session.
|
||||
for (const state of ["idle", "running", "requires_action", "idle"] as const) {
|
||||
await internal.routeSdkMessageFromPump(sessionState(state), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
}
|
||||
// State frames are bookkeeping, never timeline content.
|
||||
expect(events).toEqual([]);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("an idle session state closes an autonomous turn that no result closed", async () => {
|
||||
const session = await createSession();
|
||||
const internal: SessionStateInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
// Safety net: Claude says nothing at all is running any more.
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("session state never opens or closes a foreground turn", async () => {
|
||||
sdkQueryFactory.mockImplementation(() => createPendingQueryMock());
|
||||
const session = await createSession();
|
||||
const internal: SessionStateInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
const { turnId } = await session.startTurn("hello");
|
||||
|
||||
// The send's own running, then an idle that trails the previous turn and lands late.
|
||||
await internal.routeSdkMessageFromPump(sessionState("running"), queryMock);
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
expect(internal.activeForegroundTurnId).toBe(turnId);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("foreground");
|
||||
|
||||
await internal.routeSdkMessageFromPump(
|
||||
{ type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 },
|
||||
queryMock,
|
||||
);
|
||||
expect(internal.activeForegroundTurnId).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
interface InterruptInternals extends SessionStateInternals {
|
||||
pendingInterruptAbort: boolean;
|
||||
}
|
||||
|
||||
type InterruptibleQueryMock = QueryMock & { cancelAsyncMessage: ReturnType<typeof vi.fn> };
|
||||
|
||||
function createInterruptibleQueryMock(): InterruptibleQueryMock {
|
||||
return Object.assign(createPendingQueryMock(), {
|
||||
cancelAsyncMessage: vi.fn(async () => true),
|
||||
});
|
||||
}
|
||||
|
||||
/** The uuid Paseo stamped on the first message it pushed into the SDK input. */
|
||||
async function readFirstPromptUuid(): Promise<string | null> {
|
||||
const input = sdkQueryFactory.mock.calls[0]?.[0] as { prompt: AsyncIterable<unknown> };
|
||||
return await createPromptUuidReader(input.prompt)();
|
||||
}
|
||||
|
||||
function abortedResult() {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "error_during_execution",
|
||||
errors: [],
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
test("an interrupt before Claude starts the message withdraws it instead of interrupting", async () => {
|
||||
const queryMock = createInterruptibleQueryMock();
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
const session = await createSession();
|
||||
const internal: InterruptInternals = asInternals(session);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.startTurn("hello");
|
||||
// Claude has not acknowledged the message yet: no init, no command_lifecycle "started".
|
||||
await session.interrupt();
|
||||
|
||||
await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1));
|
||||
expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid());
|
||||
expect(queryMock.interrupt).not.toHaveBeenCalled();
|
||||
// No result follows an interrupt that never happened, so no flag may wait for one.
|
||||
expect(internal.pendingInterruptAbort).toBe(false);
|
||||
expect(events.filter((event) => event.type === "turn_canceled")).toHaveLength(1);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("an interrupt after Claude took the message but before it started stops it", async () => {
|
||||
const queryMock = createInterruptibleQueryMock();
|
||||
// Claude has dequeued the message for its next turn, so withdrawing it is a no-op.
|
||||
queryMock.cancelAsyncMessage.mockImplementation(async () => false);
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
const session = await createSession();
|
||||
const internal: InterruptInternals = asInternals(session);
|
||||
|
||||
await session.startTurn("hello");
|
||||
// No command_lifecycle "started" or init has reached Paseo yet.
|
||||
await session.interrupt();
|
||||
|
||||
await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1));
|
||||
expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid());
|
||||
expect(internal.pendingInterruptAbort).toBe(true);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a prompt sent while a stopped prompt is being withdrawn is not withdrawn too", async () => {
|
||||
const queryMock = createInterruptibleQueryMock();
|
||||
let settleWithdrawal: (cancelled: boolean) => void = () => {};
|
||||
queryMock.cancelAsyncMessage.mockImplementationOnce(
|
||||
() => new Promise<boolean>((resolve) => (settleWithdrawal = resolve)),
|
||||
);
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
const session = await createSession();
|
||||
|
||||
await session.startTurn("first");
|
||||
const firstUuid = await readFirstPromptUuid();
|
||||
await session.interrupt();
|
||||
await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1));
|
||||
|
||||
await session.startTurn("second");
|
||||
settleWithdrawal(false);
|
||||
|
||||
await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1));
|
||||
expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(firstUuid);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("an interrupt while only background subagents run never reaches Claude", async () => {
|
||||
const queryMock = createInterruptibleQueryMock();
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
const session = await createSession();
|
||||
const internal: InterruptInternals = asInternals(session);
|
||||
|
||||
await session.startTurn("delegate to a helper");
|
||||
const promptUuid = await readFirstPromptUuid();
|
||||
await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock);
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock);
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
// The main session is done; its helper keeps working.
|
||||
await internal.routeSdkMessageFromPump(sessionState("running"), queryMock);
|
||||
await internal.routeSdkMessageFromPump(helperTaskProgress, queryMock);
|
||||
|
||||
// Without perTaskStopAffordance, an interrupt here kills every running helper.
|
||||
await session.interrupt();
|
||||
|
||||
expect(queryMock.interrupt).not.toHaveBeenCalled();
|
||||
expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled();
|
||||
expect(internal.pendingInterruptAbort).toBe(false);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("an interrupt during a main turn interrupts Claude once and drops the aborted result", async () => {
|
||||
const queryMock = createInterruptibleQueryMock();
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
const session = await createSession();
|
||||
const internal: InterruptInternals = asInternals(session);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.startTurn("long task");
|
||||
const promptUuid = await readFirstPromptUuid();
|
||||
await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock);
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
|
||||
await session.interrupt();
|
||||
await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1));
|
||||
// Claude already started the message, so there is nothing to withdraw.
|
||||
expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled();
|
||||
expect(internal.pendingInterruptAbort).toBe(true);
|
||||
|
||||
// A replacement starts before the aborted turn reports its result.
|
||||
const { turnId } = await session.startTurn("replacement");
|
||||
await internal.routeSdkMessageFromPump(abortedResult(), queryMock);
|
||||
expect(internal.activeForegroundTurnId).toBe(turnId);
|
||||
expect(internal.pendingInterruptAbort).toBe(false);
|
||||
expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(0);
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.activeForegroundTurnId).toBeNull();
|
||||
expect(queryMock.interrupt).toHaveBeenCalledTimes(1);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a new turn clears an interrupt flag no result will consume", async () => {
|
||||
const session = await createSession();
|
||||
const internal: InterruptInternals = asInternals(session);
|
||||
const queryMock = createPendingQueryMock();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
// An interrupt that landed just after its turn had ended: Claude emits no result for it.
|
||||
internal.pendingInterruptAbort = true;
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.pendingInterruptAbort).toBe(false);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
|
||||
await internal.routeSdkMessageFromPump(
|
||||
{ ...abortedResult(), errors: ["API Error: 500 upstream failure"] },
|
||||
queryMock,
|
||||
);
|
||||
expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(1);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("tracks run lifecycle transitions for success, error, and interrupt", async () => {
|
||||
const session = await createSession();
|
||||
let streamCase: "success" | "error" | "interrupt" = "success";
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("subagent lifecycle across an interrupt", () => {
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
|
||||
async function startInterruptibleTurn(taskStarted: Record<string, unknown> = {}) {
|
||||
async function startInterruptibleTurn() {
|
||||
const channel = buildOpenQueryMock();
|
||||
queryFactory.mockImplementation(() => channel.query);
|
||||
const session = await new ClaudeAgentClient({
|
||||
@@ -107,7 +107,6 @@ describe("subagent lifecycle across an interrupt", () => {
|
||||
task_type: "local_agent",
|
||||
subagent_type: "general-purpose",
|
||||
description: "Reply with banana",
|
||||
...taskStarted,
|
||||
});
|
||||
await vi.waitFor(() => expect(subagentEvents.length).toBeGreaterThan(0));
|
||||
|
||||
@@ -170,31 +169,6 @@ describe("subagent lifecycle across an interrupt", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a child spawned in the background is not canceled with the turn that spawned it", async () => {
|
||||
// Claude announces a background spawn on task_started itself and sends no task_updated patch.
|
||||
const { channel, session, subagentEvents } = await startInterruptibleTurn({
|
||||
is_backgrounded: true,
|
||||
});
|
||||
|
||||
await session.interrupt();
|
||||
channel.push({
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: TASK_ID,
|
||||
tool_use_id: SUBAGENT_ID,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(subagentEvents).toContainEqual(
|
||||
expect.objectContaining({ id: SUBAGENT_ID, status: "completed" }),
|
||||
),
|
||||
);
|
||||
expect(statuses(subagentEvents, SUBAGENT_ID)).toEqual(["running", "completed"]);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a foreground child is canceled with the turn that spawned it", async () => {
|
||||
const { session, subagentEvents } = await startInterruptibleTurn();
|
||||
|
||||
|
||||
@@ -3160,15 +3160,10 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
});
|
||||
|
||||
test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => {
|
||||
// Claude starts a turn of its own after the foreground result, which starts compacting and is
|
||||
// then ended by the next foreground turn, never reaching a boundary.
|
||||
// Trailing output after the foreground result opens an autonomous turn, which starts
|
||||
// compacting and is then ended by the next foreground turn, never reaching a boundary.
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createSuccessResult(),
|
||||
createInitMessage(),
|
||||
createMessageStartEvent(),
|
||||
createCompactingStatus(),
|
||||
],
|
||||
[createSuccessResult(), createMessageStartEvent(), createCompactingStatus()],
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
]);
|
||||
|
||||
|
||||
@@ -452,15 +452,6 @@ function errorToMessageString(error: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude opens every main-session turn with system/init and closes it with exactly one result:
|
||||
* a prompt, a wake-up after background work, a slash command, an API error. Background subagents
|
||||
* never emit either on the main stream.
|
||||
*/
|
||||
function isClaudeTurnInit(message: SDKMessage): boolean {
|
||||
return message.type === "system" && message.subtype === "init";
|
||||
}
|
||||
|
||||
function firstStringField(
|
||||
input: Record<string, unknown>,
|
||||
primaryKey: string,
|
||||
@@ -2079,12 +2070,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private activeForegroundQuery: Query | null = null;
|
||||
private activeForegroundInput: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||
/**
|
||||
* Messages pushed into the live SDK input (steers, and a foreground turn's own prompt) whose
|
||||
* command_lifecycle has not reached "started". Interrupting has to withdraw them, or the SDK
|
||||
* dequeues one and resumes the turn we just stopped. SDK user UUIDs are provider-private; never
|
||||
* let them escape the adapter boundary.
|
||||
* Steers pushed into the live SDK input that Claude may not have read yet. Interrupting the turn
|
||||
* has to discard them, or the SDK dequeues one and resumes the turn we just stopped. SDK user
|
||||
* UUIDs are provider-private; never let them escape the adapter boundary.
|
||||
*/
|
||||
private readonly unstartedMessageUuids = new Set<string>();
|
||||
private readonly queuedSteerUuids = new Set<string>();
|
||||
/** Human steers whose text has not reached Claude yet and therefore supersede blocking cards. */
|
||||
private readonly permissionClearingSteerUuids = new Set<string>();
|
||||
private claudeSessionId: string | null;
|
||||
@@ -2136,12 +2126,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private queryPumpPromise: Promise<void> | null = null;
|
||||
private queryRestartNeeded = false;
|
||||
private pendingInterruptAbort = false;
|
||||
/**
|
||||
* Whether Claude's main session is inside one of its own turns: set by system/init and by a
|
||||
* pushed message's command_lifecycle "started" (/compact works for seconds before its init),
|
||||
* cleared by the turn's result or an idle session state. See observeMainTurnBracket.
|
||||
*/
|
||||
private mainTurnInFlight = false;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private activeTurnHasAssistantText = false;
|
||||
private readonly contextUsage: ClaudeContextUsageState;
|
||||
@@ -2288,7 +2272,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.contextUsage.beginTurn();
|
||||
this.transitionTurnState("foreground", "foreground turn started");
|
||||
this.clearRecentStderr();
|
||||
if (sdkUserMessageId) this.unstartedMessageUuids.add(sdkUserMessageId);
|
||||
|
||||
let cancelIssued = false;
|
||||
const requestCancel = () => {
|
||||
@@ -2299,18 +2282,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.cancelCurrentTurn === requestCancel) {
|
||||
this.cancelCurrentTurn = null;
|
||||
}
|
||||
// Whether Claude has this message in hand. A late idle from the previous turn can clear
|
||||
// mainTurnInFlight after Claude started it, so the message's own lifecycle counts too.
|
||||
const claudeStartedTurn =
|
||||
this.mainTurnInFlight ||
|
||||
(sdkUserMessageId !== null && !this.unstartedMessageUuids.has(sdkUserMessageId));
|
||||
this.rejectAllPendingPermissions(new Error("Permission request canceled"));
|
||||
this.finishForegroundTurn({
|
||||
type: "turn_canceled",
|
||||
provider: "claude",
|
||||
reason: "Interrupted",
|
||||
});
|
||||
void this.interruptActiveTurn(claudeStartedTurn).catch((error) => {
|
||||
void this.interruptActiveTurn().catch((error) => {
|
||||
this.logger.warn({ err: error }, "Failed to interrupt during cancel");
|
||||
});
|
||||
};
|
||||
@@ -2320,11 +2298,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
try {
|
||||
await this.ensureQuery();
|
||||
if (cancelIssued) {
|
||||
// Stopped while Claude was still starting up: withdrawn by never sending it.
|
||||
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
|
||||
return { turnId };
|
||||
}
|
||||
if (!this.input) {
|
||||
throw new Error("Claude session input stream not initialized");
|
||||
}
|
||||
@@ -2338,7 +2311,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
|
||||
this.finishForegroundTurn(
|
||||
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
|
||||
);
|
||||
@@ -2387,7 +2359,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
clearPendingPermissions: boolean,
|
||||
): void {
|
||||
const uuid = message.uuid;
|
||||
if (uuid) this.unstartedMessageUuids.add(uuid);
|
||||
if (uuid) this.queuedSteerUuids.add(uuid);
|
||||
if (uuid && clearPendingPermissions) {
|
||||
this.permissionClearingSteerUuids.add(uuid);
|
||||
}
|
||||
@@ -2398,7 +2370,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
} catch (error) {
|
||||
if (uuid) {
|
||||
this.unstartedMessageUuids.delete(uuid);
|
||||
this.queuedSteerUuids.delete(uuid);
|
||||
this.permissionClearingSteerUuids.delete(uuid);
|
||||
}
|
||||
throw error;
|
||||
@@ -3210,8 +3182,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const options = await this.buildOptions(launchMode);
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
// A fresh Claude process has no turn of its own in flight.
|
||||
this.mainTurnInFlight = false;
|
||||
this.query = claudeQuery(
|
||||
{ prompt: input.iterable, options },
|
||||
{
|
||||
@@ -3331,12 +3301,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return createProviderEnv({
|
||||
baseEnv: process.env,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [
|
||||
this.launchEnv,
|
||||
// Makes the CLI announce session_state_changed, whose "idle" is the safety net that closes
|
||||
// an autonomous turn (see consumeSessionStateChange).
|
||||
{ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" },
|
||||
],
|
||||
overlays: [this.launchEnv],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3403,10 +3368,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
...settingsOptions,
|
||||
// Provider subagent panes render the child's nested transcript.
|
||||
forwardSubagentText: true,
|
||||
// Stop and replace abort only the main turn, like Esc in Claude Code, and background
|
||||
// subagents keep running. Without this declaration an interrupt kills every one of them.
|
||||
// Claude stops a helper itself with its TaskStop tool.
|
||||
perTaskStopAffordance: true,
|
||||
hooks: this.buildSubagentEffortHooks(),
|
||||
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
|
||||
env: sdkEnv,
|
||||
@@ -3937,67 +3898,35 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return false;
|
||||
}
|
||||
|
||||
private isAssistantishMessage(message: SDKMessage): boolean {
|
||||
return (
|
||||
message.type === "assistant" ||
|
||||
message.type === "stream_event" ||
|
||||
message.type === "tool_progress" ||
|
||||
(message.type === "system" && message.subtype === "task_notification")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude's account of whether anything in the session is working, which is not the same as the
|
||||
* main session working: Claude Code 2.1.280 stays "running" for as long as any background
|
||||
* subagent is alive. So "running" never opens a turn.
|
||||
*
|
||||
* "idle" means nothing at all is running, so it closes an autonomous turn whatever opened it.
|
||||
* It never closes a foreground turn: an idle can trail the previous turn and land just after a
|
||||
* new send, and it must not end that send.
|
||||
*
|
||||
* Returns true when the frame was a state change, which is bookkeeping and never reaches the
|
||||
* timeline.
|
||||
* Claude keeps talking about the request it was told to kill — the notification for the tool it
|
||||
* just stopped, trailing assistant output. That is not new work, so it must not open a turn: the
|
||||
* only message that would close that turn is the result shouldSuppressStaleResult drops, leaving
|
||||
* the agent stuck reporting "running" forever.
|
||||
*/
|
||||
private consumeSessionStateChange(message: SDKMessage): boolean {
|
||||
if (message.type !== "system" || message.subtype !== "session_state_changed") {
|
||||
private shouldStartAutonomousTurn(message: SDKMessage): boolean {
|
||||
if (this.activeForegroundTurnId || this.pendingInterruptAbort) {
|
||||
return false;
|
||||
}
|
||||
if (message.state === "idle") {
|
||||
this.mainTurnInFlight = false;
|
||||
this.completeAutonomousTurn();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Tracks mainTurnInFlight from Claude's own turn brackets (isClaudeTurnInit). */
|
||||
private observeMainTurnBracket(message: SDKMessage): void {
|
||||
if (message.type === "result") {
|
||||
this.mainTurnInFlight = false;
|
||||
return;
|
||||
}
|
||||
if (isClaudeTurnInit(message)) {
|
||||
this.mainTurnInFlight = true;
|
||||
// A new turn has begun, so the interrupted one is over. An interrupt that landed after its
|
||||
// turn had already ended gets no result, and the leftover flag would swallow this turn's.
|
||||
this.pendingInterruptAbort = false;
|
||||
return;
|
||||
}
|
||||
if (readClaudeCommandLifecycle(message)?.state === "started") {
|
||||
this.mainTurnInFlight = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the main session's own turn start opens an autonomous turn. Everything a background
|
||||
* subagent emits (its task frames, its own background jobs' notifications, rate limit events)
|
||||
* arrives outside one, and no result would ever close a turn it opened.
|
||||
*/
|
||||
private opensAutonomousTurn(message: SDKMessage): boolean {
|
||||
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
|
||||
return this.isAssistantishMessage(message);
|
||||
}
|
||||
|
||||
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
|
||||
if (this.consumeSessionStateChange(message)) {
|
||||
return;
|
||||
}
|
||||
this.observeMainTurnBracket(message);
|
||||
if (this.shouldSuppressStaleResult(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isForeground = Boolean(this.activeForegroundTurnId);
|
||||
if (this.opensAutonomousTurn(message)) {
|
||||
if (this.shouldStartAutonomousTurn(message)) {
|
||||
this.startAutonomousTurn();
|
||||
}
|
||||
if (!isForeground && !this.autonomousTurn && message.type === "result") {
|
||||
@@ -4139,13 +4068,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupts Claude only when a turn of its main session is running. With nothing running there
|
||||
* is nothing to stop but our own queued messages, so those are withdrawn instead: an interrupt
|
||||
* would reach only background helpers (and kill them on a CLI that ignores
|
||||
* perTaskStopAffordance), and no result would follow to consume pendingInterruptAbort.
|
||||
*/
|
||||
private async interruptActiveTurn(claudeStartedTurn = this.mainTurnInFlight): Promise<void> {
|
||||
private async interruptActiveTurn(): Promise<void> {
|
||||
const queryToInterrupt = this.query;
|
||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||
this.logger.trace(
|
||||
@@ -4159,16 +4082,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (claudeStartedTurn) {
|
||||
this.pendingInterruptAbort = true;
|
||||
await this.withdrawUnstartedMessages(queryToInterrupt);
|
||||
} else {
|
||||
// Claude can dequeue a message for its next turn before announcing it started; withdrawing
|
||||
// it is then a no-op, and only an interrupt stops it. Withdraw once: a prompt sent while
|
||||
// this withdrawal was in flight belongs to the next turn.
|
||||
if (await this.withdrawUnstartedMessages(queryToInterrupt)) return;
|
||||
this.pendingInterruptAbort = true;
|
||||
}
|
||||
this.pendingInterruptAbort = true;
|
||||
await this.discardQueuedSteers(queryToInterrupt);
|
||||
try {
|
||||
await this.awaitWithTimeout(
|
||||
queryToInterrupt.interrupt(),
|
||||
@@ -4180,15 +4095,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt means interrupt: a message Claude never started dies with the turn instead of
|
||||
* resuming it. One already dequeued cannot be recalled, and does not need to be, since the
|
||||
* interrupt kills it. Returns whether Claude confirmed every message withdrawn.
|
||||
* Interrupt means interrupt: a steer Claude never read dies with the turn instead of resuming it.
|
||||
* A steer already dequeued cannot be recalled, and does not need to be — the interrupt kills it.
|
||||
*/
|
||||
private async withdrawUnstartedMessages(query: Query): Promise<boolean> {
|
||||
const uuids = [...this.unstartedMessageUuids];
|
||||
this.unstartedMessageUuids.clear();
|
||||
private async discardQueuedSteers(query: Query): Promise<void> {
|
||||
const uuids = [...this.queuedSteerUuids];
|
||||
this.queuedSteerUuids.clear();
|
||||
this.permissionClearingSteerUuids.clear();
|
||||
if (uuids.length === 0) return true;
|
||||
if (uuids.length === 0) return;
|
||||
// The SDK runtime supports this, but its public Query type has not caught up. Keep the
|
||||
// compatibility escape hatch inside the Claude adapter.
|
||||
const cancelAsyncMessage = (
|
||||
@@ -4196,17 +4110,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
cancelAsyncMessage?: (uuid: string) => Promise<boolean>;
|
||||
}
|
||||
).cancelAsyncMessage;
|
||||
if (!cancelAsyncMessage) return false;
|
||||
let withdrewAll = true;
|
||||
if (!cancelAsyncMessage) return;
|
||||
for (const uuid of uuids) {
|
||||
try {
|
||||
if (!(await cancelAsyncMessage.call(query, uuid))) withdrewAll = false;
|
||||
await cancelAsyncMessage.call(query, uuid);
|
||||
} catch (error) {
|
||||
withdrewAll = false;
|
||||
this.logger.warn({ err: error }, "Failed to withdraw a queued Claude message");
|
||||
this.logger.warn({ err: error }, "Failed to discard a queued Claude steer");
|
||||
}
|
||||
}
|
||||
return withdrewAll;
|
||||
}
|
||||
|
||||
private translateMessageToEvents(
|
||||
@@ -4255,7 +4166,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
this.forgetStartedMessage(message);
|
||||
this.forgetReadSteer(message);
|
||||
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
@@ -4289,14 +4200,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once Claude has started a message, or cancelled it, there is nothing left to withdraw on
|
||||
* interrupt.
|
||||
*/
|
||||
private forgetStartedMessage(message: unknown): void {
|
||||
/** Once Claude has read a steer there is nothing left to discard on interrupt. */
|
||||
private forgetReadSteer(message: unknown): void {
|
||||
const lifecycle = readClaudeCommandLifecycle(message);
|
||||
if (!lifecycle || lifecycle.state === "queued") return;
|
||||
this.unstartedMessageUuids.delete(lifecycle.commandUuid);
|
||||
this.queuedSteerUuids.delete(lifecycle.commandUuid);
|
||||
this.permissionClearingSteerUuids.delete(lifecycle.commandUuid);
|
||||
}
|
||||
|
||||
@@ -6611,8 +6519,7 @@ function extractClaudeUserText(messageRaw: unknown): string | null {
|
||||
|
||||
interface ClaudeCommandLifecycle {
|
||||
commandUuid: string;
|
||||
/** "cancelled" is terminal too: API errors, interrupts and "now" pre-emption end a message. */
|
||||
state: "queued" | "started" | "completed" | "cancelled";
|
||||
state: "queued" | "started" | "completed";
|
||||
}
|
||||
|
||||
/** Runtime-only Claude frames are validated here because the SDK's public union omits them. */
|
||||
@@ -6621,7 +6528,7 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle |
|
||||
if (record?.type !== "command_lifecycle") return null;
|
||||
if (
|
||||
typeof record.command_uuid !== "string" ||
|
||||
!["queued", "started", "completed", "cancelled"].includes(String(record.state))
|
||||
!["queued", "started", "completed"].includes(String(record.state))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -511,33 +511,6 @@ describe("ClaudeTaskProtocolSource", () => {
|
||||
expect(source.cancelRunningForegroundTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not cancel a subagent announced as backgrounded at spawn", () => {
|
||||
// Claude sets is_backgrounded on task_started for a child spawned in the background, and
|
||||
// sends no task_updated patch for it.
|
||||
const source = new ClaudeTaskProtocolSource();
|
||||
source.observe(taskStarted({ is_backgrounded: true }));
|
||||
|
||||
expect(source.cancelRunningForegroundTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not cancel a resumed subagent, which Claude always runs in the background", () => {
|
||||
const source = new ClaudeTaskProtocolSource();
|
||||
source.observe(taskStarted({ tool_use_id: "toolu_original", is_backgrounded: false }));
|
||||
source.observe(taskUpdated("completed"));
|
||||
source.observe(taskStarted({ tool_use_id: "toolu_resumed", is_backgrounded: true }));
|
||||
|
||||
expect(source.cancelRunningForegroundTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("still cancels a subagent announced in the foreground", () => {
|
||||
const source = new ClaudeTaskProtocolSource();
|
||||
source.observe(taskStarted({ is_backgrounded: false }));
|
||||
|
||||
expect(source.cancelRunningForegroundTasks()).toEqual([
|
||||
{ kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still routes a backgrounded subagent that settles after the interrupt", () => {
|
||||
// The headline case: interrupt, continue, and the child that was told to outlive the turn
|
||||
// reports completion later. Wiping the routing table on cancel drops this on the floor and
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
*
|
||||
* Verified on the wire (Claude Code 2.1.220, Paseo's own query options):
|
||||
*
|
||||
* task_started task_id, tool_use_id, description, subagent_type, task_type,
|
||||
* is_backgrounded
|
||||
* task_started task_id, tool_use_id, description, subagent_type, task_type
|
||||
* task_updated task_id, patch.status, patch.is_backgrounded
|
||||
* task_notification task_id, tool_use_id, status
|
||||
*
|
||||
@@ -40,11 +39,6 @@ interface TaskStartedMessage {
|
||||
task_type?: string;
|
||||
prompt?: string;
|
||||
skip_transcript?: boolean;
|
||||
/**
|
||||
* Set at spawn: true for a task registered in the background. A resumed subagent is always
|
||||
* registered in the background.
|
||||
*/
|
||||
is_backgrounded?: boolean;
|
||||
}
|
||||
|
||||
/** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */
|
||||
@@ -77,8 +71,8 @@ interface TaskUpdatedMessage {
|
||||
task_id: string;
|
||||
/**
|
||||
* `is_backgrounded` is declared on `SDKTaskUpdatedMessage["patch"]`: it flips when a foreground
|
||||
* task is moved to the background after `task_started`. Together with `task_started`'s own
|
||||
* flag it separates a child that dies with its turn from one told to outlive it.
|
||||
* task is backgrounded, which is the only signal that separates a child that dies with its turn
|
||||
* from one that was explicitly told to outlive it.
|
||||
*/
|
||||
patch?: { status?: string; is_backgrounded?: boolean };
|
||||
}
|
||||
@@ -177,8 +171,8 @@ export class ClaudeTaskProtocolSource {
|
||||
/** Workflow invocations already own a real Workflow card in the parent timeline. */
|
||||
private readonly idsWithExistingParentToolCard = new Set<string>();
|
||||
/**
|
||||
* Declared subagents running in the background, whether spawned there or moved there later.
|
||||
* They outlive the turn that spawned them, so a turn ending is not evidence that they stopped.
|
||||
* Declared subagents that were moved to the background. They outlive the turn that spawned
|
||||
* them, so a turn ending is not evidence that they stopped.
|
||||
*/
|
||||
private readonly backgroundedIds = new Set<string>();
|
||||
/** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */
|
||||
@@ -337,7 +331,6 @@ export class ClaudeTaskProtocolSource {
|
||||
|
||||
this.sawTaskStarted = true;
|
||||
const existingId = this.subagentIdByTaskId.get(message.task_id);
|
||||
this.recordBackgrounded(existingId ?? id, message.is_backgrounded);
|
||||
if (existingId) {
|
||||
return this.observeExistingTaskStart(message, id, existingId);
|
||||
}
|
||||
@@ -421,15 +414,14 @@ export class ClaudeTaskProtocolSource {
|
||||
|
||||
private observeTaskUpdated(message: TaskUpdatedMessage): SubagentObservation[] {
|
||||
const id = this.subagentIdByTaskId.get(message.task_id);
|
||||
if (id) this.recordBackgrounded(id, message.patch?.is_backgrounded);
|
||||
const backgrounded = message.patch?.is_backgrounded;
|
||||
if (id && typeof backgrounded === "boolean") {
|
||||
if (backgrounded) this.backgroundedIds.add(id);
|
||||
else this.backgroundedIds.delete(id);
|
||||
}
|
||||
return this.observeStatus(message.task_id, message.patch?.status);
|
||||
}
|
||||
|
||||
private recordBackgrounded(id: string, backgrounded: boolean | undefined): void {
|
||||
if (backgrounded === true) this.backgroundedIds.add(id);
|
||||
else if (backgrounded === false) this.backgroundedIds.delete(id);
|
||||
}
|
||||
|
||||
private observeTaskNotification(message: TaskNotificationMessage): SubagentObservation[] {
|
||||
const observations = this.observeWorkflowResult(message);
|
||||
observations.push(...this.observeUsage(message.task_id, message.usage));
|
||||
|
||||
@@ -304,166 +304,6 @@ async function waitForRunningClaudeSleep(
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit point-in-time polls of agent status, on top of (not instead of) the pushed
|
||||
// agent_update events the collector already records. A phantom "running" flip that starts and
|
||||
// ends between two polls would still be caught by the pushed-event scan the callers pair this
|
||||
// with; polling here additionally proves the daemon answers fetchAgent with "idle" throughout,
|
||||
// not just that it eventually pushes one.
|
||||
async function sampleAgentStatuses(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
samples: number,
|
||||
intervalMs: number,
|
||||
): Promise<string[]> {
|
||||
const statuses: string[] = [];
|
||||
for (let i = 0; i < samples; i += 1) {
|
||||
const snapshot = await client.fetchAgent({ agentId });
|
||||
statuses.push(snapshot?.agent.status ?? "missing");
|
||||
if (i < samples - 1) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
// NOTE: agent_stream events on the wire (AgentStreamEventPayloadSchema in
|
||||
// packages/protocol/src/messages.ts) do not include a "provider_subagent" variant. A backgrounded
|
||||
// subagent's descriptor updates are forwarded exclusively as separate "agent.provider_subagents.update"
|
||||
// messages (see forwardProviderSubagentUpdate in session.ts), and that push channel's delivery is
|
||||
// gated by capabilities this test's plain DaemonClient does not declare. list_provider_subagents is a
|
||||
// plain request/response RPC unaffected by that gating, so status history below polls it directly
|
||||
// instead of trying to reconstruct status transitions from the stream.
|
||||
type ProviderSubagentDescriptor = Awaited<
|
||||
ReturnType<DaemonClient["listProviderSubagents"]>
|
||||
>["subagents"][number];
|
||||
|
||||
interface ProviderSubagentPoll {
|
||||
subagent: ProviderSubagentDescriptor;
|
||||
// Every status observed while polling, in order, so a transient cancel cannot hide behind a
|
||||
// later "completed" read even though the store's status field is not expected to un-cancel.
|
||||
history: string[];
|
||||
}
|
||||
|
||||
// Polls agent.provider_subagents.list until the backgrounded helper is registered at all.
|
||||
// "Main's turn is running" (waitForAgentUpsert) fires on generic turn-start bookkeeping, which
|
||||
// can be true before Claude has actually issued the Agent tool call; canceling that early
|
||||
// cancels a turn with no helper launched yet, which is not what "Stop spares a helper" means to
|
||||
// test. This confirms the helper actually exists before the caller acts on it.
|
||||
async function waitForProviderSubagentToExist(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<ProviderSubagentDescriptor> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const { subagents } = await client.listProviderSubagents(agentId);
|
||||
if (subagents[0]) {
|
||||
return subagents[0];
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for a background helper to be registered for agent ${agentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Claude starts the wake-up turn a moment after the helper's completion lands (0.1 to 5 s in
|
||||
// recorded traces), so poll for it instead of checking the instant the helper reads completed.
|
||||
// The daemon announces an autonomous turn to subscribers as a pushed "running" agent_update,
|
||||
// not as an agent_stream turn_started (that stream reaches the client that started a run).
|
||||
async function waitForWakeTurn(
|
||||
collector: ReturnType<typeof createMessageCollector>,
|
||||
agentId: string,
|
||||
fromIndex: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const window = collector.messages.slice(fromIndex);
|
||||
const woke =
|
||||
getAgentStatuses(window, agentId).includes("running") ||
|
||||
window.some(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "turn_started",
|
||||
);
|
||||
if (woke) return true;
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The wake-up turn runs briefly after it opens, so the final state is read once it settles.
|
||||
async function waitForAgentStatus(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
status: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<string | undefined> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: string | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
last = (await client.fetchAgent({ agentId }))?.agent.status;
|
||||
if (last === status) return last;
|
||||
await sleep(500);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
// Polls agent.provider_subagents.list until the single backgrounded helper leaves "running".
|
||||
// Scenarios in this file always launch exactly one background helper, so subagents[0] is it.
|
||||
async function waitForProviderSubagentTerminal(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
timeoutMs = 90_000,
|
||||
): Promise<ProviderSubagentPoll> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const history: string[] = [];
|
||||
let lastSeen: ProviderSubagentDescriptor[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
const { subagents } = await client.listProviderSubagents(agentId);
|
||||
lastSeen = subagents;
|
||||
const subagent = subagents[0];
|
||||
if (subagent) {
|
||||
history.push(subagent.status);
|
||||
if (subagent.status !== "running") {
|
||||
return { subagent, history };
|
||||
}
|
||||
}
|
||||
await sleep(1_000);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for the background helper to leave "running". history=${JSON.stringify(history)} lastSeen=${JSON.stringify(lastSeen)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pulls the main agent's own assistant text via fetchAgentTimeline rather than the push
|
||||
// collector. Assistant text delivery is paced (docs/agent-stream-performance.md), so checking
|
||||
// the collector immediately after waitForFinish resolves can still miss the final text; the
|
||||
// timeline fetch is authoritative and matches what the pre-existing tests in this file do.
|
||||
async function fetchAssistantTexts(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
limit = 100,
|
||||
): Promise<string[]> {
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit });
|
||||
return timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text);
|
||||
}
|
||||
|
||||
async function fetchSubagentAssistantTexts(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
subagentId: string,
|
||||
): Promise<string[]> {
|
||||
const timeline = await client.fetchProviderSubagentTimeline(agentId, subagentId, { limit: 50 });
|
||||
return timeline.rows
|
||||
.filter((row) => row.item.type === "assistant_message")
|
||||
.map((row) => (row.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text);
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude) - send message during tool call", () => {
|
||||
let canRun = false;
|
||||
interface SteeringResources {
|
||||
@@ -664,141 +504,6 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
|
||||
}
|
||||
}, 210_000);
|
||||
|
||||
test("a background subagent keeps running after a message is sent while it works", async () => {
|
||||
// The scenario the rest of this file explicitly rules out: every other test here tells Claude
|
||||
// "Do not use a background task", so steering during a backgrounded subagent was never
|
||||
// covered. A subagent streams on the parent's channel, so a parent that mistakes the child's
|
||||
// frames for its own work reports "running" after it has already finished, and the message
|
||||
// sent into that window is refused, or delivered by cancelling the turn and taking the
|
||||
// subagent with it. Both outcomes are user-visible, and both are what this asserts against.
|
||||
const logger = pino({ level: "silent" });
|
||||
const resources: SteeringResources = {
|
||||
cwd: tmpCwd(),
|
||||
daemon: null,
|
||||
client: null,
|
||||
collector: null,
|
||||
};
|
||||
try {
|
||||
resources.daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` });
|
||||
const { client, cwd } = resources;
|
||||
if (!client) throw new Error("Claude background-subagent test client was not created");
|
||||
await within("connect background-subagent test client", 15_000, client.connect());
|
||||
await within(
|
||||
"subscribe background-subagent test client",
|
||||
15_000,
|
||||
client.fetchAgents({ subscribe: {} }),
|
||||
);
|
||||
const agent = await within(
|
||||
"create background-subagent test agent",
|
||||
30_000,
|
||||
client.createAgent({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
title: "claude-background-survives-send",
|
||||
...getRealProviderConfig("claude"),
|
||||
}),
|
||||
);
|
||||
resources.collector = createMessageCollector(client);
|
||||
await within(
|
||||
"launch one background subagent and end the turn",
|
||||
30_000,
|
||||
client.sendAgentMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Agent tool exactly once with run_in_background: true.",
|
||||
"subagent_type: general-purpose.",
|
||||
`description: "bg survivor".`,
|
||||
`prompt: "Use the Bash tool with run_in_background: true to run exactly: sleep 40.`,
|
||||
`Wait for its completion notification, then reply exactly: CHILD_DONE."`,
|
||||
"Immediately after launching it, reply with the single word LAUNCHED and end your turn.",
|
||||
"Do not wait for it. Do not call any other tools.",
|
||||
].join(" "),
|
||||
{ messageId: generateClientMessageId() },
|
||||
),
|
||||
);
|
||||
|
||||
// The main session has ended its turn. It must read as idle even though the subagent it
|
||||
// launched is still streaming on the shared channel.
|
||||
const afterLaunch = await within(
|
||||
"wait for the main session to go idle after launching the subagent",
|
||||
120_000,
|
||||
client.waitForFinish(agent.id, 110_000),
|
||||
);
|
||||
expect(
|
||||
afterLaunch.status,
|
||||
"a main session that finished its turn must not report busy because a subagent is running",
|
||||
).not.toBe("running");
|
||||
|
||||
// And a message sent in that window must be accepted rather than refused.
|
||||
const followUpId = generateClientMessageId();
|
||||
await within(
|
||||
"send a message to the idle main session while the subagent runs",
|
||||
30_000,
|
||||
client.sendAgentMessage(agent.id, "Are you there? Reply exactly: MAIN_ALIVE.", {
|
||||
messageId: followUpId,
|
||||
}),
|
||||
);
|
||||
await within(
|
||||
"wait for the main session to answer",
|
||||
120_000,
|
||||
client.waitForFinish(agent.id, 110_000),
|
||||
);
|
||||
|
||||
const timeline = await within(
|
||||
"fetch background-subagent timeline",
|
||||
15_000,
|
||||
client.fetchAgentTimeline(agent.id, { limit: 200 }),
|
||||
);
|
||||
const assistantText = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map(
|
||||
(entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text,
|
||||
)
|
||||
.join("\\n");
|
||||
expect(
|
||||
assistantText,
|
||||
"the main session must be reachable while its background subagent still runs",
|
||||
).toContain("MAIN_ALIVE");
|
||||
|
||||
// The subagent must have survived the send rather than being cancelled with the turn. Its
|
||||
// status is read from agent.provider_subagents.list (see the note above
|
||||
// waitForProviderSubagentToExist): agent_stream carries no provider_subagent events.
|
||||
const { subagents } = await within(
|
||||
"list provider subagents after the send",
|
||||
15_000,
|
||||
client.listProviderSubagents(agent.id),
|
||||
);
|
||||
expect(subagents.length, "the background subagent must be tracked").toBeGreaterThan(0);
|
||||
expect(
|
||||
subagents.map((subagent) => subagent.status),
|
||||
"a backgrounded subagent must not be canceled by a message sent to the main session",
|
||||
).not.toContain("canceled");
|
||||
const { subagent, history } = await within(
|
||||
"wait for the background subagent to finish",
|
||||
90_000,
|
||||
waitForProviderSubagentTerminal(client, agent.id, 80_000),
|
||||
);
|
||||
expect(subagent.status, "the helper must complete, not be canceled or fail").toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(history, "no status poll may read canceled").not.toContain("canceled");
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([
|
||||
Promise.resolve(resources.collector?.unsubscribe()),
|
||||
resources.client?.close() ?? Promise.resolve(),
|
||||
resources.daemon?.close() ?? Promise.resolve(),
|
||||
]);
|
||||
if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true });
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
expect(failures, "Claude background-subagent E2E cleanup failures").toEqual([]);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("Stop cancels a queued Claude steer before it can resume the interrupted turn", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
@@ -973,430 +678,4 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("a helper's own nested background job does not phantom-run the parent or kill the helper", async () => {
|
||||
// The exact reported bug: main backgrounds a helper, the helper itself starts its OWN
|
||||
// background job (a grandchild from main's perspective) and then keeps working in the
|
||||
// foreground. The grandchild's completion is bookkeeping that belongs to the helper, not to
|
||||
// main, and must not make main phantom-report "running" or get treated as busy.
|
||||
const logger = pino({ level: "silent" });
|
||||
const resources: SteeringResources = {
|
||||
cwd: tmpCwd(),
|
||||
daemon: null,
|
||||
client: null,
|
||||
collector: null,
|
||||
};
|
||||
try {
|
||||
resources.daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` });
|
||||
const { client, cwd } = resources;
|
||||
if (!client) throw new Error("nested background-job test client was not created");
|
||||
await within("connect nested background-job test client", 15_000, client.connect());
|
||||
await within(
|
||||
"subscribe nested background-job test client",
|
||||
15_000,
|
||||
client.fetchAgents({ subscribe: {} }),
|
||||
);
|
||||
const agent = await within(
|
||||
"create nested background-job test agent",
|
||||
30_000,
|
||||
client.createAgent({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
title: "claude-nested-background-job",
|
||||
...getRealProviderConfig("claude"),
|
||||
}),
|
||||
);
|
||||
resources.collector = createMessageCollector(client);
|
||||
|
||||
await within(
|
||||
"launch a helper with its own nested background job and end the turn",
|
||||
30_000,
|
||||
client.sendAgentMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Agent tool exactly once with run_in_background: true.",
|
||||
"subagent_type: general-purpose.",
|
||||
`description: "nested bg survivor".`,
|
||||
`prompt: "Use the Bash tool with run_in_background: true to run exactly:`,
|
||||
`python3 -c 'import time; time.sleep(5)'.`,
|
||||
`Immediately after starting it, without waiting for it, use the Bash tool again`,
|
||||
`in the foreground to run exactly: python3 -c 'import time; time.sleep(50)'.`,
|
||||
`After that foreground command finishes, reply exactly: CHILD_DONE."`,
|
||||
"Immediately after launching it, reply with the single word LAUNCHED and end your turn.",
|
||||
"Do not wait for it. Do not call any other tools.",
|
||||
].join(" "),
|
||||
{ messageId: generateClientMessageId() },
|
||||
),
|
||||
);
|
||||
|
||||
const afterLaunch = await within(
|
||||
"wait for the main session to go idle after launching the nested-background helper",
|
||||
120_000,
|
||||
client.waitForFinish(agent.id, 110_000),
|
||||
);
|
||||
expect(afterLaunch.status, "the main session must be idle once it replied LAUNCHED").toBe(
|
||||
"idle",
|
||||
);
|
||||
|
||||
// The helper's own nested background job (5s) finishes while the helper is still busy
|
||||
// in its unrelated foreground sleep (50s). Sample for 30s, well past the nested job's
|
||||
// completion even when the helper is slow to start it,
|
||||
// both by polling and by scanning every pushed agent_update in the window, so a brief
|
||||
// phantom flip cannot slip between polls.
|
||||
const sampleWindowStart = resources.collector.messages.length;
|
||||
const polledStatuses = await sampleAgentStatuses(client, agent.id, 11, 3_000);
|
||||
const pushedStatuses = getAgentStatuses(
|
||||
resources.collector.messages.slice(sampleWindowStart),
|
||||
agent.id,
|
||||
);
|
||||
const observedRunning = [...polledStatuses, ...pushedStatuses].filter(
|
||||
(status) => status === "running",
|
||||
);
|
||||
expect(
|
||||
observedRunning,
|
||||
`the parent must stay idle across the nested job's completion; polled=${JSON.stringify(polledStatuses)} pushed=${JSON.stringify(pushedStatuses)}`,
|
||||
).toEqual([]);
|
||||
|
||||
// A message sent in that window must still be answered normally.
|
||||
await within(
|
||||
"send PONG request while the nested job settles",
|
||||
30_000,
|
||||
client.sendMessage(agent.id, "Reply with exactly PONG.", {
|
||||
messageId: generateClientMessageId(),
|
||||
}),
|
||||
);
|
||||
await within("wait for the PONG reply", 60_000, client.waitForFinish(agent.id, 50_000));
|
||||
// Pulled via fetchAgentTimeline, not the push collector: assistant text delivery is paced
|
||||
// (docs/agent-stream-performance.md) and can trail waitForFinish's own completion signal,
|
||||
// so the collector can still be missing the final text the instant waitForFinish resolves.
|
||||
const pongTexts = await within(
|
||||
"fetch timeline for the PONG reply",
|
||||
15_000,
|
||||
fetchAssistantTexts(client, agent.id, 20),
|
||||
);
|
||||
expect(
|
||||
pongTexts.join("\n"),
|
||||
"the agent must answer PONG while the helper is still running",
|
||||
).toContain("PONG");
|
||||
|
||||
const messagesBeforeWake = resources.collector.messages.length;
|
||||
|
||||
// The helper itself must survive: never recorded canceled, and it eventually reports
|
||||
// CHILD_DONE from its own foreground sleep finishing.
|
||||
const { subagent, history } = await within(
|
||||
"wait for the nested-background helper to finish",
|
||||
90_000,
|
||||
waitForProviderSubagentTerminal(client, agent.id, 80_000),
|
||||
);
|
||||
expect(subagent.status, "the helper must complete, not be canceled or fail").toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(
|
||||
history,
|
||||
`no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`,
|
||||
).not.toContain("canceled");
|
||||
const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id);
|
||||
expect(
|
||||
subagentTexts.join("\n"),
|
||||
"the helper's own transcript must contain its final CHILD_DONE reply",
|
||||
).toContain("CHILD_DONE");
|
||||
|
||||
// The helper's completion should wake the main session with a fresh turn (the
|
||||
// session-state-driven autonomous turn), separate from the PONG exchange above.
|
||||
const wokeMainAgent = await waitForWakeTurn(
|
||||
resources.collector,
|
||||
agent.id,
|
||||
messagesBeforeWake,
|
||||
);
|
||||
if (!wokeMainAgent) {
|
||||
const wakeWindowMessages = resources.collector.messages.slice(messagesBeforeWake);
|
||||
const wakeWindowEventTypes = wakeWindowMessages
|
||||
.filter(
|
||||
(message) => message.type === "agent_stream" && message.payload.agentId === agent.id,
|
||||
)
|
||||
.map((message) =>
|
||||
message.type === "agent_stream" ? message.payload.event.type : "unknown",
|
||||
);
|
||||
const timeline = await client
|
||||
.fetchAgentTimeline(agent.id, { limit: 100 })
|
||||
.catch(() => null);
|
||||
throw new Error(
|
||||
`the helper's completion must wake the main session with a new turn. ` +
|
||||
`wakeWindowEventTypes=${JSON.stringify(wakeWindowEventTypes)} ` +
|
||||
`recentTimeline=${JSON.stringify(timeline ? summarizeTimelineItems(timeline) : null)}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe(
|
||||
"idle",
|
||||
);
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([
|
||||
Promise.resolve(resources.collector?.unsubscribe()),
|
||||
resources.client?.close() ?? Promise.resolve(),
|
||||
resources.daemon?.close() ?? Promise.resolve(),
|
||||
]);
|
||||
if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true });
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
expect(failures, "nested background-job E2E cleanup failures").toEqual([]);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("the app's default steer survives a backgrounded helper while the main session works", async () => {
|
||||
// appSettings.sendBehavior defaults to "steer" (packages/app/src/hooks/use-settings/storage.ts),
|
||||
// so this is what the app actually sends when a user replies while an agent looks busy.
|
||||
const logger = pino({ level: "silent" });
|
||||
const resources: SteeringResources = {
|
||||
cwd: tmpCwd(),
|
||||
daemon: null,
|
||||
client: null,
|
||||
collector: null,
|
||||
};
|
||||
try {
|
||||
resources.daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` });
|
||||
const { client, cwd } = resources;
|
||||
if (!client) throw new Error("default-steer background-helper test client was not created");
|
||||
await within("connect default-steer test client", 15_000, client.connect());
|
||||
await within(
|
||||
"subscribe default-steer test client",
|
||||
15_000,
|
||||
client.fetchAgents({ subscribe: {} }),
|
||||
);
|
||||
const agent = await within(
|
||||
"create default-steer test agent",
|
||||
30_000,
|
||||
client.createAgent({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
title: "claude-default-steer-background-helper",
|
||||
...getRealProviderConfig("claude"),
|
||||
}),
|
||||
);
|
||||
resources.collector = createMessageCollector(client);
|
||||
|
||||
await within(
|
||||
"launch the background helper and start the main session's own foreground sleep",
|
||||
30_000,
|
||||
client.sendAgentMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Agent tool exactly once with run_in_background: true.",
|
||||
"subagent_type: general-purpose.",
|
||||
`description: "bg survivor b".`,
|
||||
`prompt: "Use the Bash tool in the foreground to run exactly:`,
|
||||
`python3 -c 'import time; time.sleep(42)'. After it finishes, reply exactly: CHILD_DONE."`,
|
||||
"Do not wait for that background helper to finish before continuing.",
|
||||
"After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,",
|
||||
"to run exactly: python3 -c 'import time; time.sleep(16)'.",
|
||||
// Matching the proven pattern from the first test in this file: tell Claude to wait
|
||||
// for a later message rather than relying on the sleep call itself to block, since a
|
||||
// Bash call can resolve through Claude Code's own async task_notification path once a
|
||||
// background subagent is already active instead of blocking the turn.
|
||||
"Do not reply or finish your turn until a later user message arrives, no matter how",
|
||||
"quickly that Bash command finishes. When that later message arrives, address it and",
|
||||
"end your turn. Do not call any other tools.",
|
||||
].join(" "),
|
||||
{ messageId: generateClientMessageId() },
|
||||
),
|
||||
);
|
||||
|
||||
// Confirm the daemon considers main's own turn active before steering it. This does not
|
||||
// wait for a specific "sleep 16" shell call to show as running: Claude Code 2.1.280 can
|
||||
// report a slower Bash call through its own async task_notification tool instead of a
|
||||
// classic blocking call once a background subagent is already active, so pinning to a
|
||||
// literal running shell tool_call is not reliable here. Busy at the daemon level is what
|
||||
// "while main is busy with its own sleep" actually needs.
|
||||
await within(
|
||||
"confirm main's own turn is active before steering",
|
||||
30_000,
|
||||
client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 25_000),
|
||||
);
|
||||
// The running status precedes the Agent tool call, so a steer sent on it alone can reach
|
||||
// Claude before the helper exists and prove nothing about a helper surviving it.
|
||||
await within(
|
||||
"confirm the background helper is registered before steering",
|
||||
60_000,
|
||||
waitForProviderSubagentToExist(client, agent.id, 55_000),
|
||||
);
|
||||
|
||||
await within(
|
||||
"send the app's default steer while main is busy with its own sleep",
|
||||
30_000,
|
||||
client.sendMessage(agent.id, "Reply with exactly PONG.", {
|
||||
messageId: generateClientMessageId(),
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
);
|
||||
|
||||
const finish = await within(
|
||||
"wait for the main session to finish after the steer",
|
||||
120_000,
|
||||
client.waitForFinish(agent.id, 110_000),
|
||||
);
|
||||
expect(finish.status, "the main session must end idle after the steer").toBe("idle");
|
||||
// Pulled via fetchAgentTimeline, not the push collector: see fetchAssistantTexts.
|
||||
const postSteerTexts = await within(
|
||||
"fetch timeline for the post-steer PONG reply",
|
||||
15_000,
|
||||
fetchAssistantTexts(client, agent.id, 20),
|
||||
);
|
||||
expect(postSteerTexts.join("\n"), "PONG must be answered after the steer").toContain("PONG");
|
||||
|
||||
const { subagent, history } = await within(
|
||||
"wait for the background helper to finish",
|
||||
90_000,
|
||||
waitForProviderSubagentTerminal(client, agent.id, 80_000),
|
||||
);
|
||||
expect(subagent.status, "the helper must complete, not be canceled or fail").toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(
|
||||
history,
|
||||
`no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`,
|
||||
).not.toContain("canceled");
|
||||
const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id);
|
||||
expect(
|
||||
subagentTexts.join("\n"),
|
||||
"the helper's own transcript must contain its final CHILD_DONE reply",
|
||||
).toContain("CHILD_DONE");
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([
|
||||
Promise.resolve(resources.collector?.unsubscribe()),
|
||||
resources.client?.close() ?? Promise.resolve(),
|
||||
resources.daemon?.close() ?? Promise.resolve(),
|
||||
]);
|
||||
if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true });
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
expect(failures, "default-steer background-helper E2E cleanup failures").toEqual([]);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("Stop cancels the main session's own turn but spares its background helper", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const resources: SteeringResources = {
|
||||
cwd: tmpCwd(),
|
||||
daemon: null,
|
||||
client: null,
|
||||
collector: null,
|
||||
};
|
||||
try {
|
||||
resources.daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` });
|
||||
const { client, cwd } = resources;
|
||||
if (!client) throw new Error("Stop-spares-helper test client was not created");
|
||||
await within("connect Stop-spares-helper test client", 15_000, client.connect());
|
||||
await within(
|
||||
"subscribe Stop-spares-helper test client",
|
||||
15_000,
|
||||
client.fetchAgents({ subscribe: {} }),
|
||||
);
|
||||
const agent = await within(
|
||||
"create Stop-spares-helper test agent",
|
||||
30_000,
|
||||
client.createAgent({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
title: "claude-stop-spares-helper",
|
||||
...getRealProviderConfig("claude"),
|
||||
}),
|
||||
);
|
||||
resources.collector = createMessageCollector(client);
|
||||
|
||||
await within(
|
||||
"launch the background helper and start the main session's own foreground sleep",
|
||||
30_000,
|
||||
client.sendAgentMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Agent tool exactly once with run_in_background: true.",
|
||||
"subagent_type: general-purpose.",
|
||||
`description: "bg survivor c".`,
|
||||
`prompt: "Use the Bash tool in the foreground to run exactly:`,
|
||||
`python3 -c 'import time; time.sleep(43)'. After it finishes, reply exactly: CHILD_DONE."`,
|
||||
"Do not wait for that background helper to finish before continuing.",
|
||||
"After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,",
|
||||
"to run exactly: python3 -c 'import time; time.sleep(23)'.",
|
||||
"Do not call any other tools.",
|
||||
].join(" "),
|
||||
{ messageId: generateClientMessageId() },
|
||||
),
|
||||
);
|
||||
|
||||
// Confirm the helper has actually been launched (registered) before Stop, not just that
|
||||
// main's turn shows "running": that status can flip true on generic turn-start bookkeeping
|
||||
// before Claude has issued the Agent tool call, and canceling that early cancels a turn
|
||||
// with no helper to spare yet.
|
||||
await within(
|
||||
"confirm the background helper is registered before Stop",
|
||||
60_000,
|
||||
waitForProviderSubagentToExist(client, agent.id, 55_000),
|
||||
);
|
||||
|
||||
const messagesBeforeStop = resources.collector.messages.length;
|
||||
await within("Stop the main session's own turn", 30_000, client.cancelAgent(agent.id));
|
||||
const afterStop = await within(
|
||||
"wait for the agent to go idle after Stop",
|
||||
60_000,
|
||||
client.waitForFinish(agent.id, 50_000),
|
||||
);
|
||||
expect(afterStop.status, "Stop must leave the agent idle").toBe("idle");
|
||||
|
||||
// The helper must survive Stop: it keeps running and later reports CHILD_DONE, which
|
||||
// wakes the main session with a fresh autonomous turn.
|
||||
const { subagent, history } = await within(
|
||||
"wait for the background helper to finish after Stop",
|
||||
90_000,
|
||||
waitForProviderSubagentTerminal(client, agent.id, 80_000),
|
||||
);
|
||||
expect(subagent.status, "Stop must not cancel or fail the background helper").toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(
|
||||
history,
|
||||
`no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`,
|
||||
).not.toContain("canceled");
|
||||
const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id);
|
||||
expect(
|
||||
subagentTexts.join("\n"),
|
||||
"the helper's own transcript must contain its final CHILD_DONE reply",
|
||||
).toContain("CHILD_DONE");
|
||||
|
||||
const wokeMainAgent = await waitForWakeTurn(
|
||||
resources.collector,
|
||||
agent.id,
|
||||
messagesBeforeStop,
|
||||
);
|
||||
expect(
|
||||
wokeMainAgent,
|
||||
"the helper's completion after Stop must wake the main session with a new turn",
|
||||
).toBe(true);
|
||||
|
||||
expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe(
|
||||
"idle",
|
||||
);
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([
|
||||
Promise.resolve(resources.collector?.unsubscribe()),
|
||||
resources.client?.close() ?? Promise.resolve(),
|
||||
resources.daemon?.close() ?? Promise.resolve(),
|
||||
]);
|
||||
if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true });
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
expect(failures, "Stop-spares-helper E2E cleanup failures").toEqual([]);
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
@@ -114,34 +114,21 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
|
||||
});
|
||||
|
||||
test("searches for a term typed before the page finished loading", async ({ page }) => {
|
||||
const loadScripts = await holdScripts(page);
|
||||
let loadScripts!: () => void;
|
||||
const scriptsHeld = new Promise<void>((resolve) => (loadScripts = resolve));
|
||||
await page.route(/\.js($|\?)/, async (route) => {
|
||||
await scriptsHeld;
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" });
|
||||
await searchPlugins(page, "graphite");
|
||||
await loadScripts();
|
||||
loadScripts();
|
||||
await page.waitForLoadState("load");
|
||||
await expect(page.getByRole("button", { name: "Clear search" })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
|
||||
});
|
||||
|
||||
test("filters browse results for a term typed before the page finished loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const loadScripts = await holdScripts(page);
|
||||
await page.goto("/plugins/all", { waitUntil: "domcontentloaded" });
|
||||
const entries = await historyLength(page);
|
||||
await searchPlugins(page, "gra");
|
||||
await loadScripts();
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
|
||||
expect(await historyLength(page)).toBe(entries + 1);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/plugins\/all$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("clears the search with the clear button", async ({ page }) => {
|
||||
await page.goto("/plugins/all");
|
||||
const searchbox = page.getByRole("searchbox", { name: "Search plugins" });
|
||||
@@ -333,20 +320,6 @@ test.describe("search engine visits without JavaScript", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Holds the page's scripts so typing lands before hydration; the returned function loads them. */
|
||||
async function holdScripts(page: Page): Promise<() => Promise<void>> {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => (release = resolve));
|
||||
await page.route(/\.js($|\?)/, async (route) => {
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
return async () => {
|
||||
release();
|
||||
await page.waitForLoadState("load");
|
||||
};
|
||||
}
|
||||
|
||||
async function searchPlugins(page: Page, term: string) {
|
||||
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -46,12 +46,6 @@ export function PluginSearch({
|
||||
},
|
||||
[live, show, term],
|
||||
);
|
||||
// A term typed before the page hydrated is in the box but not in state; adopt it. Once hydrated,
|
||||
// the box always matches state, so this does nothing.
|
||||
useEffect(() => {
|
||||
const typed = input.current?.value ?? "";
|
||||
if (typed !== term) edit(typed);
|
||||
}, [edit, term]);
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => edit(event.target.value),
|
||||
[edit],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -141,7 +141,7 @@ Daemon logging uses separate console and file sinks by default:
|
||||
|
||||
- Console: `info` and above
|
||||
- File (`$PASEO_HOME/daemon.log`): `trace` and above
|
||||
- File rotation: `10m` max file size, `3` rotated files kept alongside the active file
|
||||
- File rotation: `10m` max file size, `2` retained files total (active + 1 rotated)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -155,7 +155,7 @@ Daemon logging uses separate console and file sinks by default:
|
||||
"path": "daemon.log",
|
||||
"rotate": {
|
||||
"maxSize": "10m",
|
||||
"maxFiles": 3
|
||||
"maxFiles": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ The image runs the daemon and serves the bundled web UI. It does not bundle agen
|
||||
|
||||
## Where next
|
||||
|
||||
- [Run parallel tasks](/docs/parallel-development), use separate worktrees, review diffs, and test changes in the desktop app.
|
||||
- [Connectivity](/docs/connectivity), connect through the relay or Tailscale.
|
||||
- [Docker](/docs/docker), run the daemon and bundled web UI in a container.
|
||||
- [Workspaces](/docs/workspaces), the project, workspace, and session model Paseo is built around.
|
||||
|
||||
@@ -10,8 +10,6 @@ category: Orchestration
|
||||
|
||||
Paseo lets your coding agents coordinate other agents, split work across providers and machines, and keep tasks moving automatically.
|
||||
|
||||
You can also start and supervise each task yourself in the desktop app. [Run parallel tasks in separate worktrees](/docs/parallel-development), inspect their diffs, and test each app in a terminal and browser before choosing what to merge.
|
||||
|
||||
## What your agents can do
|
||||
|
||||
- **Choose providers and models:** launch other agents using any provider and model configured on the host.
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
title: Run parallel tasks
|
||||
description: Run coding agents in separate worktrees, review their diffs, and test each change with terminals and the built-in browser in Paseo.
|
||||
nav: Run parallel tasks
|
||||
order: 12
|
||||
category: Workspaces
|
||||
---
|
||||
|
||||
# Run parallel tasks
|
||||
|
||||
Use Paseo's desktop app to give several coding agents independent tasks, review their changes, and run the results. Each task gets a workspace with its own git worktree and branch, with agents, files, diffs, terminals, and browser tabs together.
|
||||
|
||||
Start with the [desktop app](/docs#desktop-app-recommended), a local git repository, and at least one [coding agent installed and authenticated](/docs/supported-providers). The examples below use a repository with an `origin/main` branch.
|
||||
|
||||
## Start two tasks
|
||||
|
||||
1. Open **New workspace** and select your project.
|
||||
2. Set **Isolation** to **New worktree** and choose `origin/main` as the starting ref.
|
||||
3. Choose an agent and model, then submit a task, for example: “Add filtering to the project list. Run the relevant tests.”
|
||||
4. Create a second workspace with **New worktree** from the same starting ref. Give its agent another task: “Add keyboard navigation to the settings page. Run the relevant tests.”
|
||||
|
||||
Both agents run concurrently. Select either workspace in the sidebar to read progress or send a follow-up. Choose the same provider for both tasks or a different provider for each.
|
||||
|
||||
Separate worktrees keep their file edits on separate branches. For two alternative implementations of the same feature, give both agents the same task and compare their results before choosing one.
|
||||
|
||||
## Review each change
|
||||
|
||||
Select a workspace and open **Changes** from the Command Center to inspect its diff. Open **Files** to browse and edit the source. Use split panes to keep the agent conversation beside the code you're reviewing.
|
||||
|
||||
Send corrections to that workspace's agent. To get another opinion, use **New agent** in the same workspace and ask it to review the changes without editing files. Agents in the same workspace share its working directory.
|
||||
|
||||
## Run and test the app
|
||||
|
||||
In the selected workspace, use **New terminal** from the Command Center. Install dependencies and run your project's test or development command there.
|
||||
|
||||
Each worktree has its own files, but processes still share the machine's network ports. Assign a different dev-server port to each running copy. For repeatable setup and service ports, configure [worktree setup and scripts](/docs/worktrees#paseojson).
|
||||
|
||||
For a web app, use **New browser** and enter that workspace's dev-server URL. Exercise the changed flow, then switch workspaces to test the other implementation. Browser tabs run inside the desktop app.
|
||||
|
||||
You can also ask the agent to check the app in those tabs. Enable [browser automation](/docs/browser#enabling), then give it the URL and concrete steps to verify, for example:
|
||||
|
||||
> Open http://localhost:3001, filter the project list by name, and verify that clearing the filter restores the full list. Report any failures and take a screenshot of the result.
|
||||
|
||||
## Choose what to merge
|
||||
|
||||
Review the diff and test results for each workspace. Ask for any remaining changes before committing and opening a pull request for that branch, using your usual Git workflow in the workspace terminal or through its agent.
|
||||
|
||||
For independent tasks, review and merge each branch. For competing implementations, choose the version you want to keep. Archive a workspace when you no longer need it; [worktree cleanup](/docs/worktrees#manage-the-workspace) removes the managed directory after its last workspace is archived.
|
||||
|
||||
## Let an agent coordinate the tasks
|
||||
|
||||
When you want an agent to create the workspaces, launch workers, and collect their results, use [orchestration](/docs/orchestration). The workers appear in Paseo so you can inspect their conversations and changes as they work.
|
||||
@@ -8,9 +8,7 @@ category: Providers
|
||||
|
||||
# Providers
|
||||
|
||||
Paseo runs **existing coding agents you've installed and authenticated** in workspaces with an editor, terminals, diffs, and, on desktop, a browser. Run several agents in parallel, choose a provider for each task, and review their work in the same app. Your subscriptions, config, skills, and MCP servers stay intact.
|
||||
|
||||
To try the workflow, [run parallel tasks in separate worktrees](/docs/parallel-development).
|
||||
Paseo doesn't ship its own coding agent. It launches and supervises **existing CLIs you've already installed and authenticated**, Claude Code, Codex, OpenCode, Antigravity, Muse Code, Cursor, Gemini, and the rest. Your subscriptions, your config, your skills, your MCP servers all stay intact. Paseo just gives you a UI, a CLI, a relay, and orchestration on top.
|
||||
|
||||
## Mental model
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ If you don't want to manage a reverse proxy or open ports, a tunnel gives you an
|
||||
- **Tailscale Serve** keeps it inside your tailnet, no public exposure, TLS handled for you:
|
||||
|
||||
```bash
|
||||
tailscale serve --bg http://127.0.0.1:6767
|
||||
tailscale serve https / http://127.0.0.1:6767
|
||||
```
|
||||
|
||||
Reach it at `https://<your-machine>.<tailnet>.ts.net/`. Only devices on your tailnet can connect.
|
||||
|
||||
@@ -48,8 +48,6 @@ The workspace is the product concept; a git worktree is one way to isolate its f
|
||||
|
||||
## Creating a workspace
|
||||
|
||||
For a desktop walkthrough from starting parallel agents to reviewing and testing their changes, see [Run parallel tasks](/docs/parallel-development).
|
||||
|
||||
You can create a workspace in the app or from the CLI:
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user