mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebae928a9c | ||
|
|
950e1f8f41 | ||
|
|
7a30305503 | ||
|
|
3ad878b47f | ||
|
|
15d774d4a1 | ||
|
|
5fd4072893 | ||
|
|
84054f8d72 | ||
|
|
f02cc16bb4 | ||
|
|
2f0cb2f54b | ||
|
|
4ed13fadb6 | ||
|
|
db303be3c2 | ||
|
|
3e2d77723e | ||
|
|
18412604dd | ||
|
|
bc6ddbfa97 | ||
|
|
27a95a5dec | ||
|
|
9760292650 | ||
|
|
d826d8109f | ||
|
|
62f79e88bb | ||
|
|
9da676d709 | ||
|
|
dfb8a1a2bc | ||
|
|
8ced059b4d | ||
|
|
da9fae172d | ||
|
|
f24bb523e8 | ||
|
|
cf2b3f4bb3 | ||
|
|
a50e14b69e | ||
|
|
7b43f21940 | ||
|
|
2f9a786061 |
@@ -36,7 +36,7 @@ jobs:
|
||||
PASEO_HOME="$(mktemp -d)"
|
||||
export PASEO_LISTEN=127.0.0.1:6767
|
||||
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
@@ -57,7 +57,22 @@ jobs:
|
||||
echo "$STATUS_JSON"
|
||||
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
|
||||
[[ -s "$PASEO_HOME/web-ui.html" ]]
|
||||
exit 0
|
||||
if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then
|
||||
break
|
||||
fi
|
||||
TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")"
|
||||
./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter
|
||||
deadline=$((SECONDS + 15))
|
||||
while (( SECONDS < deadline )); do
|
||||
if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \
|
||||
| jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then
|
||||
./result/bin/paseo terminal kill "$TERMINAL_ID"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Nix terminal did not execute the command."
|
||||
break
|
||||
fi
|
||||
|
||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
@@ -72,7 +87,7 @@ jobs:
|
||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
echo "::group::paseo daemon run stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
|
||||
@@ -84,6 +84,45 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"plugins/*-provider/server/**/*.ts",
|
||||
"packages/plugin/src/server/acp-internal/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "node:child_process",
|
||||
"importNames": [
|
||||
"spawn",
|
||||
"exec",
|
||||
"execFile",
|
||||
"spawnSync",
|
||||
"execSync",
|
||||
"execFileSync"
|
||||
],
|
||||
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
|
||||
},
|
||||
{
|
||||
"name": "child_process",
|
||||
"importNames": [
|
||||
"spawn",
|
||||
"exec",
|
||||
"execFile",
|
||||
"spawnSync",
|
||||
"execSync",
|
||||
"execFileSync"
|
||||
],
|
||||
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
|
||||
+17
-4
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.4 - 2026-10-05
|
||||
## 0.11.0-beta.5 - 2026-10-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 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))
|
||||
- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156))
|
||||
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
|
||||
- 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))
|
||||
@@ -20,6 +20,7 @@
|
||||
- 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 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))
|
||||
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
@@ -29,7 +30,7 @@
|
||||
- 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))
|
||||
- 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 ([#5708](https://github.com/getpaseo/paseo/pull/5708))
|
||||
- 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))
|
||||
- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090))
|
||||
- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
@@ -43,11 +44,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 background Claude subagent's card showing as a bare "Task" that stayed running ([#6067](https://github.com/getpaseo/paseo/pull/6067))
|
||||
- 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 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))
|
||||
@@ -60,12 +62,15 @@
|
||||
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
|
||||
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
|
||||
- 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 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 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))
|
||||
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
@@ -80,8 +85,11 @@
|
||||
- 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))
|
||||
- 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 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 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))
|
||||
- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone))
|
||||
@@ -89,11 +97,16 @@
|
||||
- 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 macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- 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 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))
|
||||
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
|
||||
- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058))
|
||||
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
|
||||
- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||
|
||||
@@ -43,6 +43,18 @@ Run the shared browser profile fixture with:
|
||||
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
On macOS, test native titlebar dragging after scrolling with:
|
||||
|
||||
```bash
|
||||
PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
This group needs Xcode Command Line Tools and Accessibility permission for the
|
||||
terminal running it. It sends real mouse input to its test window and checks
|
||||
window movement, including button clicks and a portal over the titlebar. Leave
|
||||
the mouse idle during this group. It reads the app's production CSS and writes
|
||||
screenshots and window bounds to the output directory.
|
||||
|
||||
The browser profile group runs two Electron processes in sequence. It verifies that each
|
||||
renderer-side `did-attach` identity maps to the correct main-process guest, that two live
|
||||
tabs share cookies and local storage through one persistent session, and that the data is
|
||||
|
||||
+3
-3
@@ -248,7 +248,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
@@ -488,9 +488,9 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@ Keep a bundled provider in `plugins/<id>/` and register it through
|
||||
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
|
||||
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
|
||||
|
||||
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
|
||||
The plugin owns the CLI transport, session state, catalog, and capabilities. Launch CLI transports
|
||||
and probes through the [SDK process helpers](../public-docs/plugins/providers.md#launch-the-provider-cli),
|
||||
which share Windows launcher handling with core providers. The daemon owns
|
||||
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
|
||||
connecting. Register the provider's icon with the plugin rather than adding it to the app's
|
||||
provider icon map. You do not need a core manifest entry or provider factory.
|
||||
|
||||
@@ -157,6 +157,9 @@ buildNpmPackage {
|
||||
cp -a "$path" "$out/share/paseo-desktop/$path"
|
||||
done < desktop-files.txt
|
||||
|
||||
# Shell hooks invoke the retained CLI bin directly, without a system Node.
|
||||
patchShebangs --build "$out/share/paseo-desktop"
|
||||
|
||||
# Keep the same unpackaged monorepo layout expected by main.js.
|
||||
cp package.json $out/share/paseo-desktop/
|
||||
mkdir -p $out/share/paseo-desktop/packages/app
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
|
||||
@@ -120,6 +120,9 @@ buildNpmPackage rec {
|
||||
cp -a "$path" "$out/lib/paseo/$path"
|
||||
done < daemon-files.txt
|
||||
|
||||
# Shell hooks invoke the retained CLI bin directly, without a system Node.
|
||||
patchShebangs --build "$out/lib/paseo"
|
||||
|
||||
# Root package.json lets node resolve the workspace layout when the
|
||||
# CLI/server bin starts from $out.
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.4",
|
||||
"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-beta.4",
|
||||
"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-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/server": "0.11.0-beta.4",
|
||||
"@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-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"@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-beta.4",
|
||||
"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-beta.4",
|
||||
"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-beta.4",
|
||||
"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-beta.4",
|
||||
"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-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@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-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@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-beta.4",
|
||||
"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-beta.4",
|
||||
"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-beta.4",
|
||||
"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-beta.4",
|
||||
"@getpaseo/highlight": "0.11.0-beta.4",
|
||||
"@getpaseo/plugin": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"@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-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41753,7 +41753,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.4",
|
||||
"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-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -120,6 +120,54 @@ async function selectAssistantElement(page: Page, selector: string): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an element from the end of the text before it. A drag that starts in the gap
|
||||
* above a block anchors there, so nothing outside the element is highlighted.
|
||||
*/
|
||||
async function selectAssistantElementFromEndOf(
|
||||
page: Page,
|
||||
startText: string,
|
||||
selector: string,
|
||||
): Promise<void> {
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selected) => {
|
||||
let startNode: Text | null = null;
|
||||
for (const block of blocks) {
|
||||
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent?.endsWith(selected.startText)) {
|
||||
startNode = walker.currentNode as Text;
|
||||
}
|
||||
}
|
||||
}
|
||||
const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean);
|
||||
if (!startNode || !end) {
|
||||
throw new Error(`Could not find ${selected.startText} — ${selected.selector}`);
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startNode.length);
|
||||
range.setEnd(end, end.childNodes.length);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
},
|
||||
{ startText, selector },
|
||||
);
|
||||
}
|
||||
|
||||
/** A drag over an image alone selects it inside its rendered frame. */
|
||||
async function selectAssistantImage(page: Page): Promise<void> {
|
||||
await assistantMessageBlocks(page)
|
||||
.locator("img")
|
||||
.evaluate((element) => {
|
||||
const range = document.createRange();
|
||||
range.selectNode(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -565,6 +613,19 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
const completeCodeClipboard = await readRichClipboard(page);
|
||||
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
|
||||
|
||||
// Touching the block before it without selecting any of its text is still a
|
||||
// selection inside the code.
|
||||
await selectAssistantElementFromEndOf(
|
||||
page,
|
||||
"not a generated link.",
|
||||
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'const answer = "yes";\n return answer;',
|
||||
);
|
||||
|
||||
// Crossing the block boundary expresses an intent to carry its Markdown structure.
|
||||
await selectAssistantAcrossCodeLines(page, "const", "After code.");
|
||||
await copySelection(page);
|
||||
@@ -601,3 +662,50 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const image =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-copy-image-",
|
||||
title: "Assistant selection copy image",
|
||||
initialPrompt: "Render the image fixture.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"Before the image.",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"After the image.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await allowRichClipboard(context);
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
|
||||
|
||||
await selectAssistantTextRange(page, "Before the image.", "After the image.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
`Before the image.\n\n\n\nAfter the image.`,
|
||||
);
|
||||
|
||||
await selectAssistantImage(page);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,10 @@ test.use({
|
||||
},
|
||||
});
|
||||
|
||||
// Rendered light-theme colors: palette yellow 400 for Fast and Ultrafast, muted foreground for Normal.
|
||||
const ACTIVE_SPEED_COLOR = "rgb(251, 191, 36)";
|
||||
const NORMAL_SPEED_COLOR = "rgb(113, 113, 122)";
|
||||
|
||||
async function openSpeedSelector(page: Page): Promise<void> {
|
||||
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
|
||||
const features = page.getByRole("button", { name: "Open agent features", exact: true });
|
||||
@@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise<void> {
|
||||
else await expect(sheetTrigger).toContainText(label);
|
||||
}
|
||||
|
||||
async function expectSpeedIconColor(page: Page, color: string): Promise<void> {
|
||||
const trigger = page
|
||||
.getByRole("button", { name: /^(Speed: .+|Select speed)$/ })
|
||||
.filter({ visible: true });
|
||||
await expect(trigger.locator("svg").first()).toHaveCSS("stroke", color);
|
||||
}
|
||||
|
||||
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
|
||||
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
|
||||
const option = page.getByText(choice, { exact: true }).last();
|
||||
@@ -100,15 +111,21 @@ for (const viewport of [
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
|
||||
}
|
||||
await openSpeedSelector(page);
|
||||
await selectSpeed(page, "Fast");
|
||||
await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-fast-selected.png`) });
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
|
||||
await selectSpeed(page, "Ultrafast");
|
||||
await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR);
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
|
||||
});
|
||||
await selectSpeed(page, "Normal");
|
||||
await expectSpeedIconColor(page, NORMAL_SPEED_COLOR);
|
||||
if (viewport.name === "desktop") {
|
||||
await switchAgentModel(agent.id, "gpt-6-sol");
|
||||
await openSpeedSelector(page);
|
||||
|
||||
@@ -273,7 +273,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const card = contextWindowDetails(page);
|
||||
await refreshUsageCard(card, "Claude");
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("after a resume under another login, only that login shows", async () => {
|
||||
@@ -321,7 +321,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
|
||||
// One request per open that had usage; this one sends none.
|
||||
expect(usage.agentRequests()).toHaveLength(5);
|
||||
expect(usage.agentRequests()).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,7 +351,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const sheet = contextWindowDetails(page);
|
||||
await refreshUsageCard(sheet, "Claude");
|
||||
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
@@ -365,7 +365,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
|
||||
expect(usage.agentRequests()).toHaveLength(2);
|
||||
expect(usage.agentRequests()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
focusWorkspaceRowWithKeyboard,
|
||||
movePointerAwayFromWorkspaceHoverCard,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
@@ -404,6 +409,47 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
|
||||
@@ -322,14 +322,14 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-expired-login");
|
||||
await fixture.setReport(loginWindows);
|
||||
await refreshLoginUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
||||
@@ -474,3 +474,64 @@ async function captureSettingsState(
|
||||
await qaScreenshot(page, name);
|
||||
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
|
||||
}
|
||||
|
||||
test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installLoginUsage([
|
||||
{ harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } },
|
||||
{
|
||||
harness: "OpenCode",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "rejected", status: 401, refreshedBy: "opencode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
harness: "Pi",
|
||||
report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } },
|
||||
},
|
||||
{
|
||||
harness: "OMP",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "rejected", status: 403, refreshedBy: "omp" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await expectLoginErrors(page, [
|
||||
"Codex: Usage API returned 500. Try again.",
|
||||
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
|
||||
"Pi: No active plan",
|
||||
"OMP: Login rejected (HTTP 403). Run omp to refresh it.",
|
||||
]);
|
||||
await fixture.setReport([
|
||||
{
|
||||
harness: "Codex",
|
||||
report: { status: "error", error: "Usage API returned 500. Try again." },
|
||||
},
|
||||
{
|
||||
harness: "OpenCode",
|
||||
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
|
||||
},
|
||||
]);
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectLoginErrors(page: Page, lines: string[]) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n"));
|
||||
}
|
||||
|
||||
async function expectLoginUsageOnly(page: Page, percentage: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -134,8 +134,8 @@ export interface AgentUsageScript {
|
||||
stopReportingUsage(): void;
|
||||
/** The agent of each agent usage request, in order. */
|
||||
agentRequests(): string[];
|
||||
/** Each report Refresh, by report ID. */
|
||||
refreshedReports(): string[][];
|
||||
/** Each scoped agent Refresh, by agent ID. */
|
||||
refreshedAgents(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
supported = false;
|
||||
},
|
||||
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
|
||||
refreshedReports: () =>
|
||||
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
|
||||
refreshedAgents: () =>
|
||||
usage
|
||||
.listRequests()
|
||||
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise<void> {
|
||||
// Two moves, so the pointer always moves even when it is already parked here.
|
||||
await page.mouse.move(1200, 200, { steps: 5 });
|
||||
await page.mouse.move(1200, 400, { steps: 5 });
|
||||
}
|
||||
|
||||
export async function focusWorkspaceRowWithKeyboard(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await row.focus();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(row).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||
// Longer than the hover card's close grace period.
|
||||
await page.waitForTimeout(300);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||
|
||||
@@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
/** A real store-backed plugin on the worker's isolated daemon. */
|
||||
export async function installLoginUsage(initialReport: UsageReport) {
|
||||
type LoginReports = Array<{ harness: string; report: UsageReport }>;
|
||||
|
||||
export async function installLoginUsage(initialReport: UsageReport | LoginReports) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const store = path.join(directory, "report.json");
|
||||
const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report));
|
||||
const setReport = (report: UsageReport | LoginReports) =>
|
||||
writeFile(store, JSON.stringify(report));
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("login-journey");
|
||||
@@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) {
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {z} from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
|
||||
discover: async () => [{key: "account", input: {}}],
|
||||
fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")),
|
||||
const read = async () => {
|
||||
const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8"));
|
||||
return Array.isArray(value) ? value : [{harness: "Claude", report: value}];
|
||||
};
|
||||
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}),
|
||||
discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})),
|
||||
fetch: async ({index}) => (await read())[index].report,
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -70,6 +70,15 @@
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
/* Electron does not clip app-region rectangles to scroll viewports.
|
||||
Content-only viewports emit no window regions, including from their
|
||||
controls. Visible controls here use normal DOM hit testing.
|
||||
Use initial: Blink parses the explicit value none as no-drag. */
|
||||
[data-window-content],
|
||||
[data-window-content] * {
|
||||
-webkit-app-region: initial !important;
|
||||
}
|
||||
|
||||
/* Suppress the browser's default focus outline — it appears on mouse
|
||||
clicks and looks out of place against our themed surfaces. Keep a
|
||||
themed ring for keyboard users via :focus-visible. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export const PLAN_MODE_FEATURE_ID = "plan_mode";
|
||||
export const FAST_MODE_FEATURE_ID = "fast_mode";
|
||||
export const SPEED_FEATURE_ID = "service_tier";
|
||||
|
||||
export function isPlanningAgentMode(mode: Pick<AgentMode, "id" | "colorTier">): boolean {
|
||||
return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan");
|
||||
|
||||
@@ -1241,7 +1241,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
!liveAuxiliary;
|
||||
|
||||
return (
|
||||
<div style={viewportStyle}>
|
||||
<div style={viewportStyle} data-window-content>
|
||||
<div
|
||||
ref={handleScrollContainerRef}
|
||||
data-testid="agent-chat-scroll"
|
||||
|
||||
@@ -320,6 +320,32 @@ describe("assistant selection copy ranges", () => {
|
||||
).toBe("6. Second bullet text");
|
||||
});
|
||||
|
||||
it("keeps the next number when a drag crosses a code block nested in an item", () => {
|
||||
const message = mountFixture();
|
||||
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
|
||||
list.outerHTML = [
|
||||
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
||||
"<div><div><span>Install it:</span></div>",
|
||||
'<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="sh">',
|
||||
'<div data-paseo-markdown-tag="code"><span>brew install foo</span></div>',
|
||||
'<button data-paseo-markdown-ignore="true">Copy</button>',
|
||||
"</div></div>",
|
||||
"</div>",
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">2.</div>',
|
||||
"<div><div><span>Done.</span></div></div>",
|
||||
"</div>",
|
||||
"</div>",
|
||||
].join("");
|
||||
|
||||
const content = copyAcross(message, "Install it:", "Done.");
|
||||
|
||||
expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done.");
|
||||
expect(content?.html).toContain("<div>2. Done.</div>");
|
||||
});
|
||||
|
||||
it("retains nested markers when a drag includes the complete outer item", () => {
|
||||
const message = mountFixture();
|
||||
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
||||
@@ -385,6 +411,20 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(content?.html).not.toContain("<pre>");
|
||||
});
|
||||
|
||||
it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
|
||||
const message = mountFixture();
|
||||
const frame = document.createElement("div");
|
||||
frame.setAttribute("data-paseo-markdown-tag", "img");
|
||||
frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
|
||||
frame.setAttribute("data-paseo-markdown-alt", "chart");
|
||||
frame.innerHTML = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
|
||||
message.prepend(frame);
|
||||
|
||||
expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies code without a fence when every character is selected inside the block", () => {
|
||||
const message = mountFixture();
|
||||
const blockCode = fixtureElement(
|
||||
@@ -405,10 +445,19 @@ describe("assistant selection copy ranges", () => {
|
||||
* Turndown, so those cases need a fixture shaped like the real thing.
|
||||
*/
|
||||
function highlightedFixture(language: string | null): string {
|
||||
return [
|
||||
'<div data-testid="assistant-message">',
|
||||
'<div data-paseo-markdown-tag="p"><span>Before the block.</span></div>',
|
||||
highlightedCodeBlock(language),
|
||||
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function highlightedCodeBlock(language: string | null): string {
|
||||
const languageAttribute =
|
||||
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
|
||||
return [
|
||||
'<div data-testid="assistant-message">',
|
||||
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
|
||||
'<span data-paseo-markdown-tag="code">',
|
||||
"<span>const</span><span> answer</span><span> = 1;</span>",
|
||||
@@ -419,8 +468,6 @@ function highlightedFixture(language: string | null): string {
|
||||
"</span>",
|
||||
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
|
||||
"</div>",
|
||||
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
@@ -598,6 +645,65 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// A drag that starts or ends in the gap beside a block anchors the selection at the
|
||||
// edge of the neighbouring block. Nothing outside the code is highlighted or selected.
|
||||
it("copies code without a fence when the selection only touches the end of the block before it", () => {
|
||||
const message = mountHighlighted();
|
||||
const before = tokenText(message, "Before the block.");
|
||||
|
||||
const content = copyBetween(
|
||||
[before, before.length],
|
||||
[tokenText(message, " doThing();"), 14],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
||||
});
|
||||
|
||||
it("copies code without a fence when the selection only touches the start of the block after it", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, "const"), 0],
|
||||
[tokenText(message, "After the block."), 0],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
||||
});
|
||||
|
||||
it("copies code without a fence when the selection only touches the row before the block", () => {
|
||||
const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]);
|
||||
const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!;
|
||||
codeRow.innerHTML = highlightedCodeBlock("typescript");
|
||||
const before = tokenText(transcript, "Run this:");
|
||||
|
||||
const content = copyBetween(
|
||||
[before, before.length],
|
||||
[tokenText(transcript, " doThing();"), 14],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
||||
});
|
||||
|
||||
it("keeps an image selected beside the code in the copy", () => {
|
||||
const message = mountHighlighted();
|
||||
const image = document.createElement("div");
|
||||
image.setAttribute("data-paseo-markdown-tag", "p");
|
||||
// The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown.
|
||||
image.innerHTML =
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
|
||||
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image);
|
||||
const before = tokenText(message, "Before the block.");
|
||||
|
||||
const content = copyBetween(
|
||||
[before, before.length],
|
||||
[tokenText(message, " doThing();"), 14],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the fence when a complete code block is selected across its boundary", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from "@/utils/rich-clipboard";
|
||||
import {
|
||||
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
||||
MARKDOWN_COPY_ALT_ATTRIBUTE,
|
||||
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
MARKDOWN_COPY_SRC_ATTRIBUTE,
|
||||
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
||||
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
||||
TRAILING_CODE_LINE_BREAKS,
|
||||
@@ -53,9 +55,7 @@ turndown.addRule("compactListItem", {
|
||||
if (parent?.nodeName !== "OL") {
|
||||
return `${options.bulletListMarker} ${item}\n`;
|
||||
}
|
||||
const start = Number(parent.getAttribute("start") ?? 1);
|
||||
const index = Array.from(parent.children).indexOf(node);
|
||||
return `${start + index}. ${item}\n`;
|
||||
return `${node.getAttribute("value")}. ${item}\n`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,8 +67,8 @@ export function createAssistantSelectionClipboardContent(
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const parts = selectedMessageParts(range);
|
||||
if (!parts) {
|
||||
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
|
||||
if (!parts?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -103,10 +103,12 @@ export function createAssistantSelectionClipboardContent(
|
||||
*
|
||||
* A selection contained inside code always copies as code, even when it contains every
|
||||
* character. A selection that crosses the code boundary stays on the Markdown path so
|
||||
* a complete block retains its fence.
|
||||
* a complete block retains its fence. Crossing means selecting content outside the code:
|
||||
* a drag that starts or ends in the gap beside a block anchors at the edge of the
|
||||
* neighbouring block, which selects nothing there.
|
||||
*/
|
||||
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
|
||||
const region = closestCodeRegion(range.commonAncestorContainer, message);
|
||||
const region = selectedCodeRegion(range, message);
|
||||
if (!region) {
|
||||
return null;
|
||||
}
|
||||
@@ -130,6 +132,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb
|
||||
});
|
||||
}
|
||||
|
||||
function selectedCodeRegion(range: Range, message: Element): Element | null {
|
||||
const edgeRegions = [range.startContainer, range.endContainer].map((node) =>
|
||||
closestCodeRegion(node, message),
|
||||
);
|
||||
return (
|
||||
edgeRegions.find(
|
||||
// Measure against the whole block, so its own hover Copy button is not outside it.
|
||||
(edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function closestCodeRegion(node: Node, message: Element): Element | null {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
|
||||
@@ -281,7 +295,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return false;
|
||||
}
|
||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
// An image has no part to select, so touching it selects all of it.
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
@@ -384,6 +399,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored =
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectsContent(range: Range): boolean {
|
||||
return hasMarkdownContent(range.cloneContents(), true);
|
||||
}
|
||||
|
||||
function selectsNothingOutside(range: Range, element: Element): boolean {
|
||||
const contents = document.createRange();
|
||||
contents.selectNodeContents(element);
|
||||
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
|
||||
const before = range.cloneRange();
|
||||
before.setEnd(contents.startContainer, contents.startOffset);
|
||||
if (selectsContent(before)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
|
||||
const after = range.cloneRange();
|
||||
after.setStart(contents.endContainer, contents.endOffset);
|
||||
if (selectsContent(after)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
|
||||
if (!includeIgnored) {
|
||||
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
||||
@@ -393,8 +433,9 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
|
||||
if (fragment.textContent) {
|
||||
return true;
|
||||
}
|
||||
const visibleVoidSelector = ["br", "hr"]
|
||||
const visibleVoidSelector = ["br", "hr", "img"]
|
||||
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
||||
.concat("img")
|
||||
.join(",");
|
||||
return Boolean(fragment.querySelector(visibleVoidSelector));
|
||||
}
|
||||
@@ -488,14 +529,19 @@ function restoreMarkdownElements(container: HTMLElement): void {
|
||||
continue;
|
||||
}
|
||||
const semanticElement = document.createElement(tagName);
|
||||
if (tagName !== "br") {
|
||||
if (tagName !== "br" && tagName !== "img") {
|
||||
semanticElement.append(...element.childNodes);
|
||||
}
|
||||
if (tagName === "img") {
|
||||
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
|
||||
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
|
||||
}
|
||||
if (tagName === "ol") {
|
||||
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
|
||||
if (start) {
|
||||
semanticElement.setAttribute("start", start);
|
||||
}
|
||||
numberOrderedListItems(semanticElement, Number(start ?? 1));
|
||||
}
|
||||
if (tagName === "pre") {
|
||||
const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE);
|
||||
@@ -528,6 +574,20 @@ function restoreMarkdownElements(container: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each item carries its number before Turndown re-parses the HTML. Until then every
|
||||
* child of the list is one item, including a partly selected item demoted to a `p`.
|
||||
* The parser splits a `p` that holds a code block into several siblings, so counting
|
||||
* siblings afterwards numbers the next item too high.
|
||||
*/
|
||||
function numberOrderedListItems(list: Element, start: number): void {
|
||||
Array.from(list.children).forEach((child, index) => {
|
||||
if (child.tagName === "LI") {
|
||||
child.setAttribute("value", String(start + index));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unwrapIncompleteTables(container: HTMLElement): void {
|
||||
for (const table of container.querySelectorAll("table")) {
|
||||
if (hasUsableTableColumnContract(table)) {
|
||||
|
||||
@@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
|
||||
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
|
||||
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
||||
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
|
||||
export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src";
|
||||
export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
|
||||
|
||||
/**
|
||||
* Trailing line breaks, with any indentation that followed the last one.
|
||||
@@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* An image copies as the Markdown it came from. The rendered `img` points at a preview
|
||||
* URL for data and workspace images, and carries no alt text of its own.
|
||||
*/
|
||||
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
|
||||
return {
|
||||
paseoMarkdownTag: "img",
|
||||
paseoMarkdownSrc: source,
|
||||
...(alt ? { paseoMarkdownAlt: alt } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
|
||||
const alignment =
|
||||
typeof style === "string"
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Terminal } from "lucide-react-native";
|
||||
import { Eye, EyeOff, Terminal } from "lucide-react-native";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import {
|
||||
CONTROL_HEIGHTS,
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
@@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
return {
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
passwordRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
width: "100%",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
passwordInputWrap: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
// Boxed adornment next to the field: same height and radius as the field
|
||||
// chrome of the active form size, and the shared interaction phases.
|
||||
iconButtonSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
width: CONTROL_HEIGHTS.compact,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field,
|
||||
width: CONTROL_HEIGHTS.field,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
controlRest: {
|
||||
...geometry.controlRest,
|
||||
},
|
||||
controlHover: {
|
||||
...geometry.controlHover,
|
||||
},
|
||||
controlActive: {
|
||||
...geometry.controlActive,
|
||||
},
|
||||
controlDisabled: {
|
||||
...geometry.controlDisabled,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export interface AddRemoteSshHostModalProps {
|
||||
visible: boolean;
|
||||
@@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
||||
const targetRef = useRef("");
|
||||
const passwordRef = useRef("");
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const passwordInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
targetRef.current = "";
|
||||
passwordRef.current = "";
|
||||
inputRef.current?.replaceText("");
|
||||
passwordInputRef.current?.replaceText("");
|
||||
setIsPasswordVisible(false);
|
||||
setErrorMessage("");
|
||||
}, []);
|
||||
|
||||
@@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
result = await probeAndUpsertRemoteSshConnection(target);
|
||||
result = await probeAndUpsertRemoteSshConnection({
|
||||
...target,
|
||||
password: passwordRef.current === "" ? undefined : passwordRef.current,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof DaemonConnectionTestError
|
||||
@@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({
|
||||
const handleTargetChange = useCallback((value: string) => {
|
||||
targetRef.current = value;
|
||||
}, []);
|
||||
const handlePasswordChange = useCallback((value: string) => {
|
||||
passwordRef.current = value;
|
||||
}, []);
|
||||
const handleTogglePasswordVisibility = useCallback(() => {
|
||||
setIsPasswordVisible((currentlyVisible) => !currentlyVisible);
|
||||
}, []);
|
||||
const iconButtonStyle = useCallback(
|
||||
(
|
||||
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
|
||||
): StyleProp<ViewStyle> => [
|
||||
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: styles.controlRest,
|
||||
controlHover: styles.controlHover,
|
||||
controlActive: styles.controlActive,
|
||||
controlDisabled: styles.controlDisabled,
|
||||
},
|
||||
{
|
||||
hovered: state.hovered,
|
||||
pressed: state.pressed,
|
||||
focused: state.focused,
|
||||
disabled: isSaving,
|
||||
},
|
||||
),
|
||||
],
|
||||
[isCompact, isSaving],
|
||||
);
|
||||
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
||||
|
||||
return (
|
||||
@@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
|
||||
<View style={styles.passwordRow}>
|
||||
<View style={styles.passwordInputWrap}>
|
||||
<FormTextInput
|
||||
ref={passwordInputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-password-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
|
||||
initialValue=""
|
||||
onChangeText={handlePasswordChange}
|
||||
placeholder={t("pairing.remoteSsh.fields.optional")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={iconButtonStyle}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isPasswordVisible
|
||||
? t("pairing.remoteSsh.passwordVisibility.hide")
|
||||
: t("pairing.remoteSsh.passwordVisibility.show")
|
||||
}
|
||||
testID="remote-ssh-password-visibility-toggle"
|
||||
>
|
||||
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
|
||||
@@ -108,6 +108,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
markdownCopyDataSet,
|
||||
markdownCopyImageDataSet,
|
||||
markdownCopyOrderedListDataSet,
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
|
||||
],
|
||||
[containerStyle, imageSizeStyle],
|
||||
);
|
||||
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={frameStyle} dataSet={copyDataSet}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -98,14 +98,21 @@ function WebHoverCard({
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves
|
||||
// behind on the trigger or a button in the card does not outlast the pointer leaving.
|
||||
const keyboardFocusInside = useCallback(() => {
|
||||
return focusInside() && document.activeElement?.matches(":focus-visible") === true;
|
||||
}, [focusInside]);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
if (focusInside()) return;
|
||||
if (keyboardFocusInside()) return;
|
||||
if (graceTimerRef.current) return;
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
graceTimerRef.current = null;
|
||||
if (keyboardFocusInside()) return;
|
||||
setOpen(false);
|
||||
}, CLOSE_GRACE_MS);
|
||||
}, [focusInside]);
|
||||
}, [keyboardFocusInside]);
|
||||
|
||||
const openNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
|
||||
@@ -55,6 +55,7 @@ import type {
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
isFeatureActive,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHintKey,
|
||||
resolveAgentModelSelection,
|
||||
@@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
|
||||
}
|
||||
|
||||
function getFeatureIconColor(
|
||||
featureId: string,
|
||||
enabled: boolean,
|
||||
feature: AgentFeature,
|
||||
palette: {
|
||||
blue: { 400: string };
|
||||
green: { 400: string };
|
||||
@@ -220,11 +220,11 @@ function getFeatureIconColor(
|
||||
},
|
||||
foregroundMuted: string,
|
||||
): string {
|
||||
if (!enabled) {
|
||||
if (!isFeatureActive(feature)) {
|
||||
return foregroundMuted;
|
||||
}
|
||||
|
||||
switch (getFeatureHighlightColor(featureId)) {
|
||||
switch (getFeatureHighlightColor(feature.id)) {
|
||||
case "blue":
|
||||
return palette.blue[400];
|
||||
case "green":
|
||||
@@ -1335,8 +1335,7 @@ function DesktopFeatureItem({
|
||||
<AgentControlTrigger
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
@@ -1370,6 +1369,11 @@ function DesktopFeatureItem({
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
@@ -1453,8 +1457,7 @@ function SheetFeatureItem({
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
@@ -1490,6 +1493,11 @@ function SheetFeatureItem({
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="sheet"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHintKey,
|
||||
isFeatureActive,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from "./utils";
|
||||
@@ -35,9 +36,32 @@ describe("feature metadata helpers", () => {
|
||||
|
||||
it("maps feature highlight colors by feature id", () => {
|
||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
|
||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
||||
});
|
||||
|
||||
it("treats a select feature as active when a non-default option is selected", () => {
|
||||
const speed = {
|
||||
type: "select" as const,
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "fast", label: "Fast" },
|
||||
],
|
||||
};
|
||||
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
|
||||
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
|
||||
expect(
|
||||
isFeatureActive({
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
value: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeModelId", () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy";
|
||||
import {
|
||||
FAST_MODE_FEATURE_ID,
|
||||
PLAN_MODE_FEATURE_ID,
|
||||
SPEED_FEATURE_ID,
|
||||
} from "@/agent-controls/policy";
|
||||
|
||||
export type ExplainedAgentControl = "mode" | "model" | "thinking";
|
||||
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
|
||||
@@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip
|
||||
return feature.tooltip ?? feature.label;
|
||||
}
|
||||
|
||||
export function isFeatureActive(feature: AgentFeature): boolean {
|
||||
if (feature.type === "toggle") return feature.value;
|
||||
const selectedOption = feature.options.find((option) => option.id === feature.value);
|
||||
return selectedOption !== undefined && !selectedOption.isDefault;
|
||||
}
|
||||
|
||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||
switch (featureId) {
|
||||
case FAST_MODE_FEATURE_ID:
|
||||
case SPEED_FEATURE_ID:
|
||||
return "yellow";
|
||||
case "auto_accept":
|
||||
return "green";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -15,7 +15,11 @@ import Animated, {
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
@@ -34,11 +38,12 @@ function ComposerViewport({
|
||||
}: ComposerViewportProps) {
|
||||
const measuredHeight = useSharedValue(0);
|
||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: measuredHeight.value,
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
centered,
|
||||
}),
|
||||
@@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */
|
||||
function useCenteredClearance(safeAreaBottom: number) {
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [formBottom, setFormBottom] = useState(0);
|
||||
const measureViewport = useCallback((event: LayoutChangeEvent) => {
|
||||
setViewportHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const measureForm = useCallback((event: LayoutChangeEvent) => {
|
||||
const { y, height } = event.nativeEvent.layout;
|
||||
setFormBottom(y + height);
|
||||
}, []);
|
||||
return {
|
||||
value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }),
|
||||
measureViewport,
|
||||
measureForm,
|
||||
};
|
||||
}
|
||||
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
@@ -91,6 +114,7 @@ export function ComposerDock({
|
||||
}: ComposerDockProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
if (centered) {
|
||||
@@ -99,8 +123,13 @@ export function ComposerDock({
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
|
||||
<KeyboardTranslateView
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
} from "./capacity";
|
||||
|
||||
describe("composer viewport", () => {
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, centered: false };
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
@@ -15,6 +19,7 @@ describe("composer viewport", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
|
||||
const height = resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
centered: false,
|
||||
});
|
||||
@@ -46,23 +54,80 @@ describe("composer viewport", () => {
|
||||
expect(582 - 24 - 308 - height).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps a centered tablet form below the header after translation", () => {
|
||||
it("keeps a centered tablet form below the header once the keyboard reaches it", () => {
|
||||
const viewport = 1000;
|
||||
const bottomInset = 80;
|
||||
const keyboardShift = 300;
|
||||
const height = resolveComposerCapacity({
|
||||
height: 1000,
|
||||
bottomInset: 80,
|
||||
keyboardShift: 300,
|
||||
height: viewport,
|
||||
bottomInset,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift,
|
||||
centered: true,
|
||||
});
|
||||
expect(height).toBe(310);
|
||||
expect((1000 - 80 - height) / 2 - 300).toBe(5);
|
||||
expect(height).toBe(695);
|
||||
const restingTop = (viewport - bottomInset - height) / 2;
|
||||
const bottomClearance = resolveCenteredClearance({
|
||||
viewportHeight: viewport,
|
||||
safeAreaBottom: 0,
|
||||
formBottom: restingTop + height,
|
||||
});
|
||||
expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5);
|
||||
});
|
||||
|
||||
it("leaves a centered form room to edit on a short foldable viewport", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({
|
||||
height: 725,
|
||||
bottomInset: 72,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 313,
|
||||
centered: true,
|
||||
}),
|
||||
).toBe(407);
|
||||
});
|
||||
|
||||
it("rests a centered form on the keyboard above the bottom safe area", () => {
|
||||
// The keyboard shift excludes the safe area that the keyboard also covers.
|
||||
const geometry = {
|
||||
height: 660,
|
||||
bottomInset: 60,
|
||||
safeAreaBottom: 32,
|
||||
keyboardShift: 288,
|
||||
centered: true,
|
||||
};
|
||||
const height = resolveComposerCapacity(geometry);
|
||||
const restingTop = (geometry.height - geometry.bottomInset - height) / 2;
|
||||
const formBottom = restingTop + height;
|
||||
const clearance = resolveCenteredClearance({
|
||||
viewportHeight: geometry.height,
|
||||
safeAreaBottom: geometry.safeAreaBottom,
|
||||
formBottom,
|
||||
});
|
||||
const translation = Math.max(0, geometry.keyboardShift - clearance);
|
||||
const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift;
|
||||
expect(formBottom - translation).toBe(keyboardTop);
|
||||
expect(restingTop - translation).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the measured viewport before the first keyboard opening", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(553);
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 300,
|
||||
bottomInset: 0,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 200,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
interface ComposerGeometry {
|
||||
height: number;
|
||||
bottomInset: number;
|
||||
/** The keyboard also covers this space below its shift. */
|
||||
safeAreaBottom: number;
|
||||
keyboardShift: number;
|
||||
centered: boolean;
|
||||
}
|
||||
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
|
||||
|
||||
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
"worklet";
|
||||
// A centered form grows upward by half its height. Reserve both halves so
|
||||
// translating it still leaves five layout points below the header.
|
||||
const clearance = input.keyboardShift + 5;
|
||||
const reservedSpace = input.centered ? clearance * 2 : clearance;
|
||||
return Math.max(0, input.height - input.bottomInset - reservedSpace);
|
||||
// A centered form only translates by the keyboard overlap, so once the keyboard
|
||||
// reaches it the form sits on the keyboard and fits above it, not twice over.
|
||||
// A bottom-anchored composer pads the safe area inside its own capacity.
|
||||
const available = input.centered
|
||||
? Math.min(
|
||||
input.height - input.bottomInset,
|
||||
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||
)
|
||||
: input.height - input.bottomInset - input.keyboardShift;
|
||||
return Math.max(0, available - 5);
|
||||
}
|
||||
|
||||
/** Space below a centered form that the keyboard fills before the form moves. */
|
||||
export function resolveCenteredClearance(input: {
|
||||
viewportHeight: number;
|
||||
safeAreaBottom: number;
|
||||
formBottom: number;
|
||||
}): number {
|
||||
return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom);
|
||||
}
|
||||
|
||||
export function updateComposerCapacity(
|
||||
|
||||
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
|
||||
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
||||
fields: {
|
||||
target: "مضيف SSH",
|
||||
password: "كلمة مرور الدايمون",
|
||||
optional: "خياري",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "إظهار كلمة المرور",
|
||||
hide: "إخفاء كلمة المرور",
|
||||
},
|
||||
actions: {
|
||||
cancel: "إلغاء",
|
||||
|
||||
@@ -1727,6 +1727,12 @@ export const en = {
|
||||
helper: "Connect to a Paseo daemon running on the remote host.",
|
||||
fields: {
|
||||
target: "SSH host",
|
||||
password: "Daemon password",
|
||||
optional: "Optional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Show password",
|
||||
hide: "Hide password",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancel",
|
||||
|
||||
@@ -1749,6 +1749,12 @@ export const es: TranslationResources = {
|
||||
helper: "Conéctate a un daemon de Paseo en el host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
password: "Contraseña del daemon",
|
||||
optional: "Opcional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar contraseña",
|
||||
hide: "Ocultar contraseña",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
|
||||
@@ -1753,6 +1753,12 @@ export const fr: TranslationResources = {
|
||||
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
|
||||
fields: {
|
||||
target: "Hôte SSH",
|
||||
password: "Mot de passe du démon",
|
||||
optional: "Facultatif",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Afficher le mot de passe",
|
||||
hide: "Masquer le mot de passe",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Annuler",
|
||||
|
||||
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
|
||||
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
||||
fields: {
|
||||
target: "SSH ホスト",
|
||||
password: "デーモンのパスワード",
|
||||
optional: "任意",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "パスワードを表示",
|
||||
hide: "パスワードを非表示",
|
||||
},
|
||||
actions: {
|
||||
cancel: "キャンセル",
|
||||
|
||||
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
|
||||
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
||||
fields: {
|
||||
target: "SSH 호스트",
|
||||
password: "데몬 비밀번호",
|
||||
optional: "선택 사항",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "비밀번호 표시",
|
||||
hide: "비밀번호 숨기기",
|
||||
},
|
||||
actions: {
|
||||
cancel: "취소",
|
||||
|
||||
@@ -1734,6 +1734,12 @@ export const ptBR: TranslationResources = {
|
||||
helper: "Conecte-se a um daemon Paseo no host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
password: "Senha do daemon",
|
||||
optional: "Opcional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar senha",
|
||||
hide: "Ocultar senha",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
|
||||
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
|
||||
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
||||
fields: {
|
||||
target: "Хост SSH",
|
||||
password: "Пароль демона",
|
||||
optional: "Необязательно",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Показать пароль",
|
||||
hide: "Скрыть пароль",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Отмена",
|
||||
|
||||
@@ -1683,6 +1683,12 @@ export const zhCN: TranslationResources = {
|
||||
helper: "连接到远程主机上运行的 Paseo 守护进程。",
|
||||
fields: {
|
||||
target: "SSH 主机",
|
||||
password: "守护进程密码",
|
||||
optional: "可选",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "显示密码",
|
||||
hide: "隐藏密码",
|
||||
},
|
||||
actions: {
|
||||
cancel: "取消",
|
||||
|
||||
@@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: enabled ? -shift.value : 0 }],
|
||||
transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }],
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, progress } = useKeyboardAnimation();
|
||||
const translateY = useMemo(
|
||||
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
[height, insets.bottom, progress],
|
||||
() =>
|
||||
Animated.add(
|
||||
Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
bottomClearance,
|
||||
).interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: [-1, 0],
|
||||
extrapolateLeft: "extend",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
[bottomClearance, height, insets.bottom, progress],
|
||||
);
|
||||
const keyboardStyle = useMemo(
|
||||
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
|
||||
|
||||
@@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled: _enabled,
|
||||
bottomClearance: _bottomClearance,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
|
||||
@@ -3919,6 +3919,53 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => {
|
||||
const store = createPairingStore({
|
||||
password: " s3cret ",
|
||||
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||
});
|
||||
answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
await store.probeAndUpsertRemoteSshConnection({
|
||||
host: "deploy@example.com",
|
||||
password: " s3cret ",
|
||||
});
|
||||
|
||||
const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh");
|
||||
expect(host?.password).toBe(" s3cret ");
|
||||
expect(host?.connections[0]).toMatchObject({
|
||||
type: "remoteSsh",
|
||||
host: "deploy@example.com",
|
||||
});
|
||||
|
||||
// The fix promises the saved password is sent on every connection: a later
|
||||
// cycle must authenticate from the stored profile alone.
|
||||
await store.runProbeCycleNow("srv_ssh");
|
||||
await waitForHostOnline(store, "srv_ssh");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => {
|
||||
const store = createPairingStore({
|
||||
password: "s3cret",
|
||||
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||
});
|
||||
answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
const failure = await store
|
||||
.probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" })
|
||||
.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(DaemonAuthenticationError);
|
||||
expect((failure as DaemonAuthenticationError).reason).toBe("password_required");
|
||||
expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
@@ -1916,10 +1916,14 @@ export class HostRuntimeStore {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
password?: string;
|
||||
label?: string;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
return this.probeAndUpsertConnection({
|
||||
label: input.label,
|
||||
// The daemon hashes and compares the password verbatim (`paseo daemon
|
||||
// set-password` keeps what it reads), so whitespace is significant here.
|
||||
password: input.password ? input.password : undefined,
|
||||
connection: createRemoteSshHostConnection(input),
|
||||
});
|
||||
}
|
||||
@@ -2844,6 +2848,7 @@ export interface HostMutations {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
password?: string;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
|
||||
@@ -27,7 +27,12 @@ export function AgentUsage({
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
{view.kind === "ready" ? (
|
||||
<AgentUsageCards serverId={serverId} reports={view.reports} refreshable={refreshable} />
|
||||
<AgentUsageCards
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
reports={view.reports}
|
||||
refreshable={refreshable}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.message}>
|
||||
{view.kind === "loading" ? usageCopy.loading : view.message}
|
||||
@@ -39,10 +44,12 @@ export function AgentUsage({
|
||||
|
||||
function AgentUsageCards({
|
||||
serverId,
|
||||
agentId,
|
||||
reports,
|
||||
refreshable,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
reports: readonly UsageReportEntry[];
|
||||
refreshable: boolean;
|
||||
}) {
|
||||
@@ -51,6 +58,7 @@ function AgentUsageCards({
|
||||
<UsageCard
|
||||
key={entry.id}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
entry={entry}
|
||||
display={display}
|
||||
compact
|
||||
|
||||
@@ -40,11 +40,24 @@ function reportContent(report: UsageReport) {
|
||||
return { windows: [], balances: [], details: [], message };
|
||||
}
|
||||
|
||||
function reportMessages(entry: UsageReportEntry): string[] {
|
||||
if (entry.report.status === "available") return [];
|
||||
if (entry.loginErrors)
|
||||
return entry.loginErrors.map(
|
||||
(login) => `${login.harness}: ${reportContent(login.report).message}`,
|
||||
);
|
||||
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
|
||||
return [
|
||||
entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem),
|
||||
];
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
export function UsageCard({
|
||||
serverId,
|
||||
agentId,
|
||||
entry,
|
||||
display,
|
||||
pinnable,
|
||||
@@ -52,6 +65,7 @@ export function UsageCard({
|
||||
compact = false,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
entry: UsageReportEntry;
|
||||
display: UsageDisplay;
|
||||
/** Whether each window row pins the window to the sidebar. */
|
||||
@@ -61,13 +75,14 @@ export function UsageCard({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
|
||||
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
||||
const showsFreshnessInline = isNative || isCompact || !refreshable;
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const footer = entry.account.label ?? null;
|
||||
const { windows, balances, details, message } = reportContent(usage);
|
||||
const { windows, balances, details } = reportContent(usage);
|
||||
const messages = reportMessages(entry);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -110,9 +125,9 @@ export function UsageCard({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{message ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{message}
|
||||
{messages.length > 0 ? (
|
||||
<Text style={styles.error} testID="usage-login-error">
|
||||
{messages.join("\n")}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -98,10 +98,14 @@ async function getReport(
|
||||
serverId: string,
|
||||
reportId: string,
|
||||
forceRefresh = false,
|
||||
agentId?: string,
|
||||
): Promise<UsageReportEntry | null> {
|
||||
return (
|
||||
(await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh }))
|
||||
.reports[0] ?? null
|
||||
(
|
||||
await requireClient(serverId).listUsageReports(
|
||||
agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh },
|
||||
)
|
||||
).reports.find((report) => report.id === reportId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the source to fetch one report, and only that report. The result replaces the report
|
||||
* in every list of its host that holds it, the host's and each agent's, so every surface showing
|
||||
* it moves together; until then the previous report stays on screen.
|
||||
* Refreshes a host report, or the agent's session reports. The result replaces the card
|
||||
* in the requesting view. Host and agent views can use different logins of the same account.
|
||||
*/
|
||||
export function useReportRefresh(
|
||||
serverId: string,
|
||||
reportId: string,
|
||||
agentId?: string,
|
||||
): { refresh: () => void; refreshState: UsageRefresh } {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => getReport(serverId, reportId, true),
|
||||
mutationFn: () => getReport(serverId, reportId, true, agentId),
|
||||
onSuccess: (report) => {
|
||||
queryClient.setQueriesData<UsageReportEntry[]>(
|
||||
{ queryKey: hostUsageQueryKey(serverId) },
|
||||
queryClient.setQueryData<UsageReportEntry[]>(
|
||||
agentId === undefined
|
||||
? usageReportsQueryKey(serverId)
|
||||
: agentUsageQueryKey(serverId, agentId),
|
||||
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.4",
|
||||
"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-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/server": "0.11.0-beta.4",
|
||||
"@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",
|
||||
|
||||
@@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command {
|
||||
.argument("<id>", "Agent ID (or prefix)");
|
||||
}
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
@@ -157,31 +158,10 @@ export async function runAttachCommand(
|
||||
await unsubscribe.ready;
|
||||
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
|
||||
|
||||
// Handle Ctrl+C to detach gracefully
|
||||
let detached = false;
|
||||
const detach = () => {
|
||||
if (detached) return;
|
||||
detached = true;
|
||||
|
||||
console.log("\n\nDetaching from agent...");
|
||||
unsubscribe();
|
||||
client
|
||||
.close()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", detach);
|
||||
process.on("SIGTERM", detach);
|
||||
|
||||
// Keep the process alive
|
||||
await new Promise(() => {
|
||||
// Wait indefinitely until interrupted
|
||||
});
|
||||
await waitForStop();
|
||||
console.log("\n\nDetaching from agent...");
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
@@ -224,16 +225,7 @@ async function runFollowMode(
|
||||
await unsubscribe.ready;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
// Wait for interrupt
|
||||
await new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
});
|
||||
|
||||
await waitForStop();
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli";
|
||||
import { createCliParseArgv } from "./run";
|
||||
|
||||
const runModuleUrl = new URL("./run.ts", import.meta.url).href;
|
||||
|
||||
// Runs the CLI in a child process whose stdout pipe has no reader, as when the
|
||||
// program that launched it has already closed its end.
|
||||
async function runCliWithClosedStdout(
|
||||
argv: string[],
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
const script = `
|
||||
const { runCli } = await import(${JSON.stringify(runModuleUrl)});
|
||||
process.exitCode = await runCli(${JSON.stringify(argv)});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
process.stderr.write("still running\\n");
|
||||
`;
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
|
||||
cwd: path.dirname(fileURLToPath(import.meta.url)),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.destroy();
|
||||
let stderr = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const code = await new Promise<number | null>((resolve) => child.on("close", resolve));
|
||||
return { code, stderr };
|
||||
}
|
||||
|
||||
describe("runCli", () => {
|
||||
it("lets plugin update own --version while preserving global output options", async () => {
|
||||
const cli = createCli()
|
||||
@@ -72,6 +101,19 @@ describe("runCli", () => {
|
||||
).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]);
|
||||
});
|
||||
|
||||
it("finishes the command quietly when stdout is closed before it writes its output", async () => {
|
||||
const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-"));
|
||||
|
||||
try {
|
||||
const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]);
|
||||
|
||||
expect(result.stderr).toBe("still running\n");
|
||||
expect(result.code).toBe(0);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("classifies existing unknown directories as open-project invocations", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
|
||||
const project = path.join(root, "repository");
|
||||
|
||||
@@ -36,7 +36,21 @@ export function createCliParseArgv(input: {
|
||||
return [...nodeArgv, ...cliArgv];
|
||||
}
|
||||
|
||||
// The program that started the CLI can close its end of stdout before the output
|
||||
// is written, as `paseo ls | head -1` or a launcher that discards output does.
|
||||
// Drop the output nobody reads and let the command finish, instead of surfacing
|
||||
// the failed write as an uncaught error.
|
||||
function ignoreClosedStdout(error: NodeJS.ErrnoException): void {
|
||||
if (error.code !== "EPIPE") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> {
|
||||
if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) {
|
||||
process.stdout.on("error", ignoreClosedStdout);
|
||||
}
|
||||
|
||||
const parseArgv = createCliParseArgv({
|
||||
argv,
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent
|
||||
// attach`) stop on Ctrl+C, a termination signal, or when the program reading
|
||||
// their output closes its end, as `| head` does. A closed reader shows up as
|
||||
// EPIPE on the next write to stdout, so the command stops at its next line of
|
||||
// output.
|
||||
export function waitForStop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
process.off("SIGINT", stop);
|
||||
process.off("SIGTERM", stop);
|
||||
process.stdout.off("error", stopOnClosedStdout);
|
||||
resolve();
|
||||
};
|
||||
const stopOnClosedStdout = (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EPIPE") {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
process.stdout.on("error", stopOnClosedStdout);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Follow commands stop when nobody reads their output
|
||||
*
|
||||
* `paseo agent logs --follow` and `paseo agent attach` stream until they are
|
||||
* stopped. A program that closes its end of their stdout, as `| head` or a
|
||||
* launcher that discards output does, stops them the same way Ctrl+C does.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { connectToDaemon } from "../src/utils/client.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Follow Commands With Closed Stdout Tests ===\n");
|
||||
|
||||
const daemon = await startTestDaemon();
|
||||
const host = `127.0.0.1:${daemon.port}`;
|
||||
|
||||
try {
|
||||
// An agent created without a prompt stays idle, so no provider is called.
|
||||
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.workDir,
|
||||
title: "closed stdout",
|
||||
});
|
||||
await client.close();
|
||||
|
||||
for (const args of [
|
||||
["agent", "logs", agent.id, "--follow"],
|
||||
["agent", "attach", agent.id],
|
||||
]) {
|
||||
const label = `paseo ${args.slice(0, 2).join(" ")}`;
|
||||
console.log(`Test: ${label} exits when its stdout reader is gone`);
|
||||
const result = await runPaseoCli(daemon, [...args, "--host", host], {
|
||||
closedStdout: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.stderr, "", `${label} should exit quietly`);
|
||||
assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`);
|
||||
console.log(`✓ ${label} exits when its stdout reader is gone\n`);
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== All follow commands with closed stdout tests passed ===");
|
||||
@@ -352,6 +352,8 @@ export async function runPaseoCli(
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Close the reading end of the CLI's stdout, as a launcher that discards output does. */
|
||||
closedStdout?: boolean;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
@@ -380,6 +382,10 @@ export async function runPaseoCli(
|
||||
const stdout = createOutputCapture();
|
||||
const stderr = createOutputCapture();
|
||||
|
||||
if (options?.closedStdout) {
|
||||
proc.stdout?.destroy();
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
appendOutputCapture(stdout, data);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.4",
|
||||
"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-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2641,13 +2641,25 @@ async function runBrowserProfileGroup() {
|
||||
async function main() {
|
||||
ensureDirSync(OUT_DIR);
|
||||
if (
|
||||
!["all", "existing", "permanent-parking", "automation", "browser-profile"].includes(
|
||||
HARNESS_GROUP,
|
||||
)
|
||||
![
|
||||
"all",
|
||||
"existing",
|
||||
"permanent-parking",
|
||||
"automation",
|
||||
"browser-profile",
|
||||
"titlebar-drag",
|
||||
].includes(HARNESS_GROUP)
|
||||
) {
|
||||
fail(`unknown harness group ${HARNESS_GROUP}`);
|
||||
}
|
||||
|
||||
if (HARNESS_GROUP === "titlebar-drag") {
|
||||
const { runTitlebarDragGroup } = require("./titlebar-drag.js");
|
||||
await runTitlebarDragGroup(OUT_DIR);
|
||||
pass(`capture harness titlebar-drag complete output=${OUT_DIR}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (HARNESS_GROUP === "browser-profile") {
|
||||
const browserProfileResults = await runBrowserProfileGroup();
|
||||
await fsp.writeFile(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// titlebar-drag-mouse.m — native mouse helper for the titlebar-drag harness.
|
||||
// Posts real CGEvents (kCGHIDEventTap) so a drag moves the actual window; pure
|
||||
// CDP input would never exercise Electron's draggable-region pipeline.
|
||||
// Usage: titlebar-drag-mouse drag x1 y1 x2 y2 | click x y
|
||||
// Every event carries flags 0 so a user-held Cmd/Opt cannot leak into the drag.
|
||||
// Exit 2 when this process lacks Accessibility trust: the harness fails loudly
|
||||
// instead of posting nothing and reading as a pass.
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#import <stdio.h>
|
||||
#import <stdlib.h>
|
||||
#import <string.h>
|
||||
#import <unistd.h>
|
||||
|
||||
static void post(CGEventType type, CGPoint p, useconds_t delay) {
|
||||
CGEventRef event = CGEventCreateMouseEvent(NULL, type, p, kCGMouseButtonLeft);
|
||||
if (!event) {
|
||||
fprintf(stderr, "mouse: CGEventCreateMouseEvent failed\n");
|
||||
exit(3);
|
||||
}
|
||||
CGEventSetFlags(event, (CGEventFlags)0);
|
||||
CGEventPost(kCGHIDEventTap, event);
|
||||
CFRelease(event);
|
||||
usleep(delay);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (!AXIsProcessTrusted()) {
|
||||
fprintf(stderr, "mouse: not accessibility-trusted\n");
|
||||
return 2;
|
||||
}
|
||||
if (argc == 6 && strcmp(argv[1], "drag") == 0) {
|
||||
CGPoint a = {atof(argv[2]), atof(argv[3])};
|
||||
CGPoint b = {atof(argv[4]), atof(argv[5])};
|
||||
post(kCGEventMouseMoved, a, 150000);
|
||||
post(kCGEventLeftMouseDown, a, 150000);
|
||||
for (int i = 1; i <= 20; i++) {
|
||||
CGPoint step = {a.x + (b.x - a.x) * i / 20, a.y + (b.y - a.y) * i / 20};
|
||||
post(kCGEventLeftMouseDragged, step, 25000);
|
||||
}
|
||||
post(kCGEventLeftMouseUp, b, 25000);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "click") == 0) {
|
||||
CGPoint p = {atof(argv[2]), atof(argv[3])};
|
||||
post(kCGEventMouseMoved, p, 150000);
|
||||
post(kCGEventLeftMouseDown, p, 150000);
|
||||
post(kCGEventLeftMouseUp, p, 25000);
|
||||
return 0;
|
||||
}
|
||||
fprintf(stderr, "usage: %s drag x1 y1 x2 y2 | click x y\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// Native mouse input is required: CDP mouse events do not move Electron windows.
|
||||
// Load production CSS so restoring the global no-drag rule reproduces the failure.
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const { BrowserWindow } = require("electron");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PACKAGE_ROOT = path.join(__dirname, "..", "..");
|
||||
const INDEX_HTML = path.join(PACKAGE_ROOT, "app", "public", "index.html");
|
||||
const MOUSE_SOURCE = path.join(__dirname, "titlebar-drag-mouse.m");
|
||||
const BASE = { x: 100, y: 150 };
|
||||
const DRAG_DELTA = { x: 80, y: 50 };
|
||||
const DRAG_POINTS = [
|
||||
{ check: "header-overlay-blank", x: 625, y: 18 },
|
||||
{ check: "tab-row-overlay-blank", x: 625, y: 50 },
|
||||
{ check: "sidebar-overlay-blank", x: 125, y: 18 },
|
||||
{ check: "direct-rail-blank", x: 125, y: 50 },
|
||||
];
|
||||
const CLICK_POINTS = [
|
||||
{ check: "header-button-click", id: "header-btn", x: 955, y: 18 },
|
||||
{ check: "window-owned-toggle-click", id: "window-toggle", x: 40, y: 18 },
|
||||
{ check: "window-owned-focusable-click", id: "focusable", x: 320, y: 18 },
|
||||
{ check: "window-owned-negative-tabindex-button-click", id: "negative-button", x: 400, y: 18 },
|
||||
{ check: "sidebar-tab-click", id: "sidebar-btn", x: 205, y: 18 },
|
||||
{ check: "rail-tab-click", id: "rail-btn", x: 205, y: 50 },
|
||||
{ check: "portal-button-click", id: "portal-btn", x: 490, y: 18 },
|
||||
{ check: "floating-panel-button-click", id: "floating-btn", x: 785, y: 18 },
|
||||
];
|
||||
|
||||
/** Static overlays and a sibling window control mirror the app shell ownership. */
|
||||
function buildFixtureHtml(appStyles) {
|
||||
const dragOverlay =
|
||||
'style="position:absolute;top:0;left:0;display:block;width:100%;height:100%;-webkit-app-region:drag"';
|
||||
const button = (id, style) =>
|
||||
`<button id="${id}" style="${style}" onclick="count('${id}')">${id}</button>`;
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>${appStyles}</style>
|
||||
<style>html,body{margin:0;width:100%;height:100%;background:#161616;color:#ddd;font:12px -apple-system,sans-serif;user-select:none}
|
||||
.pane{position:absolute}</style></head><body>
|
||||
<div class="pane" id="sidebar" style="left:0;top:0;width:250px;height:600px;background:#1d1d1d">
|
||||
<div class="pane" style="left:0;top:0;width:250px;height:36px">
|
||||
<div ${dragOverlay}></div>
|
||||
${button("sidebar-btn", "position:absolute;right:8px;top:6px;width:60px;height:24px")}
|
||||
</div>
|
||||
<div class="pane"
|
||||
style="left:0;top:36px;width:250px;height:28px;-webkit-app-region:drag">
|
||||
${button("rail-btn", "position:absolute;right:8px;top:2px;width:60px;height:24px")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="header" style="left:250px;top:0;width:750px;height:36px">
|
||||
<div ${dragOverlay}></div>
|
||||
${button("header-btn", "position:absolute;right:8px;top:6px;width:60px;height:24px")}
|
||||
</div>
|
||||
<div class="pane" style="left:250px;top:36px;width:750px;height:28px">
|
||||
<div ${dragOverlay}></div>
|
||||
</div>
|
||||
<!-- The sidebar toggle belongs to the window, outside every drag surface subtree. -->
|
||||
${button("window-toggle", "position:absolute;left:10px;top:6px;width:60px;height:24px;z-index:2")}
|
||||
<div id="focusable" tabindex="0" onclick="count('focusable')"
|
||||
style="position:absolute;left:290px;top:6px;width:60px;height:24px;z-index:2">Focusable</div>
|
||||
<div id="negative-button" role="button" tabindex="-1" onclick="count('negative-button')"
|
||||
style="position:absolute;left:370px;top:6px;width:60px;height:24px;z-index:2">Button</div>
|
||||
<div class="pane" id="chat-root" style="left:250px;top:64px;width:750px;height:536px;overflow:clip">
|
||||
<div id="scroller" tabindex="-1" data-window-content
|
||||
style="width:100%;height:100%;overflow-y:auto;overflow-x:clip;outline:none">
|
||||
<div id="chat-inner" style="height:2000px;position:relative">
|
||||
<a id="chat-link" href="#" onclick="event.preventDefault();count('chat-link')" style="position:absolute;left:250px;top:160px;width:180px;height:24px">Chat link</a>
|
||||
${button("chat-code", "position:absolute;left:250px;top:320px;width:180px;height:24px")}
|
||||
<div id="chat-file" role="link" tabindex="0" onclick="count('chat-file')" style="position:absolute;left:250px;top:480px;width:180px;height:24px">file.ts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="overlay-root" style="position:fixed;inset:0;pointer-events:none;z-index:9999">
|
||||
${button("portal-btn", "position:fixed;left:460px;top:6px;width:60px;height:24px;pointer-events:auto")}
|
||||
</div>
|
||||
<div style="position:fixed;inset:0;pointer-events:none;z-index:9999">
|
||||
${button("floating-btn", "position:fixed;left:755px;top:6px;width:60px;height:24px;pointer-events:auto")}
|
||||
</div>
|
||||
<script>
|
||||
window.__clicks = {};
|
||||
window.count = (id) => { window.__clicks[id] = (window.__clicks[id] || 0) + 1; };
|
||||
window.__fixture = {
|
||||
setScroll(value) { document.getElementById("scroller").scrollTop = value; return this.snapshot(); },
|
||||
placeControl(id, y) {
|
||||
const control = document.getElementById(id);
|
||||
const rect = control.getBoundingClientRect();
|
||||
document.getElementById("scroller").scrollTop += rect.top + rect.height / 2 - y;
|
||||
const placed = control.getBoundingClientRect();
|
||||
return { x: placed.left + placed.width / 2, y: placed.top + placed.height / 2 };
|
||||
},
|
||||
snapshot() {
|
||||
const inner = document.getElementById("chat-inner").getBoundingClientRect();
|
||||
const clicks = { ...window.__clicks };
|
||||
window.__clicks = {};
|
||||
return { innerTop: inner.top, innerBottom: inner.bottom, clicks };
|
||||
},
|
||||
};
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
function readRealAppStyles() {
|
||||
const html = fs.readFileSync(INDEX_HTML, "utf8");
|
||||
const styles = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n");
|
||||
if (!styles.trim()) throw new Error(`no <style> blocks found in ${INDEX_HTML}`);
|
||||
return styles;
|
||||
}
|
||||
|
||||
async function compileMouseHelper(outDir) {
|
||||
const binary = path.join(outDir, "titlebar-drag-mouse");
|
||||
await execFileAsync("clang", [
|
||||
"-O2",
|
||||
"-o",
|
||||
binary,
|
||||
MOUSE_SOURCE,
|
||||
"-framework",
|
||||
"ApplicationServices",
|
||||
]);
|
||||
return binary;
|
||||
}
|
||||
|
||||
function samePosition(before, after) {
|
||||
return before.x === after.x && before.y === after.y;
|
||||
}
|
||||
|
||||
async function runTitlebarDragGroup(outDir) {
|
||||
const results = [];
|
||||
const record = (check, pass, detail) => {
|
||||
results.push({ group: "titlebar-drag", check, pass, ...detail });
|
||||
process.stdout.write(`${pass ? "PASS" : "FAIL"} titlebar-drag/${check}\n`);
|
||||
};
|
||||
let win = null;
|
||||
let hardError = null;
|
||||
try {
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error("titlebar-drag: macOS only — native window drag needs CGEventPost");
|
||||
}
|
||||
const mouse = await compileMouseHelper(outDir);
|
||||
const fixturePath = path.join(outDir, "titlebar-drag-fixture.html");
|
||||
await fsp.writeFile(fixturePath, buildFixtureHtml(readRealAppStyles()));
|
||||
|
||||
win = new BrowserWindow({
|
||||
...BASE,
|
||||
width: 1000,
|
||||
height: 600,
|
||||
frame: false,
|
||||
show: false,
|
||||
backgroundColor: "#161616",
|
||||
});
|
||||
const evalPage = (code) => win.webContents.executeJavaScript(code);
|
||||
const bounds = () => {
|
||||
const current = win.getBounds();
|
||||
return { x: current.x, y: current.y };
|
||||
};
|
||||
const resetPosition = async () => {
|
||||
win.setPosition(BASE.x, BASE.y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
};
|
||||
const runMouse = async (args) => {
|
||||
await execFileAsync(mouse, args.map(String));
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
};
|
||||
const mouseAt = (point) => ({ x: BASE.x + point.x, y: BASE.y + point.y });
|
||||
|
||||
await win.loadFile(fixturePath);
|
||||
win.showInactive();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
for (const scrollTop of [0, 1000]) {
|
||||
const snapshot = await evalPage(`window.__fixture.setScroll(${scrollTop})`);
|
||||
const crossesHeader = snapshot.innerTop < 36 && snapshot.innerBottom > 0;
|
||||
record(`scroll-${scrollTop}-content-crosses-header`, crossesHeader === (scrollTop === 1000), {
|
||||
innerTop: snapshot.innerTop,
|
||||
innerBottom: snapshot.innerBottom,
|
||||
});
|
||||
for (const point of DRAG_POINTS) {
|
||||
await resetPosition();
|
||||
const before = bounds();
|
||||
const from = mouseAt(point);
|
||||
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
|
||||
const after = bounds();
|
||||
const moved = after.x - before.x === DRAG_DELTA.x && after.y - before.y === DRAG_DELTA.y;
|
||||
record(`scroll-${scrollTop}-${point.check}`, moved, { before, after });
|
||||
}
|
||||
const chatPoint = { x: 625, y: 300 };
|
||||
await resetPosition();
|
||||
const before = bounds();
|
||||
const from = mouseAt(chatPoint);
|
||||
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
|
||||
const after = bounds();
|
||||
record(`scroll-${scrollTop}-chat-area-does-not-drag`, samePosition(before, after), {
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
|
||||
for (const point of CLICK_POINTS) {
|
||||
await resetPosition();
|
||||
const before = bounds();
|
||||
const from = mouseAt(point);
|
||||
await runMouse(["click", from.x, from.y]);
|
||||
const { clicks } = await evalPage("window.__fixture.snapshot()");
|
||||
const after = bounds();
|
||||
const clicked = clicks[point.id] === 1;
|
||||
const stillStationary = samePosition(before, after);
|
||||
record(point.check, clicked && stillStationary, { clicks, before, after });
|
||||
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
|
||||
const afterDrag = bounds();
|
||||
record(`${point.check}-does-not-drag`, samePosition(before, afterDrag), {
|
||||
before,
|
||||
after: afterDrag,
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of ["chat-link", "chat-code", "chat-file"]) {
|
||||
for (const y of [18, 50]) {
|
||||
await resetPosition();
|
||||
const point = await evalPage(`window.__fixture.placeControl(${JSON.stringify(id)}, ${y})`);
|
||||
const before = bounds();
|
||||
const from = mouseAt(point);
|
||||
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
|
||||
const after = bounds();
|
||||
record(
|
||||
`${id}-behind-header-${y}`,
|
||||
after.x - before.x === DRAG_DELTA.x && after.y - before.y === DRAG_DELTA.y,
|
||||
{ before, after, point },
|
||||
);
|
||||
}
|
||||
await resetPosition();
|
||||
const point = await evalPage(`window.__fixture.placeControl(${JSON.stringify(id)}, 180)`);
|
||||
const before = bounds();
|
||||
const from = mouseAt(point);
|
||||
await evalPage("window.__fixture.snapshot()");
|
||||
await runMouse(["click", from.x, from.y]);
|
||||
const { clicks } = await evalPage("window.__fixture.snapshot()");
|
||||
const after = bounds();
|
||||
record(`${id}-visible-click`, clicks[id] === 1 && samePosition(before, after), {
|
||||
clicks,
|
||||
before,
|
||||
after,
|
||||
point,
|
||||
});
|
||||
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
|
||||
const afterDrag = bounds();
|
||||
record(`${id}-visible-does-not-drag`, samePosition(before, afterDrag), {
|
||||
before,
|
||||
after: afterDrag,
|
||||
});
|
||||
}
|
||||
|
||||
const image = await win.webContents.capturePage();
|
||||
await fsp.writeFile(path.join(outDir, "titlebar-drag.png"), image.toPNG());
|
||||
} catch (error) {
|
||||
hardError = error;
|
||||
record("group-error", false, { reason: String(error?.message ?? error) });
|
||||
} finally {
|
||||
win?.destroy();
|
||||
}
|
||||
|
||||
await fsp.writeFile(
|
||||
path.join(outDir, "titlebar-drag-results.json"),
|
||||
`${JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)}\n`,
|
||||
);
|
||||
const failed = results.filter((entry) => !entry.pass).map((entry) => entry.check);
|
||||
if (hardError || failed.length > 0) {
|
||||
throw new Error(`titlebar-drag group failed: ${failed.join(", ")}`, { cause: hardError });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { runTitlebarDragGroup };
|
||||
@@ -6,6 +6,8 @@ import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port";
|
||||
import { installDaemonWebSocketGate } from "../../app/e2e/support/helpers/daemon-websocket-gate";
|
||||
import { expectAppRoute } from "../../app/e2e/support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../../app/e2e/support/helpers/mock-agent";
|
||||
import { scrollAgentChatToBottom } from "../../app/e2e/support/helpers/agent-bottom-anchor";
|
||||
import { getServerId } from "../../app/e2e/support/helpers/server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs";
|
||||
import {
|
||||
@@ -118,3 +120,131 @@ test("refresh keeps one continuous splash before restoring the desktop workspace
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
function inspectChatDragExclusions(scroll: Element) {
|
||||
const header = document.querySelector('[data-testid="composer-dock-header"]');
|
||||
if (!header) throw new Error("Expected the workspace header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
const headerY = headerRect.top + headerRect.height / 2;
|
||||
const content = scroll.firstElementChild;
|
||||
const focusScope = scroll.closest("[tabindex]");
|
||||
if (!content || !focusScope) throw new Error("Expected chat content and its focus scope");
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const chatX = (contentRect.left + contentRect.right) / 2;
|
||||
const tabRow = [...document.querySelectorAll('[data-testid="workspace-tabs-row"]')].find(
|
||||
(row) => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return rect.left < chatX && rect.right > chatX && rect.height > 0;
|
||||
},
|
||||
);
|
||||
if (!tabRow) throw new Error("Expected the chat pane's tab row");
|
||||
const tabRect = tabRow.getBoundingClientRect();
|
||||
const tabY = tabRect.top + tabRect.height / 2;
|
||||
const exclusions = [...scroll.querySelectorAll("*")].filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
style.visibility === "visible" &&
|
||||
style.getPropertyValue("-webkit-app-region") === "no-drag" &&
|
||||
((rect.top < headerY && rect.bottom > headerY) || (rect.top < tabY && rect.bottom > tabY)) &&
|
||||
rect.right > headerRect.left &&
|
||||
rect.left < headerRect.right
|
||||
);
|
||||
});
|
||||
return {
|
||||
contentCrossesHeader: contentRect.top < headerY && contentRect.bottom > headerY,
|
||||
contentCrossesTabRow: contentRect.top < tabY && contentRect.bottom > tabY,
|
||||
focusScopeRegion: getComputedStyle(focusScope).getPropertyValue("-webkit-app-region"),
|
||||
exclusions: exclusions.length,
|
||||
};
|
||||
}
|
||||
|
||||
test("scrolled chat does not exclude the workspace titlebar from dragging", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const response = Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) =>
|
||||
`Paragraph ${index}: a long conversation keeps the chat scrolled below its first message.\n\n[Drag reference](https://example.com) and [file.ts](./file.ts)\n\n\`\`\`ts\nconst item = ${index};\n\`\`\``,
|
||||
).join("\n\n");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "desktop-scrolled-chat-",
|
||||
title: "Scrollable conversation",
|
||||
initialPrompt: "Show a long response",
|
||||
featureValues: { mockAssistantResponse: response },
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: getServerId(),
|
||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openAgentRoute(page, agent);
|
||||
const chat = page.getByTestId("agent-chat-scroll");
|
||||
await expect(chat).toBeVisible();
|
||||
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await scrollAgentChatToBottom(page);
|
||||
// Electron can subtract no-drag rectangles outside a scroll viewport's clip.
|
||||
// Check the offending geometry, rather than DOM hit testing or a specific row style.
|
||||
await expect
|
||||
.poll(() => chat.evaluate(inspectChatDragExclusions))
|
||||
.toEqual({
|
||||
contentCrossesHeader: true,
|
||||
contentCrossesTabRow: true,
|
||||
focusScopeRegion: "no-drag",
|
||||
exclusions: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Window-owned controls are siblings of workspace chrome, not descendants of it.
|
||||
const closeMenu = page.getByRole("button", { name: "Close menu", exact: true });
|
||||
await expect(closeMenu).toHaveCSS("-webkit-app-region", "no-drag");
|
||||
await closeMenu.click();
|
||||
const openMenu = page.getByRole("button", { name: "Open menu", exact: true });
|
||||
await expect(openMenu).toHaveCSS("-webkit-app-region", "no-drag");
|
||||
await openMenu.click();
|
||||
await expect(closeMenu).toBeVisible();
|
||||
|
||||
const menuTrigger = page.getByTestId("workspace-header-menu-trigger");
|
||||
await expect(menuTrigger).toHaveCSS("-webkit-app-region", "no-drag");
|
||||
await menuTrigger.click();
|
||||
const menu = page.getByTestId("workspace-header-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem").first()).toHaveCSS("-webkit-app-region", "no-drag");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toBeHidden();
|
||||
await page.getByRole("textbox", { name: "Message agent...", exact: true }).click();
|
||||
// installDesktopRuntime supplies a macOS bridge, including shortcut identity.
|
||||
await page.keyboard.press("Meta+f");
|
||||
const find = page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
await expect(find).toBeFocused();
|
||||
await expect(find).toHaveCSS("-webkit-app-region", "no-drag");
|
||||
await find.fill("Paragraph 40:");
|
||||
await expect(page.getByRole("status", { name: "Find matches" })).toHaveText("1 of 1");
|
||||
for (const name of ["Next match", "Previous match", "Close Find"]) {
|
||||
await expect(page.getByRole("button", { name, exact: true })).toHaveCSS(
|
||||
"-webkit-app-region",
|
||||
"no-drag",
|
||||
);
|
||||
}
|
||||
await page.getByRole("button", { name: "Close Find", exact: true }).click();
|
||||
await expect(find).toBeHidden();
|
||||
await expect
|
||||
.poll(() =>
|
||||
chat.evaluate((scroll) => ({
|
||||
focusReturned: document.activeElement?.contains(scroll),
|
||||
tabindex: document.activeElement?.getAttribute("tabindex"),
|
||||
appRegion: getComputedStyle(document.activeElement!).getPropertyValue(
|
||||
"-webkit-app-region",
|
||||
),
|
||||
})),
|
||||
)
|
||||
.toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" });
|
||||
await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") });
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliInstallSourcePath } from "./path";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { resolveCliInstallSourcePath, resolveCliShimPath } from "./path";
|
||||
|
||||
describe("cli-install-path", () => {
|
||||
it("uses the bundled shim for packaged macOS installs", () => {
|
||||
@@ -56,3 +58,38 @@ describe("cli-install-path", () => {
|
||||
).toBe("/opt/Paseo/resources/bin/paseo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI executable selection", () => {
|
||||
const resolveWorkspaceCli = () =>
|
||||
createRequire(import.meta.url).resolve("@getpaseo/cli/bin/paseo");
|
||||
|
||||
it("uses the workspace CLI for an unpackaged Electron launcher", () => {
|
||||
expect(
|
||||
resolveCliShimPath({
|
||||
platform: "linux",
|
||||
isPackaged: false,
|
||||
executablePath: "/nix/store/electron/bin/electron",
|
||||
resolveWorkspaceCli,
|
||||
}),
|
||||
).toBe(resolveWorkspaceCli());
|
||||
});
|
||||
|
||||
it("uses the application shim for a packaged launcher", () => {
|
||||
expect(
|
||||
resolveCliShimPath({
|
||||
platform: "linux",
|
||||
isPackaged: true,
|
||||
executablePath: "/opt/Paseo/paseo",
|
||||
resolveWorkspaceCli,
|
||||
}),
|
||||
).toBe(path.join("/opt/Paseo", "resources", "bin", "paseo"));
|
||||
expect(
|
||||
resolveCliShimPath({
|
||||
platform: "darwin",
|
||||
isPackaged: true,
|
||||
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
resolveWorkspaceCli,
|
||||
}),
|
||||
).toBe(path.join("/Applications/Paseo.app", "Contents", "Resources", "bin", "paseo"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveCliInstallSourcePath(input: {
|
||||
platform: NodeJS.Platform;
|
||||
isPackaged: boolean;
|
||||
@@ -27,3 +29,19 @@ export function resolveCliInstallSourcePath(input: {
|
||||
|
||||
return input.executablePath;
|
||||
}
|
||||
|
||||
export function resolveCliShimPath(input: {
|
||||
platform: NodeJS.Platform;
|
||||
isPackaged: boolean;
|
||||
executablePath: string;
|
||||
resolveWorkspaceCli: () => string;
|
||||
}): string {
|
||||
if (!input.isPackaged) return input.resolveWorkspaceCli();
|
||||
|
||||
const filename = input.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
if (input.platform === "darwin") {
|
||||
const bundle = input.executablePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(bundle, "Contents", "Resources", "bin", filename);
|
||||
}
|
||||
return path.join(path.dirname(input.executablePath), "resources", "bin", filename);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolveCliShimPath } from "./path.js";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
|
||||
@@ -12,20 +14,10 @@ export function getCliTargetPath(): string {
|
||||
}
|
||||
|
||||
export function getBundledCliShimPath(): string {
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
// Linux
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
return resolveCliShimPath({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: app.getPath("exe"),
|
||||
resolveWorkspaceCli: () => createRequire(__filename).resolve("@getpaseo/cli/bin/paseo"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.4",
|
||||
"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-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.4",
|
||||
"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-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@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-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@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"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isBuiltin } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as server from "./server/index.js";
|
||||
|
||||
const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const entries = {
|
||||
@@ -64,6 +65,10 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien
|
||||
}
|
||||
|
||||
describe("plugin SDK import boundaries", () => {
|
||||
it("publishes process capabilities without their Windows implementation helpers", () => {
|
||||
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]);
|
||||
});
|
||||
|
||||
it("classifies every published entry", () => {
|
||||
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawnProcess, terminateProcess } from "../process.js";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import {
|
||||
ClientSideConnection,
|
||||
@@ -400,9 +401,9 @@ class AcpRuntime {
|
||||
args: options.options.command.slice(1),
|
||||
env: process.env,
|
||||
};
|
||||
child = spawn(launch.command, launch.args, {
|
||||
child = spawnProcess(launch.command, launch.args, {
|
||||
env: { ...launch.env, ...options.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
stdio: "pipe",
|
||||
});
|
||||
spawnFailure = new Promise<never>((_resolve, reject) => child!.once("error", reject));
|
||||
child.stderr.on("data", () => undefined);
|
||||
@@ -734,9 +735,9 @@ class AcpRuntime {
|
||||
if (!child || this.processFailed) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const closed = new Promise<void>((resolve) => child.once("close", () => resolve()));
|
||||
child.kill("SIGTERM");
|
||||
await terminateProcess(child, "SIGTERM");
|
||||
if (await settlesWithin(closed, 1_000)) return;
|
||||
child.kill("SIGKILL");
|
||||
await terminateProcess(child);
|
||||
if (!(await settlesWithin(closed, 1_000))) {
|
||||
throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`);
|
||||
}
|
||||
|
||||
@@ -22,3 +22,5 @@ export type {
|
||||
PluginBeforeRequests,
|
||||
PluginLifecycleRegistration,
|
||||
} from "./lifecycle.js";
|
||||
|
||||
export { spawnProcess, execCommand, terminateProcess } from "./process.js";
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js";
|
||||
|
||||
describe("quoteWindowsCommand", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows path with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted path", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("escapes ampersands", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
|
||||
});
|
||||
|
||||
test("escapes pipes", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
|
||||
});
|
||||
|
||||
test("does not double percent signs", () => {
|
||||
setPlatform("win32");
|
||||
// cmd.exe only collapses %% → % inside batch files; on the command line
|
||||
// it stays literal, which corrupts git --format atoms etc.
|
||||
expect(quoteWindowsCommand("100%")).toBe("100%");
|
||||
});
|
||||
|
||||
test("preserves git --format atoms verbatim", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe(
|
||||
"--format=%^(refname^)%09%^(committerdate:unix^)",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes carets", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
|
||||
});
|
||||
|
||||
test("escapes multiple metacharacters", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
|
||||
"build^&^(test^|deploy^)^!^<output^>",
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes commands with spaces after escaping metacharacters", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
|
||||
'"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindowsArgument", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows argument with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted argument", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the argument unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||
});
|
||||
|
||||
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { execCommand, spawnProcess } from "./index.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function writeArgumentReader(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "plugin process arguments "));
|
||||
roots.push(root);
|
||||
const script = path.join(root, "read arguments.mjs");
|
||||
await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n");
|
||||
return script;
|
||||
}
|
||||
|
||||
const argumentsToPreserve = [
|
||||
"argument with spaces",
|
||||
"--format=%(refname)%09%(committerdate:unix)",
|
||||
"100%",
|
||||
"feature&bugfix",
|
||||
"feature|bugfix",
|
||||
"feature^bugfix",
|
||||
];
|
||||
|
||||
// A bare extensionless command selects the Windows shell; an absolute node.exe path would
|
||||
// skip that path. The real child checks quoting through the public process capabilities.
|
||||
test("execCommand delivers shell arguments to the real child unchanged", async () => {
|
||||
const script = await writeArgumentReader();
|
||||
const { stdout } = await execCommand("node", [script, ...argumentsToPreserve]);
|
||||
expect(JSON.parse(stdout)).toEqual(argumentsToPreserve);
|
||||
});
|
||||
|
||||
test("spawnProcess delivers shell arguments to the real child unchanged", async () => {
|
||||
const script = await writeArgumentReader();
|
||||
const child = spawnProcess("node", [script, ...argumentsToPreserve], { stdio: "pipe" });
|
||||
let stdout = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Argument reader exited with ${code}`));
|
||||
});
|
||||
});
|
||||
expect(JSON.parse(stdout)).toEqual(argumentsToPreserve);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
execFile,
|
||||
spawn,
|
||||
type ChildProcess,
|
||||
type ChildProcessWithoutNullStreams,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
import { extname } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
isWindowsCommandScript,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
} from "./process-internal/windows-command.js";
|
||||
const execFileAsync = promisify(execFile);
|
||||
export interface ExecCommandOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
encoding?: BufferEncoding;
|
||||
killSignal?: NodeJS.Signals;
|
||||
timeout?: number;
|
||||
maxBuffer?: number;
|
||||
shell?: boolean | string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
interface ExecCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
function hasPathSeparator(value: string): boolean {
|
||||
return value.includes("/") || value.includes("\\");
|
||||
}
|
||||
|
||||
function shouldUseWindowsShell(
|
||||
command: string,
|
||||
requestedShell?: boolean | string,
|
||||
): boolean | string {
|
||||
if (isWindowsCommandScript(command)) {
|
||||
return true;
|
||||
}
|
||||
if (requestedShell !== undefined) {
|
||||
return requestedShell;
|
||||
}
|
||||
return process.platform === "win32" && !hasPathSeparator(command) && !extname(command);
|
||||
}
|
||||
|
||||
/** Launch a CLI with Windows command-script handling and argv quoting. The caller owns env. */
|
||||
export function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions & { stdio: "pipe" },
|
||||
): ChildProcessWithoutNullStreams;
|
||||
export function spawnProcess(command: string, args: string[], options?: SpawnOptions): ChildProcess;
|
||||
export function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptions,
|
||||
): ChildProcess {
|
||||
const spawnOptions = options ?? {};
|
||||
const isWindows = process.platform === "win32";
|
||||
const shell = shouldUseWindowsShell(command, spawnOptions.shell);
|
||||
|
||||
const shouldQuoteForShell = isWindows && shell !== false;
|
||||
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
|
||||
|
||||
return spawn(resolvedCommand, resolvedArgs, {
|
||||
...spawnOptions,
|
||||
env: options?.env,
|
||||
shell,
|
||||
signal: options?.signal,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function execCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: ExecCommandOptions,
|
||||
): Promise<ExecCommandResult> {
|
||||
const isWindows = process.platform === "win32";
|
||||
const shell = shouldUseWindowsShell(command, options?.shell);
|
||||
const shouldQuoteForShell = isWindows && shell !== false;
|
||||
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
|
||||
|
||||
return execFileAsync(resolvedCommand, resolvedArgs, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
encoding: options?.encoding ?? "utf8",
|
||||
signal: options?.signal,
|
||||
killSignal: options?.killSignal,
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
shell,
|
||||
windowsHide: true,
|
||||
}) as Promise<ExecCommandResult>;
|
||||
}
|
||||
|
||||
/** Force-stop a CLI, including descendants behind Windows command-script launchers. */
|
||||
export async function terminateProcess(
|
||||
child: ChildProcess,
|
||||
signal: NodeJS.Signals = "SIGKILL",
|
||||
): Promise<void> {
|
||||
if (process.platform !== "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (child.pid === undefined) return;
|
||||
try {
|
||||
await execCommand("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
|
||||
shell: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
// The process can finish between its close check and taskkill.
|
||||
if (!(error instanceof Error && "code" in error && error.code === 128)) throw error;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export interface UsageAccount {
|
||||
/** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */
|
||||
key: string;
|
||||
label?: string;
|
||||
/** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */
|
||||
harness?: string;
|
||||
/** Store locator, opaque to the daemon. */
|
||||
input: JsonValue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.4",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -6289,6 +6289,17 @@ export const UsageReportEntrySchema = z.object({
|
||||
sourceLabel: z.string(),
|
||||
icon: z.string().optional(),
|
||||
report: UsageReportSchema,
|
||||
loginErrors: z
|
||||
.array(
|
||||
z.object({
|
||||
harness: z.string(),
|
||||
report: z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }),
|
||||
z.object({ status: z.literal("error"), error: z.string() }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
export const UsageListReportsUpdateMessageSchema = z.object({
|
||||
type: z.literal("usage.list_reports.update"),
|
||||
|
||||
@@ -389,3 +389,50 @@ test("blocked setup preserves the legacy failed shape and optional provenance",
|
||||
legacySnapshot.parse(failed),
|
||||
);
|
||||
});
|
||||
|
||||
test("usage login errors are additive and older reports still parse", () => {
|
||||
const entry = {
|
||||
id: "codex:account",
|
||||
account: {},
|
||||
fetchedAt: "2026-10-05T00:00:00.000Z",
|
||||
sourceId: "codex",
|
||||
sourceLabel: "Codex",
|
||||
report: { status: "error", error: "Usage API returned 500" },
|
||||
};
|
||||
const legacy = z.object({
|
||||
type: z.literal("usage.list_reports.update"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
report: z.object({
|
||||
id: z.string(),
|
||||
account: z.object({ label: z.string().optional() }),
|
||||
fetchedAt: z.string(),
|
||||
sourceId: z.string(),
|
||||
sourceLabel: z.string(),
|
||||
icon: z.string().optional(),
|
||||
report: z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }),
|
||||
z.object({ status: z.literal("unavailable"), problem: z.unknown() }),
|
||||
z.object({ status: z.literal("error"), error: z.string() }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const oldMessage = {
|
||||
type: "usage.list_reports.update",
|
||||
payload: { requestId: "usage", report: entry },
|
||||
};
|
||||
const newMessage = {
|
||||
...oldMessage,
|
||||
payload: {
|
||||
...oldMessage.payload,
|
||||
report: {
|
||||
...entry,
|
||||
loginErrors: [{ harness: "Codex", report: entry.report }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage);
|
||||
expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage);
|
||||
expect(legacy.parse(newMessage)).toEqual(oldMessage);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.4",
|
||||
"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-beta.4",
|
||||
"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-beta.4",
|
||||
"@getpaseo/highlight": "0.11.0-beta.4",
|
||||
"@getpaseo/plugin": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"@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",
|
||||
|
||||
@@ -27,6 +27,8 @@ async function runSupervisorFixture(options: {
|
||||
fileSizeLimitBlocks?: number;
|
||||
/** Occupy the log path with a directory so the supervisor cannot open daemon.log. */
|
||||
blockLogPath?: boolean;
|
||||
/** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */
|
||||
closeOutput?: boolean;
|
||||
}): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
@@ -86,6 +88,11 @@ async function runSupervisorFixture(options: {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (options.closeOutput) {
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
@@ -116,10 +123,19 @@ async function runSupervisorFixture(options: {
|
||||
});
|
||||
});
|
||||
|
||||
const log = await readFile(logPath, "utf8");
|
||||
const log = await readLogIfWritten(logPath);
|
||||
return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr };
|
||||
}
|
||||
|
||||
async function readLogIfWritten(logPath: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(logPath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe("supervisor durable logging", () => {
|
||||
test("resolves rotation defaults", () => {
|
||||
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
|
||||
@@ -189,6 +205,30 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.stderr).toContain('"worker-json-stderr"');
|
||||
});
|
||||
|
||||
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
closeOutput: true,
|
||||
workerSource: `
|
||||
process.on("message", (message) => {
|
||||
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||
});
|
||||
process.stdout.write("first stdout line\\n");
|
||||
process.stderr.write("first stderr line\\n");
|
||||
setTimeout(() => {
|
||||
process.stdout.write("later stdout line\\n");
|
||||
process.send?.({ type: "paseo:shutdown", reason: "closed_output_probe" });
|
||||
}, 500);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain("first stderr line\n");
|
||||
expect(result.log).toContain("later stdout line\n");
|
||||
expect(result.log).toContain('"reason":"closed_output_probe"');
|
||||
});
|
||||
|
||||
test("preserves raw non-JSON stdout and stderr lines", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
|
||||
@@ -166,6 +166,15 @@ function createDurableLog(
|
||||
};
|
||||
}
|
||||
|
||||
// The program that started the daemon can close its end of stdout or stderr, as
|
||||
// `paseo daemon run | head -1` does. Drop the output nobody reads and keep supervising
|
||||
// the worker; daemon.log still records it.
|
||||
function ignoreClosedOutput(error: NodeJS.ErrnoException): void {
|
||||
if (error.code !== "EPIPE") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function runSupervisor(options: SupervisorOptions): SupervisorController {
|
||||
const restartOnCrash = options.restartOnCrash ?? false;
|
||||
const workerArgs = options.workerArgs ?? process.argv.slice(2);
|
||||
@@ -482,6 +491,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
|
||||
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
||||
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
||||
process.stdout.on("error", ignoreClosedOutput);
|
||||
process.stderr.on("error", ignoreClosedOutput);
|
||||
|
||||
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
|
||||
writeLifecycleLog(options.startupMessage);
|
||||
|
||||
@@ -3,12 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
executableExists,
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
} from "./executable-resolution.js";
|
||||
import { executableExists, findExecutable } from "./executable-resolution.js";
|
||||
import { windowsExecutableResolution } from "./windows.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
@@ -217,118 +212,3 @@ describe("executableExists", () => {
|
||||
expect(executableExists("/missing/codex", () => false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindowsCommand", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows path with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted path", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("escapes ampersands", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
|
||||
});
|
||||
|
||||
test("escapes pipes", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
|
||||
});
|
||||
|
||||
test("does not double percent signs", () => {
|
||||
setPlatform("win32");
|
||||
// cmd.exe only collapses %% → % inside batch files; on the command line
|
||||
// it stays literal, which corrupts git --format atoms etc.
|
||||
expect(quoteWindowsCommand("100%")).toBe("100%");
|
||||
});
|
||||
|
||||
test("preserves git --format atoms verbatim", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe(
|
||||
"--format=%^(refname^)%09%^(committerdate:unix^)",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes carets", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
|
||||
});
|
||||
|
||||
test("escapes multiple metacharacters", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
|
||||
"build^&^(test^|deploy^)^!^<output^>",
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes commands with spaces after escaping metacharacters", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
|
||||
'"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindowsArgument", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows argument with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted argument", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the argument unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||
});
|
||||
|
||||
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,8 @@ import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { ProcessEnvRecord } from "../server/paseo-env.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import { isWindowsCommandScript } from "../utils/windows-command.js";
|
||||
import { windowsExecutableResolution } from "./windows.js";
|
||||
|
||||
export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js";
|
||||
|
||||
type Which = (
|
||||
command: string,
|
||||
options: { all: true; path?: string; pathExt?: string },
|
||||
@@ -82,7 +79,6 @@ export async function probeExecutable(
|
||||
timeout: timeoutMs,
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 64 * 1024,
|
||||
shell: isWindowsCommandScript(executablePath),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -634,6 +634,72 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
expect(events[0]).toMatchObject({ id: TOOL_USE_ID });
|
||||
});
|
||||
|
||||
test.each(["Agent", "Task"] as const)(
|
||||
"labels a background %s call's card with its type and task after a restart",
|
||||
async (toolName) => {
|
||||
writeSession({
|
||||
parentLines: [
|
||||
parentEntry([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: TOOL_USE_ID,
|
||||
name: toolName,
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
},
|
||||
},
|
||||
]),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "replay-session",
|
||||
timestamp: "2026-07-26T06:27:48.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: TOOL_USE_ID,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
|
||||
}),
|
||||
],
|
||||
meta: JSON.stringify({
|
||||
agentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
toolUseId: TOOL_USE_ID,
|
||||
spawnDepth: 1,
|
||||
requestShape: "background",
|
||||
}),
|
||||
});
|
||||
|
||||
const cards = (await replayEvents()).flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.callId === TOOL_USE_ID
|
||||
? [event.item]
|
||||
: [],
|
||||
);
|
||||
expect(cards.at(-1)).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("replays the subagent's own transcript onto its timeline", async () => {
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
|
||||
@@ -130,6 +130,7 @@ import {
|
||||
type ProviderCatalog,
|
||||
type ProviderRefreshContext,
|
||||
type ResolveAgentDefaultModeInput,
|
||||
type ToolCallDetail,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../../provider-session-import.js";
|
||||
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
|
||||
@@ -295,6 +296,13 @@ interface AsyncMessageInput<T> {
|
||||
interface ClaudeReplayOwnership {
|
||||
restoredIds: ReadonlySet<string>;
|
||||
toolOwners: ReadonlyMap<string, string>;
|
||||
/** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */
|
||||
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>;
|
||||
}
|
||||
|
||||
interface ClaudeSubagentCardFacts {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface PersistedTimelineEntry {
|
||||
@@ -4249,16 +4257,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
...(declaration.title ? { subAgentType: declaration.title } : {}),
|
||||
...(declaration.description ? { description: declaration.description } : {}),
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4977,6 +4976,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const sidechainEntries = [parentContent, ...sidechains.contents]
|
||||
.flatMap(parseClaudeHistoryRecords)
|
||||
.filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string");
|
||||
const parentFacts = readClaudeReplayParentFacts(parentEntries);
|
||||
|
||||
// Replay produces the same observations the live task protocol produces, then folds them
|
||||
// with the same function, so identity and status are derived once for both paths.
|
||||
@@ -4987,7 +4987,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
entries,
|
||||
parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]),
|
||||
})),
|
||||
parent: readClaudeReplayParentFacts(parentEntries),
|
||||
parent: parentFacts,
|
||||
convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry),
|
||||
});
|
||||
const observations = [
|
||||
@@ -5016,6 +5016,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const replay = {
|
||||
restoredIds: restoredProviderSubagentIds,
|
||||
toolOwners: subagentReplay.toolOwners,
|
||||
subagentToolCalls: parentFacts.toolCalls,
|
||||
};
|
||||
if (observations.length === 0) return replay;
|
||||
|
||||
@@ -5082,7 +5083,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
const taskSnapshot = this.taskState.observe(entry);
|
||||
const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)];
|
||||
const items = [
|
||||
...(taskSnapshot ? [taskSnapshot] : []),
|
||||
...this.convertHistoryEntry(entry).map((item) =>
|
||||
labelReplayedSubagentCard(item, replay.subagentToolCalls),
|
||||
),
|
||||
];
|
||||
const isVisibleUserEntry =
|
||||
entry.type === "user" &&
|
||||
typeof entry.uuid === "string" &&
|
||||
@@ -5832,6 +5838,33 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The parent's card for a subagent, labeled with its type and task. */
|
||||
function buildClaudeSubagentCardDetail(
|
||||
facts: ClaudeSubagentCardFacts,
|
||||
): Extract<ToolCallDetail, { type: "sub_agent" }> {
|
||||
return {
|
||||
type: "sub_agent",
|
||||
...(facts.title ? { subAgentType: facts.title } : {}),
|
||||
...(facts.description ? { description: facts.description } : {}),
|
||||
log: "",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live, a Task call's card is built from the task protocol's declaration. Replay has no task
|
||||
* protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts.
|
||||
*/
|
||||
function labelReplayedSubagentCard(
|
||||
item: AgentTimelineItem,
|
||||
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>,
|
||||
): AgentTimelineItem {
|
||||
if (item.type !== "tool_call") return item;
|
||||
const facts = subagentToolCalls.get(item.callId);
|
||||
if (!facts) return item;
|
||||
return { ...item, detail: buildClaudeSubagentCardDetail(facts) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect what the parent transcript knows about its Task calls, in the shape the shared replay
|
||||
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import { sendPromptToAgent } from "../agent-prompt.js";
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
function isCodexInstalled(): boolean {
|
||||
try {
|
||||
return (
|
||||
execFileSync("which", ["codex"], { encoding: "utf8" }).trim().length > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sse(events: Array<Record<string, unknown>>): string {
|
||||
return events
|
||||
.map(
|
||||
(event) =>
|
||||
`event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function assistantSse(id: string, text: string, totalTokens: number): string {
|
||||
return sse([
|
||||
{ type: "response.created", response: { id } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: `msg-${id}`,
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id,
|
||||
usage: {
|
||||
input_tokens: totalTokens,
|
||||
input_tokens_details: null,
|
||||
output_tokens: 0,
|
||||
output_tokens_details: null,
|
||||
total_tokens: totalTokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function isCompactionRequest(body: string): boolean {
|
||||
return body.includes("CONTEXT CHECKPOINT COMPACTION");
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const requests: Array<{ compaction: boolean; body: string }> = [];
|
||||
const heldCompactions: ServerResponse[] = [];
|
||||
let index = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
req.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const compaction = isCompactionRequest(body);
|
||||
requests.push({ compaction, body });
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
if (compaction) {
|
||||
// Hold the summarization open, as a slow real compaction would be.
|
||||
heldCompactions.push(res);
|
||||
return;
|
||||
}
|
||||
index += 1;
|
||||
// The first turn reports a context far above the auto-compact limit.
|
||||
res.end(
|
||||
assistantSse(
|
||||
`resp-${index}`,
|
||||
`reply ${index}`,
|
||||
index === 1 ? 50_000 : 100
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("no address");
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
releaseCompactions() {
|
||||
for (const res of heldCompactions.splice(0)) {
|
||||
res.end(assistantSse("summary", "SUMMARY", 100));
|
||||
}
|
||||
},
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
for (const res of heldCompactions.splice(0)) res.destroy();
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function writeConfig(codexHome: string, serverUrl: string): void {
|
||||
writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
`
|
||||
model = "mock-model"
|
||||
approval_policy = "on-request"
|
||||
sandbox_mode = "read-only"
|
||||
model_provider = "mock_provider"
|
||||
model_auto_compact_token_limit = 10000
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "${serverUrl}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => boolean,
|
||||
label: string,
|
||||
timeoutMs = 15_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!check()) {
|
||||
if (Date.now() > deadline)
|
||||
throw new Error(`Timed out waiting for ${label}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
describe("Codex compaction and incoming prompts (local e2e)", () => {
|
||||
for (const behavior of ["send_agent_prompt default", "steer"] as const) {
|
||||
test.runIf(isCodexInstalled())(
|
||||
`${behavior}: messages from other agents during auto-compaction do not restart it (#5728)`,
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-compact-cwd-"));
|
||||
const codexHome = mkdtempSync(
|
||||
path.join(os.tmpdir(), "codex-compact-home-")
|
||||
);
|
||||
const server = await startServer();
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
let agentId: string | undefined;
|
||||
try {
|
||||
writeConfig(codexHome, server.url);
|
||||
vi.stubEnv("CODEX_HOME", codexHome);
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
|
||||
undefined,
|
||||
{ workspaceId: undefined }
|
||||
);
|
||||
agentId = created.id;
|
||||
const id = agentId;
|
||||
await manager.runAgent(id, "first turn");
|
||||
|
||||
const send = (text: string) =>
|
||||
sendPromptToAgent({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
agentId: id,
|
||||
prompt: text,
|
||||
...(behavior === "steer"
|
||||
? { activeTurnBehavior: "steer" as const }
|
||||
: {}),
|
||||
logger,
|
||||
});
|
||||
const compactions = () =>
|
||||
server.requests.filter((r) => r.compaction).length;
|
||||
|
||||
await send("second turn");
|
||||
await waitFor(() => compactions() === 1, "first compaction request");
|
||||
|
||||
// Two more messages arrive from other agents while it compacts.
|
||||
await send("update from agent A");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
await send("update from agent B");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Each message that replaced the turn aborted the compaction and started another.
|
||||
expect(compactions()).toBe(1);
|
||||
server.releaseCompactions();
|
||||
await waitFor(
|
||||
() => manager.getAgent(id)?.lifecycle === "idle",
|
||||
"agent idle",
|
||||
20_000
|
||||
);
|
||||
|
||||
// Every message reaches Codex and the chat, once compaction finishes.
|
||||
const delivered = server.requests
|
||||
.filter((request) => !request.compaction)
|
||||
.map((request) => request.body)
|
||||
.join("\n");
|
||||
const chat = manager
|
||||
.getTimeline(id)
|
||||
.flatMap((item) =>
|
||||
item.type === "user_message" ? [item.text] : []
|
||||
);
|
||||
for (const text of [
|
||||
"second turn",
|
||||
"update from agent A",
|
||||
"update from agent B",
|
||||
]) {
|
||||
expect(delivered).toContain(text);
|
||||
expect(chat).toContain(text);
|
||||
}
|
||||
expect(compactions()).toBe(1);
|
||||
} finally {
|
||||
if (agentId && manager.getAgent(agentId))
|
||||
await manager.closeAgent(agentId);
|
||||
await storage.flush();
|
||||
vi.unstubAllEnvs();
|
||||
await server.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -154,6 +154,28 @@ describe("JsonlRpcProcess", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("publishes unmatched responses without publishing the response that settles a request", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const transport = startProcess({ child });
|
||||
const messages: Record<string, unknown>[] = [];
|
||||
transport.onMessage((message) => messages.push(message));
|
||||
|
||||
try {
|
||||
const { id, promise } = transport.startRequest({ type: "prompt" });
|
||||
const response = { id, type: "response", command: "prompt" };
|
||||
const rejection = { ...response, success: false, error: "rejected after acknowledgement" };
|
||||
const unknownResponse = { ...response, id: "unknown-request", success: true };
|
||||
child.stdout.write(`${JSON.stringify({ ...response, success: true })}\n`);
|
||||
child.stdout.write(`${JSON.stringify(rejection)}\n`);
|
||||
child.stdout.write(`${JSON.stringify(unknownResponse)}\n`);
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(messages).toEqual([rejection, unknownResponse]);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unsuccessful responses", async () => {
|
||||
const transport = startProcess();
|
||||
|
||||
|
||||
@@ -258,25 +258,27 @@ export class JsonlRpcProcess {
|
||||
this.frameDecoder.write(chunk);
|
||||
}
|
||||
|
||||
/** Route a complete logical frame to its consumer (response or subscriber). */
|
||||
/** Route a complete logical frame to its consumer (pending request or subscriber). */
|
||||
private dispatchFrame(message: Record<string, unknown>): void {
|
||||
if (message.type === "response") {
|
||||
this.handleResponse(message as unknown as JsonlRpcResponse);
|
||||
const pending =
|
||||
message.type === "response" && typeof message.id === "string"
|
||||
? this.pending.get(message.id)
|
||||
: undefined;
|
||||
if (pending) {
|
||||
this.settleRequest(pending, message as unknown as JsonlRpcResponse & { id: string });
|
||||
return;
|
||||
}
|
||||
// A response nothing is waiting for is published like any other frame, so a provider
|
||||
// can read it as an event, such as OMP 18.3 rejecting a prompt after acknowledging it.
|
||||
for (const subscriber of this.messageSubscribers) {
|
||||
subscriber(message);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(response: JsonlRpcResponse): void {
|
||||
if (!response.id) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(response.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
private settleRequest(
|
||||
pending: PendingRequest,
|
||||
response: JsonlRpcResponse & { id: string },
|
||||
): void {
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
|
||||
@@ -927,6 +927,24 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test.each(["result after ack", "result before ack"] as const)(
|
||||
"fails a prompt OMP rejects before its agent runs (%s)",
|
||||
async (order) => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await expect(
|
||||
omp.runPromptRejectedBeforeAgentRuns(
|
||||
"Reply with ok.",
|
||||
"No API key found for anthropic.",
|
||||
order,
|
||||
),
|
||||
).rejects.toThrow("No API key found for anthropic.");
|
||||
expect(omp.turnFailures()).toEqual(["No API key found for anthropic."]);
|
||||
expect(omp.completedTurnCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
test("completes a no-turn notify with one notification and no assistant text", async () => {
|
||||
const scheduler = new ManualNoTurnScheduler();
|
||||
const omp = new OmpHarness({ noTurnScheduler: scheduler });
|
||||
@@ -1218,6 +1236,25 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]);
|
||||
});
|
||||
|
||||
test("ignores a prompt rejection a previous OMP process left for a reused request id", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
omp.emit({
|
||||
type: "prompt_result",
|
||||
id: "req_1",
|
||||
agentInvoked: false,
|
||||
status: "error",
|
||||
error: { message: "No API key found for anthropic." },
|
||||
});
|
||||
omp.processExit("OMP RPC process exited with code 1 and signal null");
|
||||
|
||||
await omp.runPromptWithoutTurnOnNextRuntime("/session", "req_1");
|
||||
|
||||
expect(omp.runtimeLaunches()).toHaveLength(2);
|
||||
expect(omp.turnFailures()).toEqual(["OMP RPC process exited with code 1 and signal null"]);
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("reports an immediate relaunch failure without retrying in a loop", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
@@ -172,6 +172,21 @@ interface OmpPersistenceMetadata {
|
||||
bridgedTools: Map<string, OmpBridgedToolIdentity>;
|
||||
}
|
||||
|
||||
/** OMP's outcome for one prompt, correlated by the prompt's request id. */
|
||||
interface OmpPromptResult {
|
||||
agentInvoked: boolean;
|
||||
/** Why OMP rejected the prompt before its agent ran, such as a missing API key. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function promptResultError(result: {
|
||||
status?: string;
|
||||
error?: { message: string };
|
||||
}): string | null {
|
||||
if (result.status !== "error") return null;
|
||||
return result.error?.message || "OMP rejected the prompt";
|
||||
}
|
||||
|
||||
interface StartTurnResult {
|
||||
turnId: string;
|
||||
}
|
||||
@@ -695,7 +710,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = [];
|
||||
private activePromptRequestId: string | null = null;
|
||||
private activePromptAgentInvoked: boolean | null = null;
|
||||
private readonly pendingPromptResults = new Map<string, boolean>();
|
||||
private activePromptError: string | null = null;
|
||||
private readonly pendingPromptResults = new Map<string, OmpPromptResult>();
|
||||
private pendingNoTurnCompletionAbort: AbortController | null = null;
|
||||
private lastKnownThinkingOptionId: string | null;
|
||||
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
|
||||
@@ -865,12 +881,13 @@ export class OmpAgentSession implements AgentSession {
|
||||
if (ack.requestId) {
|
||||
this.pendingPromptResults.delete(ack.requestId);
|
||||
}
|
||||
this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null;
|
||||
if (correlatedResult === false) {
|
||||
this.activePromptAgentInvoked = correlatedResult?.agentInvoked ?? ack.agentInvoked ?? null;
|
||||
this.activePromptError = correlatedResult?.error ?? null;
|
||||
if (correlatedResult?.agentInvoked === false) {
|
||||
this.scheduleNoTurnPromptCompletion(turnId);
|
||||
return;
|
||||
}
|
||||
if (correlatedResult !== true && ack.agentInvoked === false) {
|
||||
if (correlatedResult?.agentInvoked !== true && ack.agentInvoked === false) {
|
||||
await this.completeNoTurnPrompt(turnId);
|
||||
return;
|
||||
}
|
||||
@@ -1028,6 +1045,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
const state = await next.getState();
|
||||
if (this.closed) throw new Error("OMP session is closed");
|
||||
this.unsubscribeRuntime?.();
|
||||
// Request ids restart with each OMP process, so results held for the old one cannot
|
||||
// correlate with prompts sent to the new one.
|
||||
this.pendingPromptResults.clear();
|
||||
this.runtimeSession = next;
|
||||
this.hostTools = restarted.hostTools;
|
||||
this.state = state;
|
||||
@@ -1309,7 +1329,14 @@ export class OmpAgentSession implements AgentSession {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const error = this.activePromptError;
|
||||
this.emitBufferedNoTurnOutputs(turnId);
|
||||
if (error) {
|
||||
this.usagePoller.stopTurn();
|
||||
this.resetActiveTurn({ terminalizeWork: true });
|
||||
this.emit({ type: "turn_failed", provider: this.provider, turnId, error });
|
||||
return;
|
||||
}
|
||||
this.completeTurn(turnId, []);
|
||||
}
|
||||
|
||||
@@ -1318,6 +1345,7 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeNoTurnPromptText = null;
|
||||
this.activePromptRequestId = null;
|
||||
this.activePromptAgentInvoked = null;
|
||||
this.activePromptError = null;
|
||||
this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length);
|
||||
}
|
||||
|
||||
@@ -1715,15 +1743,17 @@ export class OmpAgentSession implements AgentSession {
|
||||
? event.agentInvoked
|
||||
: undefined;
|
||||
if (requestId && agentInvoked !== undefined) {
|
||||
const result = { agentInvoked, error: promptResultError(event) };
|
||||
if (requestId === this.activePromptRequestId && this.activeTurnId) {
|
||||
this.activePromptAgentInvoked = agentInvoked;
|
||||
this.activePromptError = result.error;
|
||||
if (agentInvoked === false) {
|
||||
this.scheduleNoTurnPromptCompletion(this.activeTurnId);
|
||||
} else {
|
||||
this.cancelNoTurnPromptCompletion();
|
||||
}
|
||||
} else if (this.activePromptRequestId === null) {
|
||||
this.pendingPromptResults.set(requestId, agentInvoked);
|
||||
this.pendingPromptResults.set(requestId, result);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -227,6 +227,95 @@ describe("OMP CLI runtime", () => {
|
||||
expect(eventTypes).toEqual(["notice"]);
|
||||
});
|
||||
|
||||
test("emits agent_end for a run that contains an OMP developer message", async () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
const eventTypes: string[] = [];
|
||||
session.onEvent((event) => eventTypes.push(event.type));
|
||||
|
||||
// OMP 18.5 injects a developer reminder mid-run when a tool call matches a rule.
|
||||
const reminder = {
|
||||
role: "developer",
|
||||
content: [
|
||||
{ type: "text", text: '<system-reminder reason="rule_violation">…</system-reminder>' },
|
||||
],
|
||||
attribution: "agent",
|
||||
timestamp: 2,
|
||||
};
|
||||
child.stdout.write(`${JSON.stringify({ type: "message_end", message: reminder })}\n`);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "agent_end",
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Write tiny.ts" }], timestamp: 1 },
|
||||
reminder,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
stopReason: "stop",
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
expect(eventTypes).toEqual(["message_end", "agent_end"]);
|
||||
});
|
||||
|
||||
test("emits agent_end for a run that contains every OMP message role Paseo does not render", async () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
const eventTypes: string[] = [];
|
||||
session.onEvent((event) => eventTypes.push(event.type));
|
||||
|
||||
// A prompt that @-mentions a file makes OMP add a fileMention message to the run.
|
||||
const fileMention = {
|
||||
role: "fileMention",
|
||||
files: [{ path: "notes.md", content: "# Notes\n", lineCount: 1 }],
|
||||
timestamp: 2,
|
||||
};
|
||||
child.stdout.write(`${JSON.stringify({ type: "message_end", message: fileMention })}\n`);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "agent_end",
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Read @notes.md" }], timestamp: 1 },
|
||||
fileMention,
|
||||
{
|
||||
role: "pythonExecution",
|
||||
code: "print(1)",
|
||||
output: "1\n",
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
role: "hookMessage",
|
||||
customType: "hook",
|
||||
content: "hook output",
|
||||
display: false,
|
||||
timestamp: 4,
|
||||
},
|
||||
{ role: "branchSummary", summary: "Earlier branch", fromId: "entry-1", timestamp: 5 },
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "Earlier turns",
|
||||
tokensBefore: 1000,
|
||||
timestamp: 6,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
stopReason: "stop",
|
||||
timestamp: 7,
|
||||
},
|
||||
],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
expect(eventTypes).toEqual(["message_end", "agent_end"]);
|
||||
});
|
||||
|
||||
test("lists commands through get_available_commands", async () => {
|
||||
const child = createOmpChild();
|
||||
const commandTypes: string[] = [];
|
||||
@@ -352,6 +441,31 @@ describe("OMP CLI runtime", () => {
|
||||
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
|
||||
});
|
||||
|
||||
test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => {
|
||||
const child = createOmpChild();
|
||||
onOmpCommand(child, (command) => {
|
||||
if (command.type !== "prompt") return;
|
||||
const response = { id: command.id, type: "response", command: "prompt" };
|
||||
const rejection = { ...response, success: false, error: "No API key found for anthropic." };
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({ ...response, success: true })}\n${JSON.stringify(rejection)}\n`,
|
||||
);
|
||||
});
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
const events: unknown[] = [];
|
||||
session.onEvent((event) => events.push(event));
|
||||
|
||||
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "prompt_result",
|
||||
id: "req_1",
|
||||
agentInvoked: false,
|
||||
status: "error",
|
||||
error: { message: "No API key found for anthropic." },
|
||||
});
|
||||
});
|
||||
|
||||
test("negotiates RPC protocol v2 when OMP advertises it", async () => {
|
||||
const child = createOmpChild({ supportedProtocolVersions: [1, 2] });
|
||||
const commands: Record<string, unknown>[] = [];
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
OmpModelsResultSchema,
|
||||
OmpPromptAckSchema,
|
||||
OmpRpcCommandSchema,
|
||||
OmpRuntimeEventSchema,
|
||||
parseOmpRuntimeEvent,
|
||||
OmpSessionStateSchema,
|
||||
OmpSessionStatsSchema,
|
||||
type OmpThinkingLevel,
|
||||
@@ -121,9 +121,9 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
|
||||
private readonly launchEnvironment: Record<string, string>,
|
||||
) {
|
||||
process.onMessage((message) => {
|
||||
const event = OmpRuntimeEventSchema.safeParse(message);
|
||||
if (event.success) {
|
||||
this.emit(event.data);
|
||||
const event = parseOmpRuntimeEvent(message);
|
||||
if (event) {
|
||||
this.emit(event);
|
||||
}
|
||||
});
|
||||
process.onExit(({ error }) => {
|
||||
|
||||
@@ -41,6 +41,13 @@ const OmpUserMessageSchema = z
|
||||
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
|
||||
})
|
||||
.passthrough();
|
||||
// OMP injects hidden developer messages, such as rule-violation reminders, into a run.
|
||||
const OmpDeveloperMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("developer"),
|
||||
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
|
||||
})
|
||||
.passthrough();
|
||||
const OmpCustomMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("custom"),
|
||||
@@ -79,13 +86,30 @@ const OmpBashExecutionMessageSchema = z
|
||||
timestamp: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
// Roles Paseo does not render. They are listed so that a frame carrying one, such as
|
||||
// agent_end, still parses; every role in OMP's RPC AgentMessage must appear in the union.
|
||||
const OmpPythonExecutionMessageSchema = z
|
||||
.object({ role: z.literal("pythonExecution") })
|
||||
.passthrough();
|
||||
const OmpHookMessageSchema = z.object({ role: z.literal("hookMessage") }).passthrough();
|
||||
const OmpBranchSummaryMessageSchema = z.object({ role: z.literal("branchSummary") }).passthrough();
|
||||
const OmpCompactionSummaryMessageSchema = z
|
||||
.object({ role: z.literal("compactionSummary") })
|
||||
.passthrough();
|
||||
const OmpFileMentionMessageSchema = z.object({ role: z.literal("fileMention") }).passthrough();
|
||||
|
||||
export const OmpAgentMessageSchema = z.discriminatedUnion("role", [
|
||||
OmpUserMessageSchema,
|
||||
OmpDeveloperMessageSchema,
|
||||
OmpCustomMessageSchema,
|
||||
OmpAssistantMessageSchema,
|
||||
OmpToolResultMessageSchema,
|
||||
OmpBashExecutionMessageSchema,
|
||||
OmpPythonExecutionMessageSchema,
|
||||
OmpHookMessageSchema,
|
||||
OmpBranchSummaryMessageSchema,
|
||||
OmpCompactionSummaryMessageSchema,
|
||||
OmpFileMentionMessageSchema,
|
||||
]);
|
||||
|
||||
export const OmpModelThinkingSchema = z
|
||||
@@ -480,6 +504,8 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
|
||||
type: z.literal("prompt_result"),
|
||||
id: z.string().optional(),
|
||||
agentInvoked: z.boolean().optional(),
|
||||
status: z.string().optional(),
|
||||
error: z.object({ message: z.string() }).passthrough().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(),
|
||||
@@ -609,6 +635,36 @@ export type OmpSubagentEventPayload = z.infer<typeof OmpSubagentEventPayloadSche
|
||||
export type OmpAssistantMessageEvent = z.infer<typeof OmpAssistantMessageEventSchema>;
|
||||
export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>;
|
||||
export type OmpRuntimeEvent = z.infer<typeof OmpRuntimeEventSchema>;
|
||||
|
||||
/**
|
||||
* COMPAT(ompLatePromptRejection): added in v0.11.0, remove after 2027-04-05 once the
|
||||
* minimum supported OMP version emits prompt_result errors.
|
||||
* OMP 18.3 reports a prompt it rejects after acknowledging it, such as one whose model has
|
||||
* no API key, only as a second failed `response` frame for the prompt id. Later releases
|
||||
* report the same outcome as a `prompt_result` with `status: "error"`.
|
||||
*/
|
||||
const OmpLatePromptRejectionSchema = z.object({
|
||||
type: z.literal("response"),
|
||||
command: z.literal("prompt"),
|
||||
id: z.string(),
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export function parseOmpRuntimeEvent(message: unknown): OmpRuntimeEvent | null {
|
||||
const rejection = OmpLatePromptRejectionSchema.safeParse(message);
|
||||
if (rejection.success) {
|
||||
return {
|
||||
type: "prompt_result",
|
||||
id: rejection.data.id,
|
||||
agentInvoked: false,
|
||||
status: "error",
|
||||
error: { message: rejection.data.error },
|
||||
};
|
||||
}
|
||||
const event = OmpRuntimeEventSchema.safeParse(message);
|
||||
return event.success ? event.data : null;
|
||||
}
|
||||
export type OmpTodoItem = z.infer<typeof OmpTodoItemSchema>;
|
||||
export type OmpTodoPhase = z.infer<typeof OmpTodoPhaseSchema>;
|
||||
export type OmpTodoReminderEvent = z.infer<typeof OmpTodoReminderEventSchema>;
|
||||
|
||||
@@ -294,6 +294,14 @@ export class OmpHarness {
|
||||
return await run;
|
||||
}
|
||||
|
||||
async runPromptWithoutTurnOnNextRuntime(input: string, requestId: string): Promise<unknown> {
|
||||
const session = this.requireSession();
|
||||
this.omp.queueSessionSetup((runtime) => {
|
||||
runtime.promptAck = { requestId, agentInvoked: false };
|
||||
});
|
||||
return await session.run(input);
|
||||
}
|
||||
|
||||
async runPromptWithoutTurn(input: string): Promise<unknown> {
|
||||
const session = this.requireSession();
|
||||
this.omp.latestSession().promptAck = { agentInvoked: false };
|
||||
@@ -329,6 +337,31 @@ export class OmpHarness {
|
||||
return { completed: () => isCompleted, completion };
|
||||
}
|
||||
|
||||
async runPromptRejectedBeforeAgentRuns(
|
||||
input: string,
|
||||
error: string,
|
||||
order: "result after ack" | "result before ack",
|
||||
): Promise<unknown> {
|
||||
const session = this.requireSession();
|
||||
const runtime = this.omp.latestSession();
|
||||
runtime.promptAck = { requestId: "prompt-rejected" };
|
||||
const rejection = {
|
||||
type: "prompt_result",
|
||||
id: "prompt-rejected",
|
||||
agentInvoked: false,
|
||||
status: "error",
|
||||
sessionSettled: true,
|
||||
error: { message: error, retryable: false },
|
||||
} as const;
|
||||
const promptStarted = runtime.nextPrompt();
|
||||
const run = session.run(input);
|
||||
if (order === "result before ack") runtime.emit(rejection);
|
||||
await promptStarted;
|
||||
await waitForImmediate();
|
||||
if (order === "result after ack") runtime.emit(rejection);
|
||||
return await run;
|
||||
}
|
||||
|
||||
async runPromptAfterCorrelatedTrueResult(
|
||||
input: string,
|
||||
output: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user