mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 23:02:36 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb2b40855 | ||
|
|
c062278595 | ||
|
|
b9cdfb6528 | ||
|
|
1361da5ef6 | ||
|
|
3377ffed29 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.1 - 2026-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
|
||||
|
||||
## 0.7.0 - 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -404,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. `params.rpcTimeoutMs` overrides the 60-second OMP control-plane RPC deadline.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
|
||||
@@ -30,10 +30,8 @@ The UI worklet owns transient motion:
|
||||
- the active gesture's starting revision
|
||||
- the last settled target
|
||||
|
||||
React publishes the active panel only after its motion reaches the final anchor. Retained content
|
||||
never observes gesture previews, progress, or an unsettled target. The gesture hosts stay mounted;
|
||||
worklets reveal their retained overlays through native styles while React owns pointer events and
|
||||
accessibility.
|
||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||
|
||||
## Why one position
|
||||
|
||||
@@ -87,8 +85,8 @@ definition, no longer eligible to begin.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
- Gesture start and progress must not update React state. The retained overlay is already mounted and
|
||||
offscreen; only the shared position and its derived native styles move during a drag.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
@@ -100,8 +98,8 @@ definition, no longer eligible to begin.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- `useIsMobilePanelActive` follows the settled target, not the requested target. Activation work may
|
||||
begin only after the finishing animation completes; cancellation publishes nothing.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
|
||||
+15
-38
@@ -2,9 +2,8 @@
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
Paseo does not sandbox it.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -48,13 +47,15 @@ my-plugin/
|
||||
paseo-plugin.json
|
||||
index.ts
|
||||
main.client.tsx
|
||||
paseo-plugin.d.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
||||
dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the
|
||||
runtime modules, so consumers do not install these packages when adding the plugin.
|
||||
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||
SDK contract changes.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -83,52 +84,28 @@ directory always wins over shorthand resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/monorepo --path plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin status
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
```
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||
commits. Removing a Git source deletes Paseo's managed checkout.
|
||||
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
|
||||
it before changing the configured path. A compilation or startup failure restores the running
|
||||
version. Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
### Declare Git preparation
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
that Paseo does not provide, generate source or assets, or perform another required preparation
|
||||
step:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`build` is an optional list of argv arrays. Each array must contain at least one non-empty string;
|
||||
shell command strings are rejected. Paseo starts the executable directly, without a shell, from the
|
||||
plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager.
|
||||
|
||||
On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared
|
||||
commands, then validates, compiles, and activates the candidate. It logs each argv command and its
|
||||
output in the daemon log. If a command fails, the error includes its output, Paseo discards the
|
||||
candidate, and the existing installed and running version stays untouched. On a remote daemon, all
|
||||
of this happens on the remote daemon host.
|
||||
Git installation does not run a package manager or install script. A distributable source plugin
|
||||
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
|
||||
dependencies work only when the Paseo app already ships the native module.
|
||||
|
||||
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
||||
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
||||
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Git preparation commands are
|
||||
recorded in `$PASEO_HOME/daemon.log` before a plugin exists, rather than the plugin log tail. Reload, disable, and process
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
|
||||
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
|
||||
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
||||
log credentials or tokens.
|
||||
|
||||
+3
-3
@@ -67,7 +67,7 @@ Pi model records expose input capabilities through `model.input`. Only send raw
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
|
||||
Pi and OMP control-plane RPCs wait 60 seconds by default. Override the provider's `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs a different deadline. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
@@ -85,9 +85,9 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36298,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37363,12 +37363,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/server": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/server": "0.7.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37615,10 +37615,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/relay": "0.7.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37629,7 +37629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37872,7 +37872,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38797,13 +38797,13 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38812,15 +38812,15 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38833,7 +38833,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39051,17 +39051,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/highlight": "0.7.0",
|
||||
"@getpaseo/plugin": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/highlight": "0.7.1",
|
||||
"@getpaseo/plugin": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/relay": "0.7.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39985,7 +39985,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -39,10 +39,7 @@ import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
@@ -471,10 +468,6 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
switchAwayAndBackWithoutImageInstability,
|
||||
userPagesUntilAssistantImageRenders,
|
||||
} from "../support/helpers/assistant-images";
|
||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
@@ -85,15 +84,8 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||
.toBe(Math.round(initialBox!.width));
|
||||
|
||||
await workspace.client.sendAgentMessage(
|
||||
imageAgent.id,
|
||||
"Stay running while the assistant image lightbox is closed.",
|
||||
);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await canvas.click({ position: { x: 4, y: 4 } });
|
||||
await expect(lightboxImage).toHaveCount(0);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
|
||||
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
|
||||
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-message-render-limit-",
|
||||
title: "Assistant message render limit",
|
||||
initialPrompt: "Render the configured oversized response.",
|
||||
featureValues: { mockAssistantResponse: response },
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(assistantMessage).not.toContainText(hiddenTail);
|
||||
|
||||
const notice = assistantMessage.getByTestId("assistant-message-capped-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toHaveText(
|
||||
`This message was capped (${getUtf8ByteLength(response)} bytes).`,
|
||||
);
|
||||
await expect(notice).toHaveCSS("font-style", "italic");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
expectTurnCopyButton,
|
||||
} from "../support/helpers/agent-stream";
|
||||
import {
|
||||
expectReconnectingToastDebounced,
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
@@ -180,17 +181,13 @@ test.describe("Viewed agent timelines", () => {
|
||||
"true",
|
||||
);
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastDebounced(page);
|
||||
await expectReconnectingToastVisible(page);
|
||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||
await expect(
|
||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
// Hold the first authoritative catch-up response so the assertion observes
|
||||
// the reconnect boundary instead of racing a socket that has not reopened yet.
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.restore();
|
||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||
await expectReconnectingToastGone(page);
|
||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||
exact: true,
|
||||
@@ -212,7 +209,6 @@ test.describe("Viewed agent timelines", () => {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
createMockIdleAgent,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||
|
||||
try {
|
||||
const agent = await createMockIdleAgent(workspace.client, {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
@@ -180,7 +180,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await daemonGate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
@@ -218,12 +217,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
createMockIdleAgent(primaryWorkspace.client, {
|
||||
createIdleAgent(primaryWorkspace.client, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title: "Active host agent",
|
||||
}),
|
||||
createMockIdleAgent(secondaryWorkspace.client, {
|
||||
createIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: "Inactive host agent",
|
||||
@@ -320,12 +319,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
@@ -14,31 +14,6 @@ function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
interface PositionedToolCall {
|
||||
locator: Locator;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
async function readRenderedToolCalls(toolCalls: Locator): Promise<PositionedToolCall[]> {
|
||||
const entries = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
locator: toolCalls.nth(index),
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
return entries.filter(
|
||||
(entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) {
|
||||
return toolCalls.toSorted(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - targetY),
|
||||
)[0];
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
@@ -205,53 +180,58 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
const visibleButtonBounds = buttonBounds!;
|
||||
|
||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||
const toolCallBounds = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
index,
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||
const initialToolCalls = await readRenderedToolCalls(toolCalls);
|
||||
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
|
||||
const candidate = toolCallBounds
|
||||
.filter(
|
||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
||||
entry.bounds !== null && entry.bounds.width > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
||||
)[0];
|
||||
expect(
|
||||
initialCandidate,
|
||||
candidate,
|
||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||
buttonBounds,
|
||||
scrollMetrics: await readScrollMetrics(page),
|
||||
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
|
||||
toolCallBounds,
|
||||
})}`,
|
||||
).toBeDefined();
|
||||
const visibleToolCall = candidate!;
|
||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
||||
(scroll as HTMLElement).scrollTop += deltaY;
|
||||
}, initialToolCallCenterY - buttonCenterY);
|
||||
|
||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
if (!currentButtonBounds) return false;
|
||||
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
|
||||
const closest = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
);
|
||||
if (!closest) return false;
|
||||
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
|
||||
if (
|
||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||
return false;
|
||||
}
|
||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||
return (
|
||||
toolCallCenterY >= currentButtonBounds.y &&
|
||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY));
|
||||
await getVisibleChatScroll(page).evaluate((scroll, delta) => {
|
||||
(scroll as HTMLElement).scrollTop += delta;
|
||||
}, deltaY);
|
||||
return false;
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2;
|
||||
const alignedToolCall = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
)?.locator;
|
||||
expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined();
|
||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall!.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
expect(
|
||||
@@ -265,7 +245,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
x: finalToolCallBounds.x + 24,
|
||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||
};
|
||||
const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => {
|
||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return hit !== null && toolCall.contains(hit);
|
||||
}, clickPoint);
|
||||
|
||||
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
|
||||
): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
async function seedIdleAgent(
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
provider: {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
...provider,
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
title: input.title,
|
||||
@@ -84,32 +84,6 @@ async function seedIdleAgent(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMockIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||
agentId: string,
|
||||
|
||||
@@ -315,9 +315,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||
const suppressedAgentStreamItemTypes = new Set<string>();
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
let blockedConnectionCount = 0;
|
||||
let blockedConnectionCountAtDrop = 0;
|
||||
const blockedConnectionWaiters = new Set<() => void>();
|
||||
let latestServer: WebSocketRoute | null = null;
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
@@ -446,9 +443,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
blockedConnectionCount += 1;
|
||||
for (const resolve of blockedConnectionWaiters) resolve();
|
||||
blockedConnectionWaiters.clear();
|
||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||
return;
|
||||
}
|
||||
@@ -589,7 +583,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
forward?.();
|
||||
},
|
||||
async drop(): Promise<void> {
|
||||
blockedConnectionCountAtDrop = blockedConnectionCount;
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
@@ -599,10 +592,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
),
|
||||
);
|
||||
},
|
||||
async waitForBlockedConnection(): Promise<void> {
|
||||
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
|
||||
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
|
||||
@@ -112,6 +112,14 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastDebounced(
|
||||
page: Page,
|
||||
options?: { durationMs?: number },
|
||||
): Promise<void> {
|
||||
await page.waitForTimeout(options?.durationMs ?? 750);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||
import { I18nProvider } from "@/i18n/provider";
|
||||
import {
|
||||
KeyboardActionDispatcherProvider,
|
||||
@@ -114,7 +114,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -627,9 +627,10 @@ function SidebarChrome({
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
const active = visible && isOpen;
|
||||
return (
|
||||
<SidebarModelProvider active={active}>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT,
|
||||
capAssistantMessageForRender,
|
||||
getUtf8ByteLength,
|
||||
} from "./assistant-message-render-limit";
|
||||
|
||||
describe("assistant message render limit", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("leaves messages at the limit unchanged", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
|
||||
});
|
||||
|
||||
it("caps oversized messages before markdown rendering", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not split a grapheme cluster at the boundary", () => {
|
||||
const prefix = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT - 1);
|
||||
|
||||
expect(capAssistantMessageForRender(`${prefix}étail`)).toEqual({
|
||||
text: prefix,
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts the complete message size in UTF-8 bytes", () => {
|
||||
expect(getUtf8ByteLength("aé😀")).toBe(7);
|
||||
});
|
||||
|
||||
it("keeps a bounded prefix when grapheme segmentation is unavailable", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined });
|
||||
const { capAssistantMessageForRender: capWithoutSegmenter } =
|
||||
await import("./assistant-message-render-limit");
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capWithoutSegmenter(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { clampToSafeRevealBoundary, isTextRevealPacingSupported } from "@/agent-stream/text-reveal";
|
||||
|
||||
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
|
||||
|
||||
export interface CappedAssistantMessage {
|
||||
text: string;
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
export function getUtf8ByteLength(message: string): number {
|
||||
let bytes = 0;
|
||||
|
||||
for (let index = 0; index < message.length; index += 1) {
|
||||
const codeUnit = message.charCodeAt(index);
|
||||
if (codeUnit < 0x80) {
|
||||
bytes += 1;
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2;
|
||||
} else if (
|
||||
codeUnit >= 0xd800 &&
|
||||
codeUnit <= 0xdbff &&
|
||||
index + 1 < message.length &&
|
||||
message.charCodeAt(index + 1) >= 0xdc00 &&
|
||||
message.charCodeAt(index + 1) <= 0xdfff
|
||||
) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
|
||||
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
|
||||
return { text: message, capped: false };
|
||||
}
|
||||
|
||||
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
|
||||
if (isTextRevealPacingSupported()) {
|
||||
end = clampToSafeRevealBoundary(message, end);
|
||||
} else {
|
||||
const finalCodeUnit = message.charCodeAt(end - 1);
|
||||
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: message.slice(0, end), capped: true };
|
||||
}
|
||||
@@ -136,8 +136,6 @@ let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
dom.window.requestAnimationFrame = vi.fn(() => 1);
|
||||
dom.window.cancelAnimationFrame = vi.fn();
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
|
||||
@@ -12,7 +12,6 @@ import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -34,27 +33,23 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false);
|
||||
}, [metadata?.id, url]);
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
return true;
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && source !== null,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isWeb || !source) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
};
|
||||
}, [onClose, source]);
|
||||
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
@@ -92,7 +87,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
NativeExplorerSidebarDock,
|
||||
} from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
@@ -52,7 +51,7 @@ function useActiveCompactExplorerSidebarModel(
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
||||
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
@@ -68,32 +67,32 @@ function useActiveCompactExplorerSidebarModel(
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerActive) {
|
||||
if (enabled && isExplorerOpen) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerActive) {
|
||||
if (!isExplorerOpen) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
retainedModelRef.current = resolvedModel;
|
||||
}
|
||||
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
||||
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -82,7 +81,7 @@ export function CompactExplorerSidebar({
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isActive = useIsMobilePanelActive("file-explorer");
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
@@ -100,11 +99,11 @@ export function CompactExplorerSidebar({
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen: isActive,
|
||||
isOpen,
|
||||
});
|
||||
showMobileAgent();
|
||||
},
|
||||
[isActive, showMobileAgent],
|
||||
[isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
@@ -128,7 +127,7 @@ export function CompactExplorerSidebar({
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isActive}>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
@@ -142,7 +141,7 @@ export function CompactExplorerSidebar({
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isActive}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
|
||||
@@ -62,6 +62,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
@@ -120,7 +121,6 @@ interface SidebarLabels {
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
active: boolean;
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
closeSidebar: () => void;
|
||||
@@ -273,7 +273,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
@@ -605,7 +604,6 @@ function SidebarFooter({
|
||||
}
|
||||
|
||||
function MobileSidebar({
|
||||
active,
|
||||
theme,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
@@ -640,6 +638,7 @@ function MobileSidebar({
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
closeSidebar();
|
||||
@@ -738,7 +737,7 @@ function MobileSidebar({
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -116,7 +116,6 @@ import {
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
} from "@/assistant-selection-copy/markup";
|
||||
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
@@ -764,13 +763,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
containerCompactBottom: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
cappedNotice: {
|
||||
marginTop: theme.spacing[3],
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontStyle: "italic",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
imageFrame: {
|
||||
width: "100%",
|
||||
minHeight: 160,
|
||||
@@ -1496,16 +1488,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
spacing = "default",
|
||||
phase,
|
||||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||||
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
|
||||
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
||||
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
||||
const revealedMessage = useRevealedText(renderedMessage.text, phase);
|
||||
const fullMessageByteLength = useMemo(
|
||||
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
||||
[message, phase, renderedMessage.capped],
|
||||
);
|
||||
const revealedMessage = useRevealedText(message, phase);
|
||||
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
@@ -1984,14 +1970,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
/>
|
||||
</AssistantMessageBlockContainer>
|
||||
))}
|
||||
{fullMessageByteLength !== null ? (
|
||||
<Text
|
||||
testID="assistant-message-capped-notice"
|
||||
style={assistantMessageStylesheet.cappedNotice}
|
||||
>
|
||||
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectHeaderRowProps {
|
||||
@@ -1540,7 +1540,7 @@ function ProjectBlock({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
hostBadgeByServerId,
|
||||
@@ -1565,7 +1565,7 @@ function ProjectBlock({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
@@ -1745,7 +1745,7 @@ function ProjectBlock({
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canToggleWorkspaces ? (
|
||||
@@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.isDragging === next.isDragging &&
|
||||
previous.dragHandleProps === next.dragHandleProps &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.dragGestureHostPresented === next.dragGestureHostPresented &&
|
||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||
areProjectBlockSelectionsEqual(previous, next)
|
||||
);
|
||||
@@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
@@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
pathname={pathname}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
}: {
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
sidebarFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}) {
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
const pinnedWorkspaces = useMemo(
|
||||
@@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2072,7 +2072,7 @@ function ProjectModeList({
|
||||
sidebarFilterEmpty,
|
||||
hasActiveProjectFilter,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
pathname,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
@@ -2289,7 +2289,7 @@ function ProjectModeList({
|
||||
isDragging={dragState.isDragging}
|
||||
dragHandleProps={dragState.dragHandleProps}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
@@ -2311,7 +2311,7 @@ function ProjectModeList({
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
projectIconByProjectViewKey,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -2388,7 +2388,7 @@ function ProjectModeList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
);
|
||||
@@ -2411,7 +2411,7 @@ function ProjectModeList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canTogglePinnedChats ? (
|
||||
|
||||
@@ -126,7 +126,7 @@ interface StatusWorkspaceListProps {
|
||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||
sidebarFilterEmpty?: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
@@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty = false,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
@@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
|
||||
@@ -171,7 +171,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.width = `${currentModel.viewportWidth}px`;
|
||||
canvas.style.top = `${canvasTop}px`;
|
||||
canvas.style.height = `${canvasHeight}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
@@ -536,10 +535,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const canvasStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
...CANVAS_STYLE,
|
||||
width: model.viewportWidth,
|
||||
fontFamily: (loadedTypography ?? desiredTypography).family,
|
||||
fontSize: (loadedTypography ?? desiredTypography).size,
|
||||
}),
|
||||
[desiredTypography, loadedTypography],
|
||||
[desiredTypography, loadedTypography, model.viewportWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -203,7 +203,6 @@ export const ar: TranslationResources = {
|
||||
empty: "ابدأ الدردشة مع هذا الوكيل...",
|
||||
scrollToBottom: "قم بالتمرير إلى الأسفل",
|
||||
historyLoadFailed: "تعذر تحميل سجل الوكيل",
|
||||
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
|
||||
permission: {
|
||||
plan: "يخطط",
|
||||
required: "الإذن مطلوب",
|
||||
|
||||
@@ -200,7 +200,6 @@ export const en = {
|
||||
empty: "Start chatting with this agent...",
|
||||
scrollToBottom: "Scroll to bottom",
|
||||
historyLoadFailed: "Couldn't load agent history",
|
||||
messageCapped: "This message was capped ({{bytes}} bytes).",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "Permission Required",
|
||||
|
||||
@@ -203,7 +203,6 @@ export const es: TranslationResources = {
|
||||
empty: "Comience a chatear con este agente...",
|
||||
scrollToBottom: "Desplazarse hacia abajo",
|
||||
historyLoadFailed: "No se pudo cargar el historial del agente",
|
||||
messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "Permiso requerido",
|
||||
|
||||
@@ -205,7 +205,6 @@ export const fr: TranslationResources = {
|
||||
empty: "Commencez à discuter avec cet agent...",
|
||||
scrollToBottom: "Faire défiler vers le bas",
|
||||
historyLoadFailed: "Impossible de charger l’historique de l’agent",
|
||||
messageCapped: "Ce message a été tronqué ({{bytes}} octets).",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "Autorisation requise",
|
||||
|
||||
@@ -203,7 +203,6 @@ export const ja: TranslationResources = {
|
||||
empty: "このエージェントとチャットを始めましょう...",
|
||||
scrollToBottom: "下にスクロール",
|
||||
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
|
||||
messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。",
|
||||
permission: {
|
||||
plan: "プラン",
|
||||
required: "権限が必要です",
|
||||
|
||||
@@ -203,7 +203,6 @@ export const ko: TranslationResources = {
|
||||
empty: "이 에이전트와 대화를 시작하세요...",
|
||||
scrollToBottom: "맨 아래로 스크롤",
|
||||
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
|
||||
messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).",
|
||||
permission: {
|
||||
plan: "계획",
|
||||
required: "권한 필요",
|
||||
|
||||
@@ -203,7 +203,6 @@ export const ptBR: TranslationResources = {
|
||||
empty: "Comece a conversar com este agente...",
|
||||
scrollToBottom: "Rolar para o fim",
|
||||
historyLoadFailed: "Não foi possível carregar o histórico do agente",
|
||||
messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).",
|
||||
permission: {
|
||||
plan: "Plano",
|
||||
required: "Permissão necessária",
|
||||
|
||||
@@ -203,7 +203,6 @@ export const ru: TranslationResources = {
|
||||
empty: "Начните общаться с этим агентом...",
|
||||
scrollToBottom: "Прокрутить вниз",
|
||||
historyLoadFailed: "Не удалось загрузить историю агента",
|
||||
messageCapped: "Это сообщение было обрезано ({{bytes}} байт).",
|
||||
permission: {
|
||||
plan: "План",
|
||||
required: "Требуется разрешение",
|
||||
|
||||
@@ -203,7 +203,6 @@ export const zhCN: TranslationResources = {
|
||||
empty: "开始和这个 Agent 对话...",
|
||||
scrollToBottom: "滚动到底部",
|
||||
historyLoadFailed: "无法加载智能体历史记录",
|
||||
messageCapped: "此消息已被截断({{bytes}} 字节)。",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "需要权限",
|
||||
|
||||
@@ -95,7 +95,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({ origin: "agent" });
|
||||
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
||||
@@ -188,6 +188,7 @@ export function useCloseAgentListGesture() {
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({
|
||||
origin: "agent-list",
|
||||
preview: "agent-list",
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -289,6 +290,7 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({
|
||||
origin: "agent",
|
||||
preview: "file-explorer",
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -384,6 +386,7 @@ export function useCloseFileExplorerGesture() {
|
||||
.onStart(() => {
|
||||
startedRevision.value = beginGesture({
|
||||
origin: "file-explorer",
|
||||
preview: "file-explorer",
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
canBeginMobilePanelGesture,
|
||||
createMobilePanelMotionState,
|
||||
getMobilePanelFrame,
|
||||
isMobilePanelActive,
|
||||
isMobilePanelGestureCurrent,
|
||||
transitionMobilePanel,
|
||||
type MobilePanelCommit,
|
||||
@@ -78,71 +77,6 @@ class MobilePanelsScenario {
|
||||
}
|
||||
|
||||
describe("mobile panel ownership", () => {
|
||||
it("publishes activity only after motion settles", () => {
|
||||
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
|
||||
const dragging = transitionMobilePanel(initial, {
|
||||
type: "gesture.begin",
|
||||
origin: "agent",
|
||||
}).state;
|
||||
const released = transitionMobilePanel(dragging, {
|
||||
type: "gesture.finish",
|
||||
startedRevision: 0,
|
||||
success: true,
|
||||
target: "agent-list",
|
||||
}).state;
|
||||
const commanded = transitionMobilePanel(released, {
|
||||
type: "command",
|
||||
selection: { target: "agent-list", revision: 1 },
|
||||
}).state;
|
||||
|
||||
expect(isMobilePanelActive(dragging, "agent-list")).toBe(false);
|
||||
expect(isMobilePanelActive(released, "agent-list")).toBe(false);
|
||||
expect(isMobilePanelActive(commanded, "agent-list")).toBe(false);
|
||||
|
||||
const settled = transitionMobilePanel(commanded, {
|
||||
type: "animation.finished",
|
||||
revision: 1,
|
||||
target: "agent-list",
|
||||
}).state;
|
||||
expect(isMobilePanelActive(settled, "agent-list")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not change activity for a cancelled preview", () => {
|
||||
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
|
||||
const dragging = transitionMobilePanel(initial, {
|
||||
type: "gesture.begin",
|
||||
origin: "agent",
|
||||
}).state;
|
||||
const cancelled = transitionMobilePanel(dragging, {
|
||||
type: "gesture.finish",
|
||||
startedRevision: 0,
|
||||
success: false,
|
||||
target: "agent-list",
|
||||
}).state;
|
||||
|
||||
expect(isMobilePanelActive(dragging, "agent")).toBe(true);
|
||||
expect(isMobilePanelActive(cancelled, "agent")).toBe(true);
|
||||
expect(isMobilePanelActive(cancelled, "agent-list")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the visible panel active until its closing motion settles", () => {
|
||||
const initial = createMobilePanelMotionState({ target: "agent-list", revision: 0 });
|
||||
const closing = transitionMobilePanel(initial, {
|
||||
type: "command",
|
||||
selection: { target: "agent", revision: 1 },
|
||||
}).state;
|
||||
|
||||
expect(isMobilePanelActive(closing, "agent-list")).toBe(true);
|
||||
|
||||
const settled = transitionMobilePanel(closing, {
|
||||
type: "animation.finished",
|
||||
revision: 1,
|
||||
target: "agent",
|
||||
}).state;
|
||||
expect(isMobilePanelActive(settled, "agent-list")).toBe(false);
|
||||
expect(isMobilePanelActive(settled, "agent")).toBe(true);
|
||||
});
|
||||
|
||||
it("follows programmatic commands through left, center, and right", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
|
||||
@@ -143,13 +143,6 @@ export function isMobilePanelGestureCurrent(
|
||||
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
|
||||
}
|
||||
|
||||
export function isMobilePanelActive(
|
||||
state: MobilePanelMotionState,
|
||||
panel: MobilePanelView,
|
||||
): boolean {
|
||||
return state.settledTarget === panel;
|
||||
}
|
||||
|
||||
export function getMobilePanelFrame(position: number, width: number) {
|
||||
"worklet";
|
||||
const clampedPosition = Math.max(-1, Math.min(1, position));
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isWeb } from "@/constants/platform";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
||||
import { getMobilePanelFrame } from "./model";
|
||||
import { useIsMobilePanelActive, useMobilePanelsRuntime } from "./provider";
|
||||
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
|
||||
|
||||
type OverlayPanel = Exclude<MobilePanelView, "agent">;
|
||||
|
||||
@@ -24,8 +24,10 @@ export function MobilePanelOverlay({
|
||||
panelStyle,
|
||||
}: MobilePanelOverlayProps) {
|
||||
const { position, windowWidth } = useMobilePanelsRuntime();
|
||||
const target = usePanelStore((state) => state.mobilePanel.target);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const isOpen = useIsMobilePanelActive(panel);
|
||||
const isOpen = target === panel;
|
||||
const isPresented = useIsMobilePanelPresented(panel);
|
||||
const isLeft = panel === "agent-list";
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
||||
@@ -40,11 +42,10 @@ export function MobilePanelOverlay({
|
||||
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
||||
}, [isLeft, windowWidth]);
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
const isVisible = isLeft ? position.value < 0 : position.value > 0;
|
||||
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||
}, [isLeft]);
|
||||
|
||||
const overlayStyle = useMemo(
|
||||
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
|
||||
[isPresented],
|
||||
);
|
||||
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
||||
const backdropStyle = useMemo(
|
||||
() => [styles.backdrop, backdropAnimatedStyle],
|
||||
@@ -72,25 +73,19 @@ export function MobilePanelOverlay({
|
||||
{/* Fabric needs an always-mounted native host to attach the close handler while the
|
||||
retained panel content is hidden. nativeID keeps that host registered. */}
|
||||
<View
|
||||
accessibilityElementsHidden={!isOpen}
|
||||
collapsable={false}
|
||||
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
|
||||
nativeID={`${panel}-gesture-host`}
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={styles.overlay}
|
||||
>
|
||||
<Animated.View
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={[styles.overlay, overlayAnimatedStyle]}
|
||||
>
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Pressable
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
onPress={showMobileAgent}
|
||||
pointerEvents={isOpen ? "auto" : "none"}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
testID={isOpen ? `${panel}-backdrop` : undefined}
|
||||
testID={`${panel}-backdrop`}
|
||||
>
|
||||
<Animated.View pointerEvents="none" style={backdropStyle} />
|
||||
</Pressable>
|
||||
@@ -98,7 +93,7 @@ export function MobilePanelOverlay({
|
||||
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
||||
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,18 @@ import {
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||
const LEFT_PANEL_MASK = 1;
|
||||
const RIGHT_PANEL_MASK = 2;
|
||||
|
||||
function getPanelMask(panel: MobilePanelView): number {
|
||||
if (panel === "agent-list") {
|
||||
return LEFT_PANEL_MASK;
|
||||
}
|
||||
if (panel === "file-explorer") {
|
||||
return RIGHT_PANEL_MASK;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface MobilePanelsRuntime {
|
||||
beginGesture: (input: BeginGestureInput) => number;
|
||||
@@ -57,6 +69,7 @@ interface MobilePanelsRuntime {
|
||||
|
||||
interface BeginGestureInput {
|
||||
origin: MobilePanelView;
|
||||
preview: MobilePanelView;
|
||||
}
|
||||
|
||||
interface FinishGestureInput {
|
||||
@@ -66,7 +79,7 @@ interface FinishGestureInput {
|
||||
}
|
||||
|
||||
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
||||
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
|
||||
const MobilePanelPresentationContext = createContext(0);
|
||||
|
||||
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
@@ -79,7 +92,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const [activePanel, setActivePanel] = useState(initialSelection.target);
|
||||
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
|
||||
|
||||
const setOpenGestureBlocked = useCallback(
|
||||
(owner: symbol, blocked: boolean) => {
|
||||
@@ -93,15 +106,19 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
[openGesturesBlocked],
|
||||
);
|
||||
|
||||
const publishActivePanel = useCallback((panel: MobilePanelView, revision: number) => {
|
||||
const presentPanel = useCallback((panel: MobilePanelView) => {
|
||||
const mask = getPanelMask(panel);
|
||||
if (mask) {
|
||||
setPresentedPanels((current) => current | mask);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
|
||||
const selection = usePanelStore.getState().mobilePanel;
|
||||
if (selection.revision !== revision || selection.target !== panel) {
|
||||
return;
|
||||
}
|
||||
if (isNative && panel !== "agent") {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
setActivePanel(panel);
|
||||
setPresentedPanels(getPanelMask(panel));
|
||||
}, []);
|
||||
|
||||
const animateTransition = useCallback(
|
||||
@@ -129,11 +146,11 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
motionState.value = settled.state;
|
||||
scheduleOnRN(publishActivePanel, target, revision);
|
||||
scheduleOnRN(settlePresentation, target, revision);
|
||||
},
|
||||
);
|
||||
},
|
||||
[motionState, position, publishActivePanel],
|
||||
[motionState, position, settlePresentation],
|
||||
);
|
||||
|
||||
const applySelection = useCallback(
|
||||
@@ -159,12 +176,18 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
if (selection === previousState.mobilePanel) {
|
||||
return;
|
||||
}
|
||||
if (selection.target !== "agent") {
|
||||
presentPanel(selection.target);
|
||||
if (isNative) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}
|
||||
scheduleOnUI(applySelection, selection);
|
||||
});
|
||||
}, [applySelection]);
|
||||
}, [applySelection, presentPanel]);
|
||||
|
||||
const beginGesture = useCallback(
|
||||
({ origin }: BeginGestureInput): number => {
|
||||
({ origin, preview }: BeginGestureInput): number => {
|
||||
"worklet";
|
||||
const currentState = motionState.value;
|
||||
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
|
||||
@@ -176,9 +199,10 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
motionState.value = transition.state;
|
||||
cancelAnimation(position);
|
||||
scheduleOnRN(presentPanel, preview);
|
||||
return transition.state.gesture?.startedRevision ?? -1;
|
||||
},
|
||||
[motionState, position],
|
||||
[motionState, position, presentPanel],
|
||||
);
|
||||
|
||||
const updateGesture = useCallback(
|
||||
@@ -242,9 +266,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<MobilePanelsContext.Provider value={value}>
|
||||
<MobilePanelActiveContext.Provider value={activePanel}>
|
||||
<MobilePanelPresentationContext.Provider value={presentedPanels}>
|
||||
{children}
|
||||
</MobilePanelActiveContext.Provider>
|
||||
</MobilePanelPresentationContext.Provider>
|
||||
</MobilePanelsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -258,8 +282,9 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
|
||||
return useContext(MobilePanelActiveContext) === panel;
|
||||
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
|
||||
const presentedPanels = useContext(MobilePanelPresentationContext);
|
||||
return (presentedPanels & getPanelMask(panel)) !== 0;
|
||||
}
|
||||
|
||||
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"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.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/server": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/server": "0.7.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -9,44 +9,25 @@ const getPluginLogs = vi.fn(async () => [
|
||||
message: "ready",
|
||||
},
|
||||
]);
|
||||
const installDirectoryPlugin = vi.fn(async () => ({
|
||||
id: "trusted-plugin",
|
||||
path: "/plugins/trusted-plugin",
|
||||
enabled: true,
|
||||
status: "running" as const,
|
||||
}));
|
||||
const installPluginSource = vi.fn(async () => ({
|
||||
id: "trusted-plugin",
|
||||
path: "/plugins/trusted-plugin",
|
||||
enabled: true,
|
||||
status: "running" as const,
|
||||
}));
|
||||
const close = vi.fn(async () => undefined);
|
||||
const features: {
|
||||
pluginManagement?: boolean;
|
||||
pluginLogs?: boolean;
|
||||
pluginGitManagement?: boolean;
|
||||
} = {};
|
||||
const features: { pluginManagement?: boolean; pluginLogs?: boolean } = {};
|
||||
|
||||
vi.mock("../../utils/client.js", () => ({
|
||||
connectToDaemon: vi.fn(async () => ({
|
||||
getLastServerInfoMessage: () => ({ features }),
|
||||
listPlugins,
|
||||
getPluginLogs,
|
||||
installDirectoryPlugin,
|
||||
installPluginSource,
|
||||
close,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { createPluginCommand, runPluginListCommand, runPluginLogsCommand } from "./index.js";
|
||||
import { runPluginListCommand, runPluginLogsCommand } from "./index.js";
|
||||
|
||||
describe("plugin management commands", () => {
|
||||
beforeEach(() => {
|
||||
features.pluginManagement = false;
|
||||
features.pluginLogs = false;
|
||||
features.pluginGitManagement = false;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -82,55 +63,4 @@ describe("plugin management commands", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes trust explicit at the plugin add entry point", () => {
|
||||
const command = createPluginCommand();
|
||||
expect(
|
||||
command.commands.find((subcommand) => subcommand.name() === "install")?.description(),
|
||||
).toContain("Trust and install");
|
||||
expect(command.helpInformation()).toContain("trusted, unsandboxed plugins");
|
||||
});
|
||||
|
||||
it("prints the trust acknowledgement before installing", async () => {
|
||||
features.pluginManagement = true;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" });
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Git build commands run unsandboxed on the daemon host"),
|
||||
);
|
||||
expect(installDirectoryPlugin).toHaveBeenCalledWith("/plugins/trusted-plugin", undefined);
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("folds the legacy --path option into the plugin source reference", async () => {
|
||||
features.pluginGitManagement = true;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(installPluginSource).toHaveBeenCalledWith({
|
||||
source: "owner/monorepo:plugins/review",
|
||||
});
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps an absolute monorepo path as one plugin source reference", async () => {
|
||||
features.pluginGitManagement = true;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
await command.parseAsync(["install", "/plugins/monorepo:plugins/review"], { from: "user" });
|
||||
|
||||
expect(installPluginSource).toHaveBeenCalledWith({
|
||||
source: "/plugins/monorepo:plugins/review",
|
||||
});
|
||||
expect(installDirectoryPlugin).not.toHaveBeenCalled();
|
||||
stderr.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,6 @@ import type {
|
||||
PluginSourceStatusItem,
|
||||
PluginSourceUpdateItem,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
formatPluginSourceReference,
|
||||
parsePluginSourceReference,
|
||||
} from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
|
||||
@@ -117,9 +113,6 @@ async function install(
|
||||
options: PluginOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<PluginListItem>> {
|
||||
process.stderr.write(
|
||||
"Trusting plugin code: server code and Git build commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
|
||||
);
|
||||
const isExplicitPath =
|
||||
path.isAbsolute(source) ||
|
||||
source === "." ||
|
||||
@@ -128,19 +121,17 @@ async function install(
|
||||
source.startsWith("../") ||
|
||||
source.startsWith(".\\") ||
|
||||
source.startsWith("..\\");
|
||||
const hasPluginPathSuffix = parsePluginSourceReference(source).pluginPath !== undefined;
|
||||
const canUseLegacyDirectoryInstall =
|
||||
isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path;
|
||||
const sourceReference = formatPluginSourceReference(source, options.path);
|
||||
const canUseLegacyDirectoryInstall = isExplicitPath && !options.ref && !options.path;
|
||||
const data = canUseLegacyDirectoryInstall
|
||||
? await withPluginManagementClient(options.host, (client) =>
|
||||
client.installDirectoryPlugin(source, options.id),
|
||||
)
|
||||
: await withPluginSourceClient(options.host, (client) =>
|
||||
client.installPluginSource({
|
||||
source: sourceReference,
|
||||
source,
|
||||
...(options.id ? { id: options.id } : {}),
|
||||
...(options.ref ? { ref: options.ref } : {}),
|
||||
...(options.path ? { pluginPath: options.path } : {}),
|
||||
}),
|
||||
);
|
||||
return { type: "single", data, schema: pluginSchema };
|
||||
@@ -197,7 +188,7 @@ async function remove(
|
||||
}
|
||||
|
||||
export function createPluginCommand(): Command {
|
||||
const plugin = new Command("plugin").description("Manage trusted, unsandboxed plugins");
|
||||
const plugin = new Command("plugin").description("Manage trusted plugins");
|
||||
addJsonOption(
|
||||
plugin
|
||||
.command("init")
|
||||
@@ -215,11 +206,11 @@ export function createPluginCommand(): Command {
|
||||
plugin
|
||||
.command("install")
|
||||
.alias("add")
|
||||
.description("Trust and install a plugin from a directory or Git repository")
|
||||
.argument("<source>", "Host directory, Git source, or Git source:plugin/path")
|
||||
.description("Install a plugin from a directory or Git repository")
|
||||
.argument("<source>", "Host directory, owner/repo shorthand, or Git URL")
|
||||
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
||||
.option("--ref <ref>", "Git branch, tag, or commit")
|
||||
.option("--path <path>", "Legacy form of the :plugin/path source suffix"),
|
||||
.option("--path <path>", "Plugin directory within the repository"),
|
||||
).action(withOutput(install));
|
||||
addJsonAndDaemonHostOptions(
|
||||
plugin.command("status").description("Check plugin source updates").argument("[id]"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import ts from "typescript";
|
||||
@@ -34,22 +34,6 @@ describe("plugin scaffold", () => {
|
||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
||||
id: "hello-plugin",
|
||||
});
|
||||
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
|
||||
name: "hello-plugin",
|
||||
private: true,
|
||||
version: "0.0.0",
|
||||
scripts: { typecheck: "tsc --noEmit" },
|
||||
devDependencies: {
|
||||
"@getpaseo/plugin": "0.7.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@types/react": "~19.2.0",
|
||||
react: "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
typescript: "^5.9.3",
|
||||
zod: "^4.4.3",
|
||||
},
|
||||
});
|
||||
expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts");
|
||||
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
|
||||
'from "./main.client"',
|
||||
);
|
||||
|
||||
@@ -1,7 +1,364 @@
|
||||
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
import { resolveCliVersion } from "../../version.js";
|
||||
|
||||
const SDK_DECLARATIONS = `declare module "@getpaseo/plugin/server" {
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
|
||||
export interface PluginRpcContract<
|
||||
InputSchema extends ZodType = ZodType,
|
||||
OutputSchema extends ZodType = ZodType,
|
||||
> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
export interface PluginAttachmentItem {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
text: string;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export interface PluginAttachmentSearchPayload {
|
||||
items: PluginAttachmentItem[];
|
||||
}
|
||||
|
||||
export interface PluginAttachmentSourceContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
search: PluginRpcContract;
|
||||
}
|
||||
|
||||
export interface PluginHandlerContext {
|
||||
paseo: PaseoApi;
|
||||
}
|
||||
|
||||
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}): PluginRpcContract<InputSchema, OutputSchema>;
|
||||
|
||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition;
|
||||
|
||||
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
|
||||
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
|
||||
}
|
||||
|
||||
declare module "@getpaseo/plugin/react-native" {
|
||||
import type { ComponentType, FunctionComponent, ReactNode } from "react";
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalComponent extends FunctionComponent<ModalProps> {
|
||||
Content: ComponentType<ModalContentProps>;
|
||||
}
|
||||
|
||||
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
export interface ToastOptions {
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
}
|
||||
export interface ToastApi {
|
||||
show(message: string, options?: ToastOptions): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export const Icon: ComponentType<PluginIconProps>;
|
||||
export const Modal: ModalComponent;
|
||||
export function useToast(): ToastApi;
|
||||
}
|
||||
|
||||
declare module "@getpaseo/plugin" {
|
||||
import type { ComponentType } from "react";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginHandlerContext,
|
||||
PluginRpcContract,
|
||||
} from "@getpaseo/plugin/server";
|
||||
|
||||
export {
|
||||
PluginAttachmentItemSchema,
|
||||
PluginAttachmentSearchPayloadSchema,
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
type PluginAttachmentItem,
|
||||
type PluginAttachmentSearchPayload,
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginHandlerContext,
|
||||
type PluginRpcContract,
|
||||
} from "@getpaseo/plugin/server";
|
||||
|
||||
export interface PluginTheme {
|
||||
readonly colors: {
|
||||
readonly surface0: string;
|
||||
readonly surface1: string;
|
||||
readonly surface2: string;
|
||||
readonly border: string;
|
||||
readonly foreground: string;
|
||||
readonly foregroundMuted: string;
|
||||
readonly accent: string;
|
||||
readonly accentForeground: string;
|
||||
readonly statusSuccess: string;
|
||||
readonly statusWarning: string;
|
||||
readonly statusDanger: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginHostProps {
|
||||
theme: PluginTheme;
|
||||
host: { id: string; label: string };
|
||||
layout: { compact: boolean; platform: "ios" | "android" | "web" };
|
||||
}
|
||||
|
||||
interface PluginNavigableHostProps extends PluginHostProps {
|
||||
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
|
||||
readonly navigation?: {
|
||||
readonly openAgent: (input: { readonly agentId: string }) => void;
|
||||
readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginSurfaceProps extends PluginNavigableHostProps {}
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceSnapshot {
|
||||
readonly id: string;
|
||||
readonly projectId: string;
|
||||
readonly projectDisplayName: string;
|
||||
readonly projectRootPath: string;
|
||||
readonly directory: string;
|
||||
readonly projectKind: "git" | "non_git" | "directory";
|
||||
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
|
||||
readonly name: string;
|
||||
readonly title: string | null;
|
||||
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
|
||||
readonly statusEnteredAt: string | null;
|
||||
readonly archivingAt: string | null;
|
||||
readonly diffStat: { readonly additions: number; readonly deletions: number } | null;
|
||||
}
|
||||
|
||||
export interface PluginAgentSnapshot {
|
||||
readonly id: string;
|
||||
readonly workspaceId: string;
|
||||
readonly provider: string;
|
||||
readonly status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly lastActivityAt: string;
|
||||
readonly title: string | null;
|
||||
readonly cwd: string;
|
||||
readonly model: string | null;
|
||||
readonly currentModeId: string | null;
|
||||
readonly thinkingOptionId: string | null;
|
||||
readonly requiresAttention: boolean;
|
||||
readonly attentionReason: "finished" | "error" | "permission" | null;
|
||||
readonly parentAgentId: string | null;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
|
||||
context: "workspace";
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface PluginAgentPanelProps extends PluginNavigableHostProps {
|
||||
context: "agent";
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface PluginComposerPillProps extends PluginHostProps {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface PluginComposerPillContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
Component: ComponentType<PluginComposerPillProps>;
|
||||
onPress(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export type PluginPanelLocation = "workspace" | "explorer";
|
||||
export interface PluginOpenPanelOptions { location?: PluginPanelLocation; }
|
||||
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export type PluginWorkspacePanelContribution =
|
||||
| { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
|
||||
| { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "agent"; Component: ComponentType<PluginAgentPanelProps> };
|
||||
|
||||
export interface PluginSidebarContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export interface PluginThemeColors {
|
||||
background: string;
|
||||
foreground: string;
|
||||
raised: string;
|
||||
control: string;
|
||||
border: string;
|
||||
accent?: string;
|
||||
mutedForeground: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
export interface PluginThemeContribution {
|
||||
id: string;
|
||||
name: string;
|
||||
appearance: "light" | "dark";
|
||||
colors: PluginThemeColors;
|
||||
}
|
||||
|
||||
export interface PluginSurfaceContribution {
|
||||
id: string;
|
||||
Component: ComponentType<PluginSurfaceProps>;
|
||||
}
|
||||
|
||||
export type PluginTimelineData = null | boolean | number | string | PluginTimelineData[] | { [key: string]: PluginTimelineData };
|
||||
export interface PluginTimelineItem { type: "plugin"; kind: string; version: number; data: PluginTimelineData; }
|
||||
export interface PluginTimelineTransformResult { items: PluginTimelineItem[]; }
|
||||
export type PluginTimelineTransformerContribution<ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"]> =
|
||||
ItemType extends AgentTimelineItem["type"]
|
||||
? {
|
||||
id: string;
|
||||
query: { itemType: ItemType };
|
||||
transform(input: { item: Extract<AgentTimelineItem, { type: ItemType }> }): PluginTimelineTransformResult | undefined;
|
||||
}
|
||||
: never;
|
||||
export interface PluginTimelineItemProps<Data = unknown> extends PluginHostProps {
|
||||
agentId: string;
|
||||
item: { type: "plugin"; kind: string; version: number; data: Data };
|
||||
timestamp: Date;
|
||||
}
|
||||
export interface PluginTimelineRendererContribution<Schema extends ZodType = ZodType> {
|
||||
kind: string;
|
||||
version: number;
|
||||
schema: Schema;
|
||||
Component: ComponentType<PluginTimelineItemProps<ZodOutput<Schema>>>;
|
||||
}
|
||||
|
||||
export interface PluginCommandCapabilities {
|
||||
paseo: PaseoApi;
|
||||
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
input: ZodInput<InputSchema>,
|
||||
): Promise<ZodOutput<OutputSchema>>;
|
||||
openSurface(id: string): void;
|
||||
}
|
||||
|
||||
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
|
||||
context: "global";
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
|
||||
context: "workspace";
|
||||
workspace: PluginWorkspaceSnapshot;
|
||||
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
||||
}
|
||||
|
||||
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
|
||||
context: "agent";
|
||||
workspace: PluginWorkspaceSnapshot;
|
||||
agent: PluginAgentSnapshot;
|
||||
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
||||
}
|
||||
|
||||
export interface PluginClientContext extends PluginCommandCapabilities {
|
||||
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
|
||||
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
|
||||
}
|
||||
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
|
||||
|
||||
export type PluginCommandCenterItemContribution =
|
||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise<void> }
|
||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
|
||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> };
|
||||
|
||||
export interface PluginContext {
|
||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
handler: (
|
||||
input: ZodOutput<InputSchema>,
|
||||
context: PluginHandlerContext,
|
||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
||||
): void;
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||
addClientSide(contribution: PluginClientContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
addTheme(contribution: PluginThemeContribution): void;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(contribution: PluginTimelineTransformerContribution<ItemType>): void;
|
||||
addTimelineRenderer<Schema extends ZodType>(contribution: PluginTimelineRendererContribution<Schema>): void;
|
||||
}
|
||||
|
||||
export type PluginCleanup = () => void | Promise<void>;
|
||||
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
|
||||
|
||||
export const Icon: ComponentType<PluginIconProps>;
|
||||
|
||||
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
|
||||
|
||||
export function usePaseo(): PaseoApi;
|
||||
|
||||
export function useWorkspace<Selection>(
|
||||
workspaceId: string,
|
||||
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
|
||||
): Selection | null;
|
||||
|
||||
export function useAgent<Selection>(
|
||||
agentId: string,
|
||||
selector: (agent: PluginAgentSnapshot) => Selection,
|
||||
): Selection | null;
|
||||
}
|
||||
`;
|
||||
|
||||
const TSCONFIG = {
|
||||
compilerOptions: {
|
||||
@@ -75,7 +432,8 @@ export async function scaffoldPluginDirectory(
|
||||
version: "0.0.0",
|
||||
scripts: { typecheck: "tsc --noEmit" },
|
||||
devDependencies: {
|
||||
"@getpaseo/plugin": resolveCliVersion(),
|
||||
"@getpaseo/client": "^0.4.0",
|
||||
"@getpaseo/protocol": "^0.6.1",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@types/react": "~19.2.0",
|
||||
react: "19.1.0",
|
||||
@@ -88,6 +446,7 @@ export async function scaffoldPluginDirectory(
|
||||
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
|
||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||
["paseo-plugin.d.ts", SDK_DECLARATIONS],
|
||||
["index.ts", ENTRY],
|
||||
["main.client.tsx", CLIENT_SURFACE],
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/relay": "0.7.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1988,49 +1988,6 @@ test("file context action RPCs correlate success and error responses", async ()
|
||||
});
|
||||
});
|
||||
|
||||
test("serializes plugin source suffixes through the legacy path field", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_plugin_source",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const installPromise = client.installPluginSource({
|
||||
source: "owner/repository:plugins/review",
|
||||
});
|
||||
const request = parseSentFrame(mock.sent.at(-1));
|
||||
expect(request).toEqual({
|
||||
type: "plugin.source.install.request",
|
||||
requestId: expect.any(String),
|
||||
source: "owner/repository",
|
||||
pluginPath: "plugins/review",
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "plugin.source.install.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
plugin: {
|
||||
id: "review",
|
||||
path: "/plugins/review",
|
||||
enabled: true,
|
||||
status: "running",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(installPromise).resolves.toMatchObject({ id: "review", status: "running" });
|
||||
});
|
||||
|
||||
test("a connection loss rejects an in-flight file context action", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { z } from "zod";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import {
|
||||
AgentCreateFailedStatusPayloadSchema,
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
@@ -4972,19 +4971,12 @@ export class DaemonClient {
|
||||
source: string;
|
||||
id?: string;
|
||||
ref?: string;
|
||||
pluginPath?: string;
|
||||
}): Promise<PluginListItem> {
|
||||
const requestId = this.createRequestId();
|
||||
const reference = parsePluginSourceReference(input.source);
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "plugin.source.install.request",
|
||||
requestId,
|
||||
source: reference.source,
|
||||
...(reference.pluginPath ? { pluginPath: reference.pluginPath } : {}),
|
||||
...(input.id ? { id: input.id } : {}),
|
||||
...(input.ref ? { ref: input.ref } : {}),
|
||||
},
|
||||
message: { type: "plugin.source.install.request", requestId, ...input },
|
||||
responseType: "plugin.source.install.response",
|
||||
});
|
||||
return payload.plugin;
|
||||
|
||||
@@ -232,20 +232,6 @@ async function callBrowserTool(client, name, args = {}) {
|
||||
return mcpPayload(await client.callTool({ name, args }), name);
|
||||
}
|
||||
|
||||
async function callBrowserToolUntilReady(client, name, args = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await client.callTool({ name, args });
|
||||
const payload = result.structuredContent;
|
||||
if (payload?.ok === true) return payload.result;
|
||||
if (payload?.ok !== false || payload.error?.retryable !== true) {
|
||||
return mcpPayload(result, name);
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error(`${name} remained unavailable for ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForGuestSelector(client, browserId) {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -582,7 +568,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
|
||||
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
||||
await page.waitForTimeout(500);
|
||||
try {
|
||||
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
|
||||
await callBrowserTool(client, "browser_screenshot", { browserId });
|
||||
} catch (error) {
|
||||
failures.push(`inactive browser remains captureable: ${String(error)}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -44,8 +44,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -54,8 +54,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -53,15 +53,8 @@ describe("plugin protocol compatibility", () => {
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "plugin.source.install.request",
|
||||
requestId: "request-install",
|
||||
source: "owner/repository:plugins/review",
|
||||
ref: "main",
|
||||
}).type,
|
||||
).toBe("plugin.source.install.request");
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "plugin.source.install.request",
|
||||
requestId: "request-install-old-client",
|
||||
source: "owner/repository",
|
||||
ref: "main",
|
||||
pluginPath: "plugins/review",
|
||||
}).type,
|
||||
).toBe("plugin.source.install.request");
|
||||
|
||||
@@ -1465,7 +1465,6 @@ export const PluginSourceInstallRequestSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
id: PluginIdSchema.optional(),
|
||||
ref: z.string().min(1).optional(),
|
||||
// COMPAT(plugin-source-path): accepted for v0.7 clients; remove after 2027-09-01.
|
||||
pluginPath: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePluginSourceReference } from "./plugin-source-reference.js";
|
||||
|
||||
describe("plugin source references", () => {
|
||||
it.each([
|
||||
["owner/repository", "owner/repository", undefined],
|
||||
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
|
||||
[
|
||||
"https://example.test:8443/owner/repository.git",
|
||||
"https://example.test:8443/owner/repository.git",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"https://example.test:8443/owner/repository.git:plugins/review",
|
||||
"https://example.test:8443/owner/repository.git",
|
||||
"plugins/review",
|
||||
],
|
||||
["git@example.test:owner/repository.git", "git@example.test:owner/repository.git", undefined],
|
||||
[
|
||||
"git@example.test:owner/repository.git:plugins/review",
|
||||
"git@example.test:owner/repository.git",
|
||||
"plugins/review",
|
||||
],
|
||||
["file:///D:/plugins/repository", "file:///D:/plugins/repository", undefined],
|
||||
[
|
||||
"file:///D:/plugins/repository:plugins/review",
|
||||
"file:///D:/plugins/repository",
|
||||
"plugins/review",
|
||||
],
|
||||
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
|
||||
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
|
||||
])("parses %s", (reference, source, pluginPath) => {
|
||||
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
export interface PluginSourceReference {
|
||||
source: string;
|
||||
pluginPath: string | undefined;
|
||||
}
|
||||
|
||||
export function parsePluginSourceReference(reference: string): PluginSourceReference {
|
||||
const separator = reference.lastIndexOf(":");
|
||||
if (separator === -1) return { source: reference, pluginPath: undefined };
|
||||
|
||||
const pluginPath = reference.slice(separator + 1);
|
||||
if (!isPortableRelativePluginPath(pluginPath)) {
|
||||
return { source: reference, pluginPath: undefined };
|
||||
}
|
||||
|
||||
const scheme = reference.indexOf("://");
|
||||
if (scheme !== -1) {
|
||||
const pathStart = reference.indexOf("/", scheme + 3);
|
||||
if (pathStart === -1 || separator < pathStart) {
|
||||
return { source: reference, pluginPath: undefined };
|
||||
}
|
||||
} else {
|
||||
const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length;
|
||||
if (scpSeparator !== undefined && separator === scpSeparator - 1) {
|
||||
return { source: reference, pluginPath: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return { source: reference.slice(0, separator), pluginPath };
|
||||
}
|
||||
|
||||
export function formatPluginSourceReference(
|
||||
source: string,
|
||||
pluginPath: string | undefined,
|
||||
): string {
|
||||
if (pluginPath === undefined) return source;
|
||||
if (!isPortableRelativePluginPath(pluginPath)) {
|
||||
throw new Error("Plugin path must be a portable path relative to the repository");
|
||||
}
|
||||
return `${source}:${pluginPath}`;
|
||||
}
|
||||
|
||||
function isPortableRelativePluginPath(pluginPath: string): boolean {
|
||||
if (pluginPath === ".") return true;
|
||||
if (!pluginPath || pluginPath.startsWith("/") || pluginPath.startsWith("\\")) return false;
|
||||
if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false;
|
||||
return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== "..");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -72,11 +72,11 @@
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/highlight": "0.7.0",
|
||||
"@getpaseo/plugin": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/highlight": "0.7.1",
|
||||
"@getpaseo/plugin": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/relay": "0.7.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -425,6 +425,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
@@ -495,6 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
};
|
||||
|
||||
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||
|
||||
@@ -45,12 +45,19 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
supportsThinkingDisabled: true,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5-1",
|
||||
label: "Fable 5.1",
|
||||
description: "Fable 5.1 · Most powerful model",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5",
|
||||
// COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support.
|
||||
aliases: ["claude-fable-5[1m]"],
|
||||
label: "Fable 5",
|
||||
description: "Fable 5 · Most powerful model",
|
||||
description: "Fable 5 · Previous release",
|
||||
minimumClaudeCodeVersion: "2.1.169",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
@@ -326,7 +333,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
|
||||
}
|
||||
|
||||
const runtimeMatch = trimmed.match(
|
||||
/^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
||||
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
||||
);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
@@ -371,7 +378,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
}
|
||||
|
||||
const runtimeMatch = trimmed.match(
|
||||
/claude[-_ ](opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
@@ -417,6 +424,14 @@ function normalizeMajorMinorClaudeModelId(
|
||||
): string | null {
|
||||
const family = familyValue.toLowerCase();
|
||||
const suffix = hasOneMillionContext ? "[1m]" : "";
|
||||
const candidate = `claude-${family}-${major}-${minor}${suffix}`;
|
||||
return isClaudeManifestModelId(candidate) ? candidate : null;
|
||||
const candidates = [
|
||||
`claude-${family}-${major}-${minor}${suffix}`,
|
||||
`claude-${family}-${major}-${minor}`,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (isClaudeManifestModelId(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("getClaudeModels", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
|
||||
expect(contextWindows).toEqual(
|
||||
new Map([
|
||||
["claude-opus-5", 1_000_000],
|
||||
["claude-fable-5-1", 1_000_000],
|
||||
["claude-fable-5", 1_000_000],
|
||||
["claude-fable-5[1m]", 1_000_000],
|
||||
["claude-opus-4-8[1m]", 1_000_000],
|
||||
@@ -160,6 +162,9 @@ describe("getClaudeModels", () => {
|
||||
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
|
||||
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
||||
);
|
||||
expect(
|
||||
models.get("claude-fable-5-1")?.thinkingOptions?.map((option) => option.id),
|
||||
).not.toContain(CLAUDE_DISABLED_THINKING_OPTION_ID);
|
||||
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -170,6 +175,7 @@ describe("getClaudeModels", () => {
|
||||
["claude-sonnet-5[1m]", true, "high"],
|
||||
["claude-sonnet-5-20260101", true, "high"],
|
||||
["claude-fable-5", false, "high"],
|
||||
["claude-fable-5-1", false, "high"],
|
||||
["claude-haiku-4-5", false, undefined],
|
||||
["openrouter/anthropic/claude-opus-4-8", false, undefined],
|
||||
[null, false, undefined],
|
||||
@@ -458,7 +464,7 @@ describe("Claude Opus 5 catalog", () => {
|
||||
describe("Claude Fable 5 catalog", () => {
|
||||
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
|
||||
const fable5Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-fable-5"))
|
||||
.filter((model) => model.id === "claude-fable-5" || model.id === "claude-fable-5[1m]")
|
||||
.map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
|
||||
id,
|
||||
aliases,
|
||||
@@ -491,6 +497,24 @@ describe("Claude Fable 5 catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Fable 5.1 catalog", () => {
|
||||
it("offers one Fable 5.1 entry with a 1M context window", () => {
|
||||
const fable51Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-fable-5-1"))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(fable51Models).toEqual([
|
||||
{ id: "claude-fable-5-1", label: "Fable 5.1", contextWindowMaxTokens: 1_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves suffixed and dated Fable 5.1 IDs to the catalog entry", () => {
|
||||
expect(findClaudeModel("claude-fable-5-1[1m]")?.id).toBe("claude-fable-5-1");
|
||||
expect(findClaudeModel("claude-fable-5-1-20260901")?.id).toBe("claude-fable-5-1");
|
||||
expect(findClaudeModel("claude-fable-5-1-20260901[1m]")?.id).toBe("claude-fable-5-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
@@ -502,5 +526,6 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,10 +104,6 @@ interface CollaborationModeRecord {
|
||||
}
|
||||
|
||||
interface CodexSessionTestAccess {
|
||||
codexUserMessageTurns(): {
|
||||
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
||||
count(): number;
|
||||
};
|
||||
ensureThreadLoaded(): Promise<void>;
|
||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||
handleNotification(method: string, params: unknown): void;
|
||||
@@ -503,14 +499,13 @@ function markdownImageSource(markdown: string): string {
|
||||
|
||||
function emitCodexUserMessage(
|
||||
appServer: FakeCodexAppServer,
|
||||
input: { id: string; text: string; threadId?: string; turnId?: string },
|
||||
input: { id: string; text: string; threadId?: string },
|
||||
): void {
|
||||
appServer.child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: input.threadId ?? "thread-1",
|
||||
...(input.turnId ? { turnId: input.turnId } : {}),
|
||||
item: {
|
||||
type: "userMessage",
|
||||
id: input.id,
|
||||
@@ -1674,62 +1669,6 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rewinds a paginated conversation through the public session capability", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
"thread/rollback": () => {
|
||||
throw new Error("paginated threads do not support thread/rollback");
|
||||
},
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
await session.startTurn("remember first");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-first",
|
||||
text: "remember first",
|
||||
turnId: "turn-first",
|
||||
});
|
||||
appServer.completeTurn();
|
||||
await session.startTurn("remember second");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-second",
|
||||
text: "remember second",
|
||||
turnId: "turn-second",
|
||||
});
|
||||
appServer.completeTurn();
|
||||
|
||||
await session.revertConversation({ messageId: "codex-first" });
|
||||
|
||||
const forkRequests = appServer
|
||||
.requests()
|
||||
.filter((request) => request.method === "thread/fork")
|
||||
.map((request) => request.params);
|
||||
expect(forkRequests).toEqual([
|
||||
{
|
||||
threadId: "thread-1",
|
||||
beforeTurnId: "turn-first",
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
expect(appServer.recordedRollbacks).toEqual([]);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
sessionId: "forked-thread",
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("correlates a Codex user message with the submitting client message", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
@@ -3878,35 +3817,6 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("retains native turn ids from persisted user messages", async () => {
|
||||
const session = createSession();
|
||||
session.client = {
|
||||
request: vi.fn(async () => ({
|
||||
thread: {
|
||||
turns: [
|
||||
{
|
||||
id: "native-turn-1",
|
||||
items: [
|
||||
{
|
||||
type: "userMessage",
|
||||
id: "message-history",
|
||||
content: [{ type: "text", text: "History prompt" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory();
|
||||
|
||||
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
|
||||
index: 0,
|
||||
turnId: "native-turn-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
||||
const session = createSession();
|
||||
session.client = {
|
||||
|
||||
@@ -429,7 +429,6 @@ interface CodexConfiguredDefaults {
|
||||
interface PersistedTimelineEntry {
|
||||
item: AgentTimelineItem;
|
||||
timestamp?: string;
|
||||
providerTurnId?: string;
|
||||
}
|
||||
|
||||
interface PersistedSubAgentRoute {
|
||||
@@ -1991,9 +1990,6 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
timeline.push({
|
||||
item: settledTimelineItem,
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && typeof turn.id === "string"
|
||||
? { providerTurnId: turn.id }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
@@ -2166,7 +2162,6 @@ const ItemTextDeltaNotificationSchema = z
|
||||
const ItemLifecycleNotificationSchema = z
|
||||
.object({
|
||||
threadId: z.string().optional(),
|
||||
turnId: z.string().optional(),
|
||||
item: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
@@ -2188,11 +2183,6 @@ const CodexEventThreadIdFields = {
|
||||
thread_id: z.string().optional(),
|
||||
};
|
||||
|
||||
const CodexEventTurnIdFields = {
|
||||
turnId: z.string().optional(),
|
||||
turn_id: z.string().optional(),
|
||||
};
|
||||
|
||||
function getCodexEventThreadId(params: {
|
||||
threadId?: string;
|
||||
thread_id?: string;
|
||||
@@ -2201,14 +2191,6 @@ function getCodexEventThreadId(params: {
|
||||
return params.threadId ?? params.thread_id ?? params.msg.threadId ?? params.msg.thread_id ?? null;
|
||||
}
|
||||
|
||||
function getCodexEventTurnId(params: {
|
||||
turnId?: string;
|
||||
turn_id?: string;
|
||||
msg: { turnId?: string; turn_id?: string };
|
||||
}): string | null {
|
||||
return params.turnId ?? params.turn_id ?? params.msg.turnId ?? params.msg.turn_id ?? null;
|
||||
}
|
||||
|
||||
const CodexEventTurnAbortedNotificationSchema = z
|
||||
.object({
|
||||
...CodexEventThreadIdFields,
|
||||
@@ -2237,11 +2219,9 @@ const CodexEventTaskCompleteNotificationSchema = z
|
||||
const CodexEventItemLifecycleNotificationSchema = z
|
||||
.object({
|
||||
...CodexEventThreadIdFields,
|
||||
...CodexEventTurnIdFields,
|
||||
msg: z
|
||||
.object({
|
||||
...CodexEventThreadIdFields,
|
||||
...CodexEventTurnIdFields,
|
||||
type: z.enum(["item_started", "item_completed"]),
|
||||
item: z
|
||||
.object({
|
||||
@@ -2422,14 +2402,12 @@ type ParsedCodexNotification =
|
||||
kind: "item_completed";
|
||||
source: "item" | "codex_event";
|
||||
threadId: string | null;
|
||||
turnId: string | null;
|
||||
item: { id?: string; type?: string; [key: string]: unknown };
|
||||
}
|
||||
| {
|
||||
kind: "item_started";
|
||||
source: "item" | "codex_event";
|
||||
threadId: string | null;
|
||||
turnId: string | null;
|
||||
item: { id?: string; type?: string; [key: string]: unknown };
|
||||
}
|
||||
| {
|
||||
@@ -2686,7 +2664,6 @@ const CodexNotificationSchema = z.union([
|
||||
kind: "item_completed",
|
||||
source: "item",
|
||||
threadId: params.threadId ?? null,
|
||||
turnId: params.turnId ?? null,
|
||||
item: params.item,
|
||||
}),
|
||||
),
|
||||
@@ -2704,7 +2681,6 @@ const CodexNotificationSchema = z.union([
|
||||
kind: "item_started",
|
||||
source: "item",
|
||||
threadId: params.threadId ?? null,
|
||||
turnId: params.turnId ?? null,
|
||||
item: params.item,
|
||||
}),
|
||||
),
|
||||
@@ -2725,7 +2701,6 @@ const CodexNotificationSchema = z.union([
|
||||
kind: "item_started",
|
||||
source: "codex_event",
|
||||
threadId: getCodexEventThreadId(params),
|
||||
turnId: getCodexEventTurnId(params),
|
||||
item: params.msg.item,
|
||||
}),
|
||||
),
|
||||
@@ -2746,7 +2721,6 @@ const CodexNotificationSchema = z.union([
|
||||
kind: "item_completed",
|
||||
source: "codex_event",
|
||||
threadId: getCodexEventThreadId(params),
|
||||
turnId: getCodexEventTurnId(params),
|
||||
item: params.msg.item,
|
||||
}),
|
||||
),
|
||||
@@ -3343,7 +3317,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||
private readonly userMessageTurnIndexes = new Map<string, number>();
|
||||
private readonly userMessageTurnIds: string[] = [];
|
||||
private readonly userMessageProviderTurnIds = new Map<string, string>();
|
||||
private pendingManualCompactionStarts = 0;
|
||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||
private pendingRootCompactionItemIds = new Set<string>();
|
||||
@@ -3788,7 +3761,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.resetCodexUserMessageTurns();
|
||||
for (const entry of timeline) {
|
||||
if (entry.item.type === "user_message") {
|
||||
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
|
||||
this.rememberCodexUserMessageTurn(entry.item.messageId);
|
||||
}
|
||||
}
|
||||
this.persistedHistory = timeline;
|
||||
@@ -4277,42 +4250,28 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private rememberCodexUserMessageTurn(
|
||||
messageId: string | null | undefined,
|
||||
providerTurnId?: string | null,
|
||||
): boolean {
|
||||
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
|
||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.userMessageTurnIndexes.has(messageId)) {
|
||||
if (providerTurnId) {
|
||||
this.userMessageProviderTurnIds.set(messageId, providerTurnId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.userMessageTurnIndexes.set(messageId, this.userMessageTurnIds.length);
|
||||
this.userMessageTurnIds.push(messageId);
|
||||
if (providerTurnId) {
|
||||
this.userMessageProviderTurnIds.set(messageId, providerTurnId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private resetCodexUserMessageTurns(): void {
|
||||
this.userMessageTurnIndexes.clear();
|
||||
this.userMessageTurnIds.length = 0;
|
||||
this.userMessageProviderTurnIds.clear();
|
||||
}
|
||||
|
||||
private truncateCodexUserMessageTurns(numTurns: number): void {
|
||||
if (numTurns <= 0) {
|
||||
return;
|
||||
}
|
||||
const retainedCount = Math.max(0, this.userMessageTurnIds.length - numTurns);
|
||||
const removedMessageIds = this.userMessageTurnIds.splice(retainedCount);
|
||||
for (const messageId of removedMessageIds) {
|
||||
this.userMessageProviderTurnIds.delete(messageId);
|
||||
}
|
||||
this.userMessageTurnIds.length = Math.max(0, this.userMessageTurnIds.length - numTurns);
|
||||
this.userMessageTurnIndexes.clear();
|
||||
this.userMessageTurnIds.forEach((messageId, index) => {
|
||||
this.userMessageTurnIndexes.set(messageId, index);
|
||||
@@ -4321,12 +4280,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
|
||||
return {
|
||||
resolve: (messageId) => {
|
||||
const index = this.userMessageTurnIndexes.get(messageId);
|
||||
return index === undefined
|
||||
? null
|
||||
: { index, turnId: this.userMessageProviderTurnIds.get(messageId) ?? null };
|
||||
},
|
||||
resolve: (messageId) => this.userMessageTurnIndexes.get(messageId) ?? null,
|
||||
count: () => this.userMessageTurnIds.length,
|
||||
};
|
||||
}
|
||||
@@ -6488,7 +6442,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId, parsed.turnId)) {
|
||||
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) {
|
||||
return;
|
||||
}
|
||||
const clientMessageId = timelineItem.clientMessageId ?? this.activeClientMessageId;
|
||||
|
||||
@@ -50,7 +50,6 @@ type UnexpectedTerminationHandler = (error: Error) => void;
|
||||
|
||||
export interface CodexThreadForkParams {
|
||||
threadId: string;
|
||||
beforeTurnId?: string | null;
|
||||
path?: string | null;
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
|
||||
@@ -51,25 +51,16 @@ class FakeCodex implements CodexRewindClient {
|
||||
};
|
||||
}
|
||||
|
||||
request(method: string): Promise<unknown> {
|
||||
if (method === "thread/read") {
|
||||
return Promise.resolve({ thread: { id: "source-thread", historyMode: "legacy" } });
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
request(): Promise<unknown> {
|
||||
throw new Error("FakeCodex uses typed thread methods");
|
||||
}
|
||||
}
|
||||
|
||||
class CodexMessageTurns implements CodexUserMessageTurnIndex {
|
||||
constructor(
|
||||
private readonly indexesByMessageId: Map<string, number>,
|
||||
private readonly turnIdsByMessageId: Map<string, string> = new Map(),
|
||||
) {}
|
||||
constructor(private readonly indexesByMessageId: Map<string, number>) {}
|
||||
|
||||
resolve(messageId: string): { index: number; turnId: string | null } | null {
|
||||
const index = this.indexesByMessageId.get(messageId);
|
||||
return index === undefined
|
||||
? null
|
||||
: { index, turnId: this.turnIdsByMessageId.get(messageId) ?? null };
|
||||
resolve(messageId: string): number | null {
|
||||
return this.indexesByMessageId.get(messageId) ?? null;
|
||||
}
|
||||
|
||||
count(): number {
|
||||
@@ -140,95 +131,6 @@ describe("Codex Rewind", () => {
|
||||
expect(reboundThreadId).toBe("forked-thread");
|
||||
});
|
||||
|
||||
test("rewinds a paginated conversation with one bounded fork and no rollback", async () => {
|
||||
class PaginatedCodex extends FakeCodex {
|
||||
override request(method: string): Promise<unknown> {
|
||||
if (method === "thread/read") {
|
||||
return Promise.resolve({ thread: { id: "source-thread", historyMode: "paginated" } });
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
|
||||
override rollbackThread(): Promise<CodexThreadRollbackResponse> {
|
||||
throw new Error("paginated threads do not support thread/rollback");
|
||||
}
|
||||
}
|
||||
|
||||
const codex = new PaginatedCodex();
|
||||
const userMessageTurns = new CodexMessageTurns(
|
||||
new Map([
|
||||
["codex-first", 0],
|
||||
["codex-second", 1],
|
||||
]),
|
||||
new Map([
|
||||
["codex-first", "turn-first"],
|
||||
["codex-second", "turn-second"],
|
||||
]),
|
||||
);
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-first",
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessageTurns,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(codex.recordedForks).toEqual([
|
||||
{
|
||||
threadId: "source-thread",
|
||||
beforeTurnId: "turn-first",
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
expect(codex.recordedRollbacks).toEqual([]);
|
||||
expect(reboundThreadId).toBe("forked-thread");
|
||||
});
|
||||
|
||||
test("does not fork a paginated thread when the target turn id is unavailable", async () => {
|
||||
class PaginatedCodex extends FakeCodex {
|
||||
override request(method: string): Promise<unknown> {
|
||||
if (method === "thread/read") {
|
||||
return Promise.resolve({ thread: { id: "source-thread", historyMode: "paginated" } });
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const codex = new PaginatedCodex();
|
||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await expect(
|
||||
revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-first",
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessageTurns,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Codex could not find the turn containing user message codex-first");
|
||||
|
||||
expect(codex.recordedForks).toEqual([]);
|
||||
expect(codex.recordedRollbacks).toEqual([]);
|
||||
expect(reboundThreadId).toBeNull();
|
||||
});
|
||||
|
||||
test("declines to rewind when the user message is not in the Codex thread", async () => {
|
||||
const codex = new FakeCodex();
|
||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
||||
|
||||
@@ -16,30 +16,10 @@ export interface CodexRewindClient {
|
||||
}
|
||||
|
||||
export interface CodexUserMessageTurnIndex {
|
||||
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
||||
resolve(messageId: string): number | null;
|
||||
count(): number;
|
||||
}
|
||||
|
||||
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
async function readCodexThreadHistoryMode(
|
||||
client: CodexRewindClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = await client.request("thread/read", { threadId, includeTurns: false });
|
||||
if (typeof response !== "object" || response === null || !("thread" in response)) {
|
||||
throw new Error("Codex thread/read did not return thread metadata");
|
||||
}
|
||||
const thread = response.thread;
|
||||
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
|
||||
return "legacy";
|
||||
}
|
||||
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
|
||||
return thread.historyMode;
|
||||
}
|
||||
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
|
||||
}
|
||||
|
||||
async function forkCodexThread(
|
||||
client: CodexRewindClient,
|
||||
params: CodexThreadForkParams,
|
||||
@@ -74,35 +54,17 @@ export async function revertCodexConversation(input: {
|
||||
throw new Error("Codex thread is not ready for rewind");
|
||||
}
|
||||
|
||||
const targetTurn = input.userMessageTurns.resolve(input.messageId);
|
||||
if (targetTurn === null) {
|
||||
const targetTurnIndex = input.userMessageTurns.resolve(input.messageId);
|
||||
if (targetTurnIndex === null) {
|
||||
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
|
||||
}
|
||||
|
||||
const currentUserTurnCount = input.userMessageTurns.count();
|
||||
const numTurns = currentUserTurnCount - targetTurn.index;
|
||||
const numTurns = currentUserTurnCount - targetTurnIndex;
|
||||
if (numTurns < 0) {
|
||||
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
||||
}
|
||||
|
||||
const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId);
|
||||
if (historyMode === "paginated") {
|
||||
if (!targetTurn.turnId) {
|
||||
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
|
||||
}
|
||||
const forked = await forkCodexThread(input.client, {
|
||||
threadId: input.threadId,
|
||||
beforeTurnId: targetTurn.turnId,
|
||||
cwd: input.cwd ?? null,
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
await input.setThreadId(forked.thread.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fork is non-destructive: the old thread file stays on disk and remains
|
||||
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
||||
const forked = await forkCodexThread(input.client, {
|
||||
|
||||
@@ -7,15 +7,11 @@ import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderParams } from "./provider-config.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
|
||||
test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => {
|
||||
expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({
|
||||
readyTimeoutMs: 20_000,
|
||||
rpcTimeoutMs: 60_000,
|
||||
});
|
||||
expect(resolveOmpProviderParams({ rpcTimeoutMs: 90_000 }).runtimeProviderParams).toMatchObject({
|
||||
readyTimeoutMs: 90_000,
|
||||
rpcTimeoutMs: 90_000,
|
||||
});
|
||||
test("OMP RPC timeout defaults to 60 seconds and accepts an override", () => {
|
||||
expect(resolveOmpProviderParams({}).runtimeProviderParams.rpcTimeoutMs).toBe(60_000);
|
||||
expect(
|
||||
resolveOmpProviderParams({ rpcTimeoutMs: 90_000 }).runtimeProviderParams.rpcTimeoutMs,
|
||||
).toBe(90_000);
|
||||
});
|
||||
|
||||
class ManualIdleScheduler implements OmpProviderIdleScheduler {
|
||||
|
||||
@@ -828,15 +828,14 @@ function buildExtensionUiResponse(
|
||||
function createRuntime(
|
||||
logger: Logger,
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
providerParams: OmpRuntimeProviderParams,
|
||||
requestTimeoutMs: number,
|
||||
): OmpRuntime {
|
||||
return new OmpCliRuntime({
|
||||
logger,
|
||||
runtimeSettings,
|
||||
command: ["omp"],
|
||||
commandsRpcName: "get_available_commands",
|
||||
readyTimeoutMs: providerParams.readyTimeoutMs,
|
||||
requestTimeoutMs: providerParams.rpcTimeoutMs,
|
||||
requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2217,7 +2216,8 @@ export class OmpAgentClient implements AgentClient {
|
||||
this.noTurnScheduler = options.noTurnScheduler;
|
||||
this.usagePollScheduler = options.usagePollScheduler;
|
||||
this.runtime =
|
||||
options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams);
|
||||
options.runtime ??
|
||||
createRuntime(options.logger, runtimeSettings, this.providerParams.rpcTimeoutMs);
|
||||
}
|
||||
|
||||
private async configureNativePaseoTools(
|
||||
|
||||
@@ -47,7 +47,6 @@ export interface OmpCliRuntimeOptions {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
command?: [string, ...string[]];
|
||||
commandsRpcName?: "get_available_commands";
|
||||
readyTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
}
|
||||
@@ -88,10 +87,7 @@ export class OmpCliRuntime implements OmpRuntime {
|
||||
const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined);
|
||||
input.signal?.addEventListener("abort", handleAbort, { once: true });
|
||||
try {
|
||||
await establishOmpProtocol(process, this.options.logger, {
|
||||
readyTimeoutMs: this.options.readyTimeoutMs,
|
||||
requestTimeoutMs: this.options.requestTimeoutMs,
|
||||
});
|
||||
await establishOmpProtocol(process, this.options.logger, this.options.requestTimeoutMs);
|
||||
input.signal?.throwIfAborted();
|
||||
return new OmpCliRuntimeSession(process, this.commandsRpcName);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { JsonlRpcExit } from "../jsonl-rpc-process.js";
|
||||
import { establishOmpProtocol, type OmpProtocolTransport } from "./protocol-session.js";
|
||||
|
||||
@@ -44,10 +44,6 @@ const V2_READY = {
|
||||
maxReassembledFrameBytes: 64 * 1024 * 1024,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("establishOmpProtocol", () => {
|
||||
it("owns readiness and v2 negotiation", async () => {
|
||||
const harness = transportHarness();
|
||||
@@ -63,43 +59,6 @@ describe("establishOmpProtocol", () => {
|
||||
expect(harness.exitSubscribed()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the configured RPC timeout while waiting for a cold OMP", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = transportHarness();
|
||||
const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" }), {
|
||||
readyTimeoutMs: 60_000,
|
||||
requestTimeoutMs: 60_000,
|
||||
});
|
||||
const outcome = negotiation.then(
|
||||
() => "resolved",
|
||||
() => "rejected",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(12_000);
|
||||
harness.emitReady(V2_READY);
|
||||
|
||||
await expect(outcome).resolves.toBe("resolved");
|
||||
});
|
||||
|
||||
it("waits 20 seconds for OMP to become ready", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = transportHarness();
|
||||
const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" }));
|
||||
const outcome = negotiation.then(
|
||||
() => "resolved",
|
||||
(error: unknown) =>
|
||||
error instanceof Error ? error.message : "rejected with a non-Error value",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(19_999);
|
||||
expect(harness.exitSubscribed()).toBe(true);
|
||||
expect(await Promise.race([outcome, Promise.resolve("pending")])).toBe("pending");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(outcome).resolves.toBe("Timed out waiting for OMP to become ready");
|
||||
expect(harness.exitSubscribed()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps protocol v1 when the ready frame has no matching capability", async () => {
|
||||
const harness = transportHarness();
|
||||
const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" }));
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type JsonlRpcExit,
|
||||
} from "../jsonl-rpc-process.js";
|
||||
|
||||
const OMP_READY_TIMEOUT_MS = 20_000;
|
||||
const OMP_READY_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface OmpProtocolTransport {
|
||||
onMessage(callback: (message: Record<string, unknown>) => void): () => void;
|
||||
@@ -13,19 +13,12 @@ export interface OmpProtocolTransport {
|
||||
request(command: Record<string, unknown>, timeoutMs: number | null): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface OmpProtocolTimeouts {
|
||||
readyTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function establishOmpProtocol(
|
||||
transport: OmpProtocolTransport,
|
||||
logger: Logger,
|
||||
timeouts: OmpProtocolTimeouts = {},
|
||||
requestTimeoutMs = JSONL_RPC_DEFAULT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const readyTimeoutMs = timeouts.readyTimeoutMs ?? OMP_READY_TIMEOUT_MS;
|
||||
const requestTimeoutMs = timeouts.requestTimeoutMs ?? JSONL_RPC_DEFAULT_TIMEOUT_MS;
|
||||
const ready = await waitForReady(transport, readyTimeoutMs);
|
||||
const ready = await waitForReady(transport);
|
||||
if (!supportsJsonlRpcProtocolV2(ready)) return;
|
||||
const response = (await transport.request(
|
||||
{ type: "negotiate_protocol", protocolVersion: 2 },
|
||||
@@ -35,17 +28,14 @@ export async function establishOmpProtocol(
|
||||
logger.debug({}, "Negotiated OMP RPC protocol v2 (chunked frame transport)");
|
||||
}
|
||||
|
||||
function waitForReady(
|
||||
transport: OmpProtocolTransport,
|
||||
requestTimeoutMs: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
function waitForReady(transport: OmpProtocolTransport): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let unsubscribeMessage = (): void => {};
|
||||
let unsubscribeExit = (): void => {};
|
||||
const timer = setTimeout(
|
||||
() => finish(new Error("Timed out waiting for OMP to become ready")),
|
||||
requestTimeoutMs,
|
||||
OMP_READY_TIMEOUT_MS,
|
||||
);
|
||||
const finish = (result: Record<string, unknown> | Error): void => {
|
||||
if (settled) return;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
|
||||
const OMP_SESSION_DIR = "~/.omp/agent/sessions";
|
||||
const DEFAULT_OMP_MODE_ID = "full";
|
||||
const DEFAULT_OMP_READY_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000;
|
||||
|
||||
export const MIN_SUPPORTED_OMP_VERSION = "16.3.9";
|
||||
@@ -17,7 +16,7 @@ export { OMP_MODES };
|
||||
export const OmpProviderParamsSchema = z
|
||||
.object({
|
||||
sessionDir: z.string().min(1).optional(),
|
||||
rpcTimeoutMs: z.number().int().positive().optional(),
|
||||
rpcTimeoutMs: z.number().int().positive().default(DEFAULT_OMP_RPC_TIMEOUT_MS),
|
||||
smolModel: z.string().min(1).optional(),
|
||||
slowModel: z.string().min(1).optional(),
|
||||
planModel: z.string().min(1).optional(),
|
||||
@@ -26,7 +25,6 @@ export const OmpProviderParamsSchema = z
|
||||
|
||||
export interface OmpRuntimeProviderParams {
|
||||
sessionDir: string;
|
||||
readyTimeoutMs: number;
|
||||
rpcTimeoutMs: number;
|
||||
}
|
||||
|
||||
@@ -134,12 +132,10 @@ export function resolveOmpProviderParams(providerParams: unknown): {
|
||||
modelRoleParams: OmpModelRoleParams;
|
||||
} {
|
||||
const params = OmpProviderParamsSchema.parse(providerParams ?? {});
|
||||
const configuredRpcTimeoutMs = params.rpcTimeoutMs;
|
||||
return {
|
||||
runtimeProviderParams: {
|
||||
sessionDir: params.sessionDir ?? OMP_SESSION_DIR,
|
||||
readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS,
|
||||
rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS,
|
||||
rpcTimeoutMs: params.rpcTimeoutMs,
|
||||
},
|
||||
modelRoleParams: {
|
||||
...(params.smolModel ? { smolModel: params.smolModel } : {}),
|
||||
|
||||
@@ -942,27 +942,6 @@ describe("PiRpcAgentSession", () => {
|
||||
).not.toContain("turn_failed");
|
||||
});
|
||||
|
||||
test("clears queued Pi messages before interrupting", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn("work");
|
||||
await session.interrupt();
|
||||
|
||||
expect(fakeSession.controlRequests).toEqual(["clear_queue", "abort"]);
|
||||
});
|
||||
|
||||
test("still interrupts when an older Pi binary lacks clear_queue", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.clearQueueError = new Error("Unknown command: clear_queue");
|
||||
|
||||
await session.startTurn("work");
|
||||
await session.interrupt();
|
||||
|
||||
expect(fakeSession.controlRequests).toEqual(["clear_queue", "abort"]);
|
||||
});
|
||||
|
||||
test("adds Pi assistant context to generic provider finish errors", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
@@ -1662,213 +1641,6 @@ describe("PiRpcAgentSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiRpcAgentSession steering", () => {
|
||||
test("steers the active Pi turn and correlates the echoed user message", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
const { turnId } = await session.startTurn("fix the tests", {
|
||||
clientMessageId: "client-prompt-1",
|
||||
});
|
||||
const result = await session.steerActiveTurn("steer this turn", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "accepted" });
|
||||
expect(fakeSession.steerCalls).toEqual([{ message: "steer this turn", imageCount: 0 }]);
|
||||
|
||||
fakeSession.finishSubmittedUserMessage({
|
||||
id: "entry-steer-1",
|
||||
parentId: null,
|
||||
text: "steer this turn",
|
||||
});
|
||||
|
||||
const userMessages = events.timelineItems().filter((item) => item.type === "user_message");
|
||||
expect(userMessages).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "steer this turn",
|
||||
messageId: "entry-steer-1",
|
||||
clientMessageId: "client-steer-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not reuse the foreground client ID for a steer without one", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("work", { clientMessageId: "client-prompt-1" });
|
||||
|
||||
await session.steerActiveTurn("steer without a client ID", { expectedTurnId: turnId });
|
||||
fakeSession.finishSubmittedUserMessage({
|
||||
id: "entry-steer-1",
|
||||
parentId: null,
|
||||
text: "steer without a client ID",
|
||||
});
|
||||
|
||||
expect(events.timelineItems()).toContainEqual({
|
||||
type: "user_message",
|
||||
text: "steer without a client ID",
|
||||
messageId: "entry-steer-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps multiple steers correlated in admission order", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("work");
|
||||
|
||||
await session.steerActiveTurn("steer one", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer-1",
|
||||
});
|
||||
await session.steerActiveTurn("steer two", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer-2",
|
||||
});
|
||||
|
||||
fakeSession.finishSubmittedUserMessage({ id: "entry-1", parentId: null, text: "steer one" });
|
||||
fakeSession.finishSubmittedUserMessage({ id: "entry-2", parentId: null, text: "steer two" });
|
||||
|
||||
const userMessages = events.timelineItems().filter((item) => item.type === "user_message");
|
||||
expect(userMessages).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "steer one",
|
||||
messageId: "entry-1",
|
||||
clientMessageId: "client-steer-1",
|
||||
},
|
||||
{
|
||||
type: "user_message",
|
||||
text: "steer two",
|
||||
messageId: "entry-2",
|
||||
clientMessageId: "client-steer-2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports unavailable when the expected turn is not the active one", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("run");
|
||||
|
||||
const stale = await session.steerActiveTurn("steer", {
|
||||
expectedTurnId: "turn-that-ended",
|
||||
});
|
||||
expect(stale).toEqual({ status: "unavailable" });
|
||||
|
||||
fakeSession.finishTurn();
|
||||
await flushTurnScheduling();
|
||||
const idle = await session.steerActiveTurn("steer", { expectedTurnId: turnId });
|
||||
expect(idle).toEqual({ status: "unavailable" });
|
||||
expect(fakeSession.steerCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps slash-command steers on the interrupt fallback", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("run");
|
||||
|
||||
const result = await session.steerActiveTurn("/model", { expectedTurnId: turnId });
|
||||
|
||||
expect(result).toEqual({ status: "unavailable" });
|
||||
expect(fakeSession.steerCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("falls back when the Pi binary lacks the steer RPC", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("run");
|
||||
fakeSession.steerError = new Error("Unknown command: steer");
|
||||
|
||||
const result = await session.steerActiveTurn("steer", { expectedTurnId: turnId });
|
||||
|
||||
expect(result).toEqual({ status: "unavailable" });
|
||||
expect(fakeSession.steerCalls).toEqual([{ message: "steer", imageCount: 0 }]);
|
||||
});
|
||||
|
||||
test("surfaces an ambiguous steer failure without interrupting", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("run");
|
||||
fakeSession.steerError = new Error("Pi RPC socket closed");
|
||||
|
||||
await expect(session.steerActiveTurn("steer", { expectedTurnId: turnId })).rejects.toThrow(
|
||||
"Pi RPC socket closed",
|
||||
);
|
||||
});
|
||||
|
||||
test("denies pending permissions that block an accepted steer", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("work");
|
||||
fakeSession.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "perm-1",
|
||||
method: "confirm",
|
||||
title: "Allow command?",
|
||||
});
|
||||
await events.nextPermissionRequest();
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
|
||||
const result = await session.steerActiveTurn("answer instead", {
|
||||
expectedTurnId: turnId,
|
||||
clearPendingPermissions: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "accepted" });
|
||||
expect(fakeSession.extensionUiResponses).toEqual([
|
||||
{ id: "perm-1", response: { cancelled: true } },
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves permissions open for a steer without the clearing flag", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const { turnId } = await session.startTurn("work");
|
||||
fakeSession.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "perm-1",
|
||||
method: "confirm",
|
||||
title: "Allow command?",
|
||||
});
|
||||
await events.nextPermissionRequest();
|
||||
|
||||
const result = await session.steerActiveTurn("answer instead", { expectedTurnId: turnId });
|
||||
|
||||
expect(result).toEqual({ status: "accepted" });
|
||||
expect(fakeSession.extensionUiResponses).toEqual([]);
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("drops pending steer correlation once the turn completes", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const first = await session.startTurn("work");
|
||||
await session.steerActiveTurn("stale steer", {
|
||||
expectedTurnId: first.turnId,
|
||||
clientMessageId: "client-steer-1",
|
||||
});
|
||||
fakeSession.finishTurn();
|
||||
await flushTurnScheduling();
|
||||
|
||||
const second = await session.startTurn("next");
|
||||
await session.steerActiveTurn("fresh steer", { expectedTurnId: second.turnId });
|
||||
fakeSession.finishSubmittedUserMessage({
|
||||
id: "entry-late",
|
||||
parentId: null,
|
||||
text: "stale steer",
|
||||
});
|
||||
|
||||
const userMessages = events.timelineItems().filter((item) => item.type === "user_message");
|
||||
expect(userMessages).toEqual([
|
||||
{ type: "user_message", text: "stale steer", messageId: "entry-late" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiRpcAgentClient", () => {
|
||||
test("lists JSONL persisted sessions from configured provider params", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
|
||||
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
type AgentSlashCommandKind,
|
||||
type AgentStreamEvent,
|
||||
type FetchCatalogOptions,
|
||||
type SteerActiveTurnOptions,
|
||||
type SteerResult,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
@@ -220,21 +218,6 @@ interface StartTurnResult {
|
||||
turnId: string;
|
||||
}
|
||||
|
||||
interface PiPendingSteerSubmission {
|
||||
text: string;
|
||||
clientMessageId: string | null;
|
||||
}
|
||||
|
||||
// COMPAT(piSteerFallback): added in v0.5.0, remove after 2027-03-01 once the pi floor
|
||||
// supports the steer RPC. Older binaries answer with "Unknown command: steer".
|
||||
function isPiDefinitiveSteerRejection(error: unknown): boolean {
|
||||
return toDiagnosticErrorMessage(error) === "Unknown command: steer";
|
||||
}
|
||||
|
||||
function isPiMissingClearQueueRpc(error: unknown): boolean {
|
||||
return toDiagnosticErrorMessage(error) === "Unknown command: clear_queue";
|
||||
}
|
||||
|
||||
interface PiRpcAgentSessionOptions {
|
||||
runtimeSession: PiRuntimeSession;
|
||||
config: AgentSessionConfig;
|
||||
@@ -1236,7 +1219,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = [];
|
||||
private activePromptRequestId: string | null = null;
|
||||
private readonly pendingPromptResults = new Map<string, boolean>();
|
||||
private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = [];
|
||||
private lastKnownThinkingOptionId: string | null;
|
||||
currentLeafOverrideId: string | null | undefined;
|
||||
private readonly capturedUserEntries: PiCapturedEntry[] = [];
|
||||
@@ -1329,7 +1311,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeTurnStartedEmitted = false;
|
||||
this.pendingSettledMessages = null;
|
||||
this.activePromptRequestId = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
this.activeNoTurnPromptText = payload.text;
|
||||
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
|
||||
@@ -1363,7 +1344,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeTurnStartedEmitted = false;
|
||||
this.pendingSettledMessages = null;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
if (isPiRequestAbortError(error)) {
|
||||
this.emit({
|
||||
@@ -1386,63 +1366,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
async steerActiveTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
// Pi rejects steer RPCs that are extension commands, so slash inputs keep the
|
||||
// interrupt-and-replace fallback where they can run directly.
|
||||
if (this.parseSlashCommandInput(payload.text)) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runtimeSession.steer(payload.text, payload.images);
|
||||
} catch (error) {
|
||||
if (isPiDefinitiveSteerRejection(error)) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// The steer is already queued inside pi; if the turn moved on meanwhile its fate
|
||||
// is ambiguous, so surface it instead of replacing the wrong turn.
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
this.pendingSteerSubmissions.push({
|
||||
text: payload.text,
|
||||
clientMessageId: options.clientMessageId ?? null,
|
||||
});
|
||||
if (options.clearPendingPermissions) {
|
||||
await this.clearPendingPermissionsForSteer();
|
||||
}
|
||||
return { status: "accepted" };
|
||||
}
|
||||
|
||||
private async clearPendingPermissionsForSteer(): Promise<void> {
|
||||
const requestIds = Array.from(this.pendingExtensionUiRequests.keys());
|
||||
for (const requestId of requestIds) {
|
||||
if (!this.pendingExtensionUiRequests.has(requestId)) continue;
|
||||
await this.respondToPermission(requestId, {
|
||||
behavior: "deny",
|
||||
message: "The user answered with a message instead of approving. Their message follows.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private takePendingSteerSubmission(text: string): PiPendingSteerSubmission | undefined {
|
||||
const index = this.pendingSteerSubmissions.findIndex((submission) => submission.text === text);
|
||||
if (index < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const [submission] = this.pendingSteerSubmissions.splice(index, 1);
|
||||
return submission;
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
@@ -1536,15 +1459,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.lastInterruptedTurnId = turnId;
|
||||
}
|
||||
try {
|
||||
try {
|
||||
await this.runtimeSession.clearQueue();
|
||||
} catch (error) {
|
||||
// COMPAT(piClearQueueFallback): added in v0.5.0, remove after 2027-03-01 once
|
||||
// the pi floor supports clear_queue (added in pi 0.84.4).
|
||||
if (!isPiMissingClearQueueRpc(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.runtimeSession.abort();
|
||||
} catch (error) {
|
||||
if (this.interruptingTurnId === turnId) {
|
||||
@@ -1560,7 +1474,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeTurnStartedEmitted = false;
|
||||
this.pendingSettledMessages = null;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
@@ -1579,7 +1492,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeTurnStartedEmitted = false;
|
||||
this.pendingSettledMessages = null;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
this.emit({
|
||||
type: "turn_canceled",
|
||||
@@ -1973,10 +1885,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
const pendingSteer = this.takePendingSteerSubmission(entry.text);
|
||||
const clientMessageId = pendingSteer
|
||||
? pendingSteer.clientMessageId
|
||||
: this.activeClientMessageId;
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
@@ -1985,7 +1893,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
type: "user_message",
|
||||
text: entry.text,
|
||||
messageId: entry.id,
|
||||
...(clientMessageId ? { clientMessageId } : {}),
|
||||
...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
@@ -2160,7 +2068,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnStartedEmitted = false;
|
||||
this.pendingSettledMessages = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
@@ -2461,7 +2368,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnStartedEmitted = false;
|
||||
this.pendingSettledMessages = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
const errorMessage = latestPiErrorMessage(messages);
|
||||
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
||||
|
||||
@@ -368,47 +368,6 @@ describe("PiCliRuntime", () => {
|
||||
expect(child.killedSignals).toContain("SIGTERM");
|
||||
});
|
||||
|
||||
test("sends the steer RPC frame", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
const steerCommand = capturePendingCommand(child, "steer");
|
||||
await session.steer("focus on error handling", [
|
||||
{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" },
|
||||
]);
|
||||
|
||||
expect(await steerCommand).toMatchObject({
|
||||
type: "steer",
|
||||
message: "focus on error handling",
|
||||
images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("sends the steer RPC frame without images", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
const steerCommand = capturePendingCommand(child, "steer");
|
||||
await session.steer("focus on error handling");
|
||||
|
||||
const command = await steerCommand;
|
||||
expect(command).toMatchObject({ type: "steer", message: "focus on error handling" });
|
||||
expect(command.images).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends the clear_queue RPC frame", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
const clearQueueCommand = capturePendingCommand(child, "clear_queue");
|
||||
await session.clearQueue();
|
||||
|
||||
expect(await clearQueueCommand).toMatchObject({ type: "clear_queue" });
|
||||
});
|
||||
|
||||
test("falls back to get_state when get_session_stats is unsupported", async () => {
|
||||
const child = createPiChild();
|
||||
let commandSequence: string[] = [];
|
||||
|
||||
@@ -121,21 +121,6 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
return { requestId };
|
||||
}
|
||||
|
||||
async steer(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void> {
|
||||
await this.request({
|
||||
type: "steer",
|
||||
message,
|
||||
...(images?.length ? { images } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async clearQueue(): Promise<void> {
|
||||
await this.request({ type: "clear_queue" });
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
await this.waitForCompletion({
|
||||
type: "compact",
|
||||
|
||||
@@ -128,8 +128,6 @@ export interface PiRpcSlashCommand {
|
||||
|
||||
export type PiRpcCommand =
|
||||
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
|
||||
| { id?: string; type: "steer"; message: string; images?: PiImageContent[] }
|
||||
| { id?: string; type: "clear_queue" }
|
||||
| { id?: string; type: "compact"; customInstructions?: string }
|
||||
| { id?: string; type: "set_auto_compaction"; enabled: boolean }
|
||||
| { id?: string; type: "abort" }
|
||||
|
||||
@@ -45,11 +45,6 @@ export interface PiRuntimeSession {
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<PiPromptAck>;
|
||||
steer(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void>;
|
||||
clearQueue(): Promise<void>;
|
||||
compact(customInstructions?: string): Promise<void>;
|
||||
setAutoCompaction(enabled: boolean): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
|
||||
@@ -94,10 +94,6 @@ export class FakePi implements PiRuntime {
|
||||
|
||||
export class FakePiSession implements PiRuntimeSession {
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly steerCalls: Array<{ message: string; imageCount: number }> = [];
|
||||
steerError: Error | null = null;
|
||||
readonly controlRequests: string[] = [];
|
||||
clearQueueError: Error | null = null;
|
||||
readonly compactRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly setAutoCompactionRequests: boolean[] = [];
|
||||
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
|
||||
@@ -204,23 +200,6 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async steer(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void> {
|
||||
this.steerCalls.push({ message, imageCount: images?.length ?? 0 });
|
||||
if (this.steerError) {
|
||||
throw this.steerError;
|
||||
}
|
||||
}
|
||||
|
||||
async clearQueue(): Promise<void> {
|
||||
this.controlRequests.push("clear_queue");
|
||||
if (this.clearQueueError) {
|
||||
throw this.clearQueueError;
|
||||
}
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
|
||||
this.emit({ type: "compaction_start", reason: "manual" });
|
||||
@@ -241,7 +220,6 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.controlRequests.push("abort");
|
||||
this.abortRequested = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
appendFile,
|
||||
chmod,
|
||||
link,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
@@ -548,14 +547,10 @@ describe("file explorer service", () => {
|
||||
try {
|
||||
await writeFile(path.join(root, "source.txt"), "source", "utf8");
|
||||
await writeFile(path.join(root, "existing.txt"), "existing", "utf8");
|
||||
await link(path.join(root, "source.txt"), path.join(root, "source-link.txt"));
|
||||
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }),
|
||||
).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' });
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: "source.txt", name: "source-link.txt" }),
|
||||
).resolves.toEqual({ status: "error", error: '"source-link.txt" already exists' });
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }),
|
||||
).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { constants, promises as fs, type BigIntStats, type Stats } from "fs";
|
||||
import { constants, promises as fs, type BigIntStats } from "fs";
|
||||
import type { FileHandle } from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -707,10 +707,11 @@ export async function renameExplorerEntry({
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (targetStats) {
|
||||
if (!(await isCaseOnlyRename(source, targetPath, sourceStats, targetStats))) {
|
||||
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||
}
|
||||
if (
|
||||
targetStats &&
|
||||
(targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino)
|
||||
) {
|
||||
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||
}
|
||||
|
||||
const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath });
|
||||
@@ -781,25 +782,6 @@ function isEntryExistsError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function isCaseOnlyRename(
|
||||
source: ScopedPath,
|
||||
targetPath: string,
|
||||
sourceStats: Stats,
|
||||
targetStats: Stats,
|
||||
): Promise<boolean> {
|
||||
const hasStableFileIds =
|
||||
sourceStats.dev !== 0 ||
|
||||
sourceStats.ino !== 0 ||
|
||||
targetStats.dev !== 0 ||
|
||||
targetStats.ino !== 0;
|
||||
const isSameEntry = hasStableFileIds
|
||||
? targetStats.dev === sourceStats.dev && targetStats.ino === sourceStats.ino
|
||||
: !sourceStats.isSymbolicLink() &&
|
||||
!targetStats.isSymbolicLink() &&
|
||||
(await fs.realpath(targetPath)) === source.resolvedPath;
|
||||
return isSameEntry && source.requestedPath.toLowerCase() === targetPath.toLowerCase();
|
||||
}
|
||||
|
||||
async function resolveScopedPath({
|
||||
root,
|
||||
relativePath = ".",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -278,12 +278,12 @@ describe("PluginService", () => {
|
||||
await service.start();
|
||||
|
||||
await expect(
|
||||
service.installSource({ source: `${repository}:plugins/review` }),
|
||||
service.installSource({ source: repository, pluginPath: "plugins/review" }),
|
||||
).resolves.toMatchObject({ id: "local-monorepo", path: pluginDirectory, status: "running" });
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it("keeps the running commit when a Git update build command fails", async () => {
|
||||
it("keeps the running commit when a Git update fails validation", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
|
||||
@@ -313,15 +313,6 @@ describe("PluginService", () => {
|
||||
const installedPath = installed.path;
|
||||
const installedCommit = installed.commit;
|
||||
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "git-update",
|
||||
build: [
|
||||
[process.execPath, "-e", 'process.stderr.write("build exploded") ; process.exit(1)'],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repository, "index.ts"),
|
||||
'export default function contribute(plugin: unknown) { void plugin; throw new Error("broken update"); }',
|
||||
@@ -330,7 +321,6 @@ describe("PluginService", () => {
|
||||
await runGitCommand(["commit", "-m", "broken update"], { cwd: repository });
|
||||
|
||||
await expect(service.updateSources("git-update")).rejects.toThrow();
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
expect(service.catalog()).toEqual([
|
||||
expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }),
|
||||
]);
|
||||
@@ -345,91 +335,6 @@ describe("PluginService", () => {
|
||||
await service.stopAllPlugins();
|
||||
}, 30_000);
|
||||
|
||||
it("runs Git build commands in staging before validation and activation on install and update", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
|
||||
roots.push(repository);
|
||||
await runGitCommand(["init", "-b", "main"], { cwd: repository });
|
||||
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
|
||||
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "prepared-git-plugin",
|
||||
build: [
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
'require("node:fs").writeFileSync(process.argv[1], "prepared")',
|
||||
"build-marker;touch shell-injection",
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n");
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
|
||||
|
||||
const events: string[] = [];
|
||||
const running = new Set<string>();
|
||||
const runtime: TestPluginRuntime = {
|
||||
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
|
||||
invoke: async () => undefined,
|
||||
getLogs: () => [],
|
||||
clearLogs: () => undefined,
|
||||
validatePlugin: async (directory) => {
|
||||
events.push(
|
||||
`validate:${await readFile(path.join(directory, "build-marker;touch shell-injection"), "utf8")}`,
|
||||
);
|
||||
},
|
||||
startPlugin: async (pluginId) => {
|
||||
events.push("start");
|
||||
running.add(pluginId);
|
||||
},
|
||||
stopPluginById: async (pluginId) => running.delete(pluginId),
|
||||
stopAll: async () => running.clear(),
|
||||
subscribe: () => () => undefined,
|
||||
bindPaseoSessionHost: () => undefined,
|
||||
};
|
||||
const service = createService(
|
||||
home,
|
||||
{},
|
||||
{
|
||||
runtime,
|
||||
managedSources: new ManagedPluginSources(home),
|
||||
},
|
||||
);
|
||||
await service.start();
|
||||
|
||||
const installed = await service.installSource({ source: pathToFileURL(repository).href });
|
||||
|
||||
expect(events).toEqual(["validate:prepared", "start"]);
|
||||
await expect(stat(path.join(installed.path, "shell-injection"))).rejects.toThrow();
|
||||
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "prepared-git-plugin",
|
||||
build: [
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
'require("node:fs").writeFileSync("build-marker;touch shell-injection", "updated")',
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository });
|
||||
|
||||
await expect(service.updateSources("prepared-git-plugin")).resolves.toEqual([
|
||||
expect.objectContaining({ id: "prepared-git-plugin", updated: true }),
|
||||
]);
|
||||
expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]);
|
||||
await service.stopAllPlugins();
|
||||
}, 30_000);
|
||||
|
||||
it("activates an update when the enabled plugin previously failed to start", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
type PluginSourceStatusItem,
|
||||
type PluginSourceUpdateItem,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import { runPluginBuild } from "./preparation.js";
|
||||
import { PluginRuntime } from "./runtime.js";
|
||||
|
||||
interface PluginRuntimePort {
|
||||
@@ -167,36 +165,27 @@ export class PluginService {
|
||||
source: string;
|
||||
id?: string;
|
||||
ref?: string;
|
||||
pluginPath?: string;
|
||||
}): Promise<PluginListItem> {
|
||||
const directDirectory = path.resolve(input.source);
|
||||
const directInfo = await stat(directDirectory).catch(() => null);
|
||||
const reference = directInfo?.isDirectory()
|
||||
? { source: input.source, pluginPath: undefined }
|
||||
: parsePluginSourceReference(input.source);
|
||||
const directory = path.resolve(reference.source);
|
||||
const info = directInfo?.isDirectory() ? directInfo : await stat(directory).catch(() => null);
|
||||
const directory = path.resolve(input.source);
|
||||
const info = await stat(directory).catch(() => null);
|
||||
if (info?.isDirectory()) {
|
||||
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
|
||||
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
|
||||
const pluginDirectory = resolveLocalPluginPath(directory, input.pluginPath);
|
||||
return this.installDirectory({ path: pluginDirectory, id: input.id });
|
||||
}
|
||||
const managedSources = this.requireManagedSources();
|
||||
return this.enqueue(async () => {
|
||||
let candidate = await managedSources.prepareInstall({
|
||||
...input,
|
||||
source: reference.source,
|
||||
pluginPath: reference.pluginPath,
|
||||
});
|
||||
let pluginId: string;
|
||||
let candidate = await managedSources.prepareInstall(input);
|
||||
const pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
|
||||
if (this.configStore.get().plugins?.[pluginId]) {
|
||||
await managedSources.discard(candidate);
|
||||
throw new Error(
|
||||
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
|
||||
);
|
||||
}
|
||||
candidate = await managedSources.place(pluginId, candidate);
|
||||
try {
|
||||
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
||||
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
|
||||
if (this.configStore.get().plugins?.[pluginId]) {
|
||||
throw new Error(
|
||||
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
|
||||
);
|
||||
}
|
||||
candidate = await managedSources.place(pluginId, candidate);
|
||||
await this.validateCandidate(candidate);
|
||||
} catch (error) {
|
||||
await managedSources.discard(candidate);
|
||||
@@ -393,10 +382,8 @@ export class PluginService {
|
||||
updated: false,
|
||||
};
|
||||
}
|
||||
let candidate = prepared.candidate;
|
||||
const candidate = await managedSources.place(pluginId, prepared.candidate);
|
||||
try {
|
||||
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
||||
candidate = await managedSources.place(pluginId, candidate);
|
||||
await this.validateCandidate(candidate);
|
||||
} catch (error) {
|
||||
await managedSources.discard(candidate);
|
||||
@@ -494,11 +481,11 @@ export class PluginService {
|
||||
|
||||
function resolveLocalPluginPath(directory: string, pluginPath: string | undefined): string {
|
||||
if (!pluginPath) return directory;
|
||||
if (path.isAbsolute(pluginPath)) throw new Error("Plugin path must be relative to the source");
|
||||
if (path.isAbsolute(pluginPath)) throw new Error("Plugin --path must be relative to the source");
|
||||
const pluginDirectory = path.resolve(directory, pluginPath);
|
||||
const relative = path.relative(directory, pluginDirectory);
|
||||
const escapesSource =
|
||||
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
if (escapesSource) throw new Error("Plugin path must stay inside the source directory");
|
||||
if (escapesSource) throw new Error("Plugin --path must stay inside the source directory");
|
||||
return pluginDirectory;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PluginIdSchema, type PluginSourceStatusItem } from "@getpaseo/protocol/
|
||||
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||
import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
const GIT_TIMEOUT_MS = 120_000;
|
||||
const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const;
|
||||
@@ -28,7 +27,6 @@ const ManagedPluginRecordsSchema = z.record(PluginIdSchema, ManagedPluginRecordS
|
||||
export interface ManagedPluginRecord extends z.infer<typeof ManagedPluginRecordSchema> {}
|
||||
|
||||
export interface ManagedPluginCandidate {
|
||||
build: PluginManifest["build"];
|
||||
defaultId: string;
|
||||
directory: string;
|
||||
record: ManagedPluginRecord;
|
||||
@@ -78,9 +76,8 @@ export class ManagedPluginSources {
|
||||
await checkout(checkoutRoot, resolution.commit);
|
||||
const directory = path.resolve(checkoutRoot, pluginPath);
|
||||
assertPluginPath(checkoutRoot, directory);
|
||||
const { id: defaultId, build } = await readPluginManifest(directory);
|
||||
const { id: defaultId } = await readPluginManifest(directory);
|
||||
return {
|
||||
build,
|
||||
defaultId,
|
||||
directory,
|
||||
record: {
|
||||
@@ -210,9 +207,8 @@ export class ManagedPluginSources {
|
||||
await checkout(checkoutRoot, commit);
|
||||
const directory = path.resolve(checkoutRoot, record.pluginPath);
|
||||
assertPluginPath(checkoutRoot, directory);
|
||||
const { id: defaultId, build } = await readPluginManifest(directory);
|
||||
const { id: defaultId } = await readPluginManifest(directory);
|
||||
return {
|
||||
build,
|
||||
defaultId,
|
||||
directory,
|
||||
versionRoot: stagingRoot,
|
||||
@@ -263,10 +259,10 @@ function redactRemoteCredentials(remote: string): string {
|
||||
function normalizePluginPath(pluginPath: string | undefined): string {
|
||||
if (!pluginPath || pluginPath === ".") return ".";
|
||||
if (path.isAbsolute(pluginPath))
|
||||
throw new Error("Plugin path must be relative to the repository");
|
||||
throw new Error("Plugin --path must be relative to the repository");
|
||||
const normalized = path.normalize(pluginPath);
|
||||
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
||||
throw new Error("Plugin path must stay inside the repository");
|
||||
throw new Error("Plugin --path must stay inside the repository");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
describe("plugin manifest", () => {
|
||||
it("accepts only non-empty argv arrays for build commands", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
const manifest = path.join(directory, "paseo-plugin.json");
|
||||
|
||||
await writeFile(
|
||||
manifest,
|
||||
JSON.stringify({ id: "prepared", build: [["pnpm", "install", "--frozen-lockfile"]] }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).resolves.toMatchObject({
|
||||
build: [["pnpm", "install", "--frozen-lockfile"]],
|
||||
});
|
||||
|
||||
for (const build of [[], [[]], [["pnpm", ""]], [["pnpm", 1]], "pnpm install"]) {
|
||||
await writeFile(manifest, JSON.stringify({ id: "prepared", build }));
|
||||
await expect(readPluginManifest(directory)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,7 @@ import { z } from "zod";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
|
||||
const MANIFEST_FILENAME = "paseo-plugin.json";
|
||||
const PluginBuildCommandSchema = z
|
||||
.array(z.string().refine((argument) => argument.trim().length > 0))
|
||||
.min(1);
|
||||
const PluginManifestSchema = z
|
||||
.object({
|
||||
id: PluginIdSchema,
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
const PluginManifestSchema = z.object({ id: PluginIdSchema }).strict();
|
||||
|
||||
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type pino from "pino";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
const OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
export async function runPluginBuild(
|
||||
directory: string,
|
||||
commands: PluginManifest["build"],
|
||||
logger: pino.Logger,
|
||||
): Promise<void> {
|
||||
for (const command of commands ?? []) {
|
||||
logger.info({ directory, command }, "Running plugin build command");
|
||||
const result = await run(command, directory);
|
||||
if (result.stdout)
|
||||
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
|
||||
if (result.stderr)
|
||||
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
|
||||
);
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function run(command: string[], directory: string): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const executable = command[0]!;
|
||||
const arguments_ = command.slice(1);
|
||||
const child = spawn(executable, arguments_, { cwd: directory, shell: false });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let error: Error | undefined;
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
stdout = appendOutput(stdout, chunk.toString());
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr = appendOutput(stderr, chunk.toString());
|
||||
});
|
||||
child.once("error", (spawnError) => {
|
||||
error = spawnError;
|
||||
});
|
||||
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
|
||||
});
|
||||
}
|
||||
|
||||
function appendOutput(current: string, next: string): string {
|
||||
return `${current}${next}`.slice(-OUTPUT_LIMIT);
|
||||
}
|
||||
|
||||
function formatCommand(command: string[]): string {
|
||||
return command.map((argument) => JSON.stringify(argument)).join(" ");
|
||||
}
|
||||
|
||||
function formatOutput(result: CommandResult): string {
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
||||
return output ? `\n${output}` : "";
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Error;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
import { basename, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import {
|
||||
serializeAgentStreamEvent,
|
||||
type AgentSnapshotPayload,
|
||||
@@ -480,6 +479,7 @@ export interface SessionOptions {
|
||||
source: string;
|
||||
id?: string;
|
||||
ref?: string;
|
||||
pluginPath?: string;
|
||||
}): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
|
||||
statusSources(
|
||||
pluginId?: string,
|
||||
@@ -2109,10 +2109,10 @@ export class Session {
|
||||
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
||||
return this.pluginRuntime
|
||||
.installSource({
|
||||
// COMPAT(plugin-source-path): accepted for v0.7 clients; remove after 2027-09-01.
|
||||
source: formatPluginSourceReference(msg.source, msg.pluginPath),
|
||||
source: msg.source,
|
||||
...(msg.id ? { id: msg.id } : {}),
|
||||
...(msg.ref ? { ref: msg.ref } : {}),
|
||||
...(msg.pluginPath ? { pluginPath: msg.pluginPath } : {}),
|
||||
})
|
||||
.then((plugin) => {
|
||||
this.emit({
|
||||
|
||||
@@ -899,9 +899,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||
]);
|
||||
releaseFetch.resolve();
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||
});
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -3141,36 +3141,26 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
let result: WorkspaceGitFetchResult | null = null;
|
||||
const eventsBeforeFetchSnapshot: FileChange[] = [];
|
||||
try {
|
||||
try {
|
||||
result = await this.deps.runGitFetch(
|
||||
target.cwd,
|
||||
{
|
||||
onRefSnapshot: (phase) => {
|
||||
const events = target.bufferedFetchMetadataEvents.splice(0);
|
||||
if (phase === "before") {
|
||||
eventsBeforeFetchSnapshot.push(...events);
|
||||
}
|
||||
},
|
||||
result = await this.deps.runGitFetch(
|
||||
target.cwd,
|
||||
{
|
||||
onRefSnapshot: (phase) => {
|
||||
const events = target.bufferedFetchMetadataEvents.splice(0);
|
||||
if (phase === "before") {
|
||||
eventsBeforeFetchSnapshot.push(...events);
|
||||
}
|
||||
},
|
||||
createRunGitCommand("background-fetch"),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||
"Background git fetch failed",
|
||||
);
|
||||
}
|
||||
this.applyRepoFetchResult(target, result, eventsBeforeFetchSnapshot);
|
||||
},
|
||||
createRunGitCommand("background-fetch"),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||
"Background git fetch failed",
|
||||
);
|
||||
} finally {
|
||||
target.fetchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applyRepoFetchResult(
|
||||
target: RepoGitTarget,
|
||||
result: WorkspaceGitFetchResult | null,
|
||||
eventsBeforeFetchSnapshot: FileChange[],
|
||||
): void {
|
||||
this.flushFetchMetadataEvents(target, eventsBeforeFetchSnapshot);
|
||||
if (!result || result.changes === null) {
|
||||
target.recentFetchRemoteRefChanges.clear();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user