mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
13 Commits
v0.1.86
...
feat/markd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ce3b434e | ||
|
|
bcd1f28f9a | ||
|
|
41cb1af036 | ||
|
|
17aa957d3e | ||
|
|
2c756e9a8a | ||
|
|
bb6a4db6e0 | ||
|
|
2a13a082b7 | ||
|
|
eea5932a21 | ||
|
|
e04bb942e2 | ||
|
|
3c3574d670 | ||
|
|
44863ec1dd | ||
|
|
47414abc5e | ||
|
|
9860dd36ef |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.87 - 2026-05-30
|
||||
|
||||
### Added
|
||||
|
||||
- Permission prompts from OpenCode subagents now surface in Paseo so you can approve or deny them
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an intermittent Android crash while animated views were drawing
|
||||
- Fixed mobile bottom sheets not reopening after being dismissed
|
||||
|
||||
## 0.1.86 - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -155,6 +155,20 @@ The "render invisible to measure, then reveal" pattern is the canonical
|
||||
solution to chicken-and-egg positioning in this codebase. Reach for it before
|
||||
anything fancier.
|
||||
|
||||
## Gotcha 6 — Bottom sheet refs are not lifecycle truth
|
||||
|
||||
`@gorhom/bottom-sheet` modals churn their imperative ref while presenting and
|
||||
dismissing. Do not treat `ref != null` as permission to call `present()`, and do
|
||||
not treat `ref == null` as the sheet being closed. The user-visible lifecycle is
|
||||
the desired `visible` prop plus the sheet callbacks (`onChange(-1)`,
|
||||
`onDismiss`).
|
||||
|
||||
If a user closes a sheet with the backdrop or a pan gesture, the sheet may detach
|
||||
and reattach before React state has acknowledged `visible=false`. Re-presenting
|
||||
on that attach races Gorhom's dismiss path and leaves the modal unable to reopen.
|
||||
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
|
||||
ignore ref churn while dismissing.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
149
docs/refactors/session-decomposition-plan.md
Normal file
149
docs/refactors/session-decomposition-plan.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Session God-File Decomposition Plan
|
||||
|
||||
`packages/server/src/server/session.ts` — 9116 lines, one `Session` class (declared line 724), 128 handlers, ~60 instance fields, 7 entangled domains. Goal: a **strictly behavior-preserving, incremental** decomposition into per-domain controllers, mirroring the existing `TerminalSessionController`.
|
||||
|
||||
## Chosen strategy: controller-context (per-domain option-bag controllers)
|
||||
|
||||
Each domain becomes a controller class in its own file with the **exact** contract the repo already proved twice (`TerminalSessionController` at `packages/server/src/terminal/terminal-session-controller.ts`, and `CreateAgentLifecycleDispatch`):
|
||||
|
||||
- An **options-bag constructor** injecting only what that domain reads.
|
||||
- An **owned-type `ReadonlySet`** of message types.
|
||||
- A **NON-async `dispatch(msg): Promise<void> | undefined`** that checks the owned-type set FIRST and returns `undefined` synchronously on a miss (verified at terminal-session-controller.ts:140-143).
|
||||
- `start()` wired from `subscribeToOptionalManagers`, `dispose()` called by the shell's ordered `cleanup()`.
|
||||
|
||||
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
|
||||
|
||||
### Why this is safe at the dispatch seam (verified)
|
||||
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
|
||||
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
|
||||
|
||||
## Slice ordering (least-coupled first)
|
||||
|
||||
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
|
||||
|
||||
| # | Slice | Effort | Risk |
|
||||
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
|
||||
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
|
||||
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
|
||||
| 2 | ProviderCatalogController | M | medium |
|
||||
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
|
||||
| 4 | GitCheckoutController | L | medium |
|
||||
| 5 | WorkspaceController | XL | high |
|
||||
| 6 | Voice prereqs: emit() purity + abortController ownership | M | high |
|
||||
| 7 | VoiceSessionController | XL | high |
|
||||
| 8a | Agent-lifecycle config setters | M | medium |
|
||||
| 8b | AgentLifecycleController | XL | high |
|
||||
|
||||
---
|
||||
|
||||
## Slice 0 — Test net + disjointness tripwire (prerequisite)
|
||||
|
||||
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
|
||||
|
||||
Write RED-then-GREEN against the **current in-place** Session:
|
||||
|
||||
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
|
||||
- one `schedule/*` and one `loop/*` round-trip.
|
||||
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
|
||||
- a handler that **rejects async** emits the SAME pair.
|
||||
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
|
||||
|
||||
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
|
||||
|
||||
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
|
||||
|
||||
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
|
||||
|
||||
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — ProviderCatalogController
|
||||
|
||||
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `providerSnapshotManager` (**shared by reference** — stays a daemon singleton read by checkout/lifecycle/workspace), `isProviderVisibleToClient` (predicate closing over `this`, reads `appVersion` live), `downgradeModeIconsForClient`, `downgradeEntryModesForClient`, agent-control reads `{ listProviderAvailability, listDraftFeatures }`.
|
||||
|
||||
**Behavior note:** COMPAT correctness — PUSH and PULL paths MUST call the SAME injected visibility/downgrade closures, reading `appVersion` LIVE (mutated post-construction via `updateAppVersion`). Keep `COMPAT(providersSnapshot)` and `COMPAT(customModeIcons)` comments verbatim. Do NOT pull `resolveStructuredGenerationProviders`/`getFocusedAgentSelectionForCwd` in. **Tests:** `session.dispatch-seam.test.ts`, `daemon-e2e/models.e2e.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 3 — Split the shared observer seams (prerequisite, no controller)
|
||||
|
||||
In-place refactor on the shell, two named fan-outs:
|
||||
|
||||
1. **workspace-git observer** (4910-4917): make `emitCheckoutStatusUpdate` and `onBranchChanged` injectable callbacks; keep `workspaceGitWatchTargets`/`workspaceGitSubscriptions` shared by reference.
|
||||
2. **agentManager.subscribe callback** (~1298): refactor into `{ onAgentUpdate, shouldAutoAllowVoicePermission(event), onStreamEvent }`.
|
||||
|
||||
**Behavior note:** the single hardest seam, split exactly once before the two domains that co-own it. The observer fires BOTH workspace (`handleWorkspaceGitBranchSnapshot`, `emitWorkspaceUpdateForCwd`) and checkout (`emitCheckoutStatusUpdate`) effects; the agent-subscribe callback is invoked by agent EVENTS (not the `??` chain) and does lifecycle + voice work. Add a test asserting BOTH a `workspace_update` and a `checkout_status_update` fire from one simulated git snapshot change, and a voice-permission test for the auto-allow path. **Tests:** `session.workspace-git-watch.test.ts`, `session.workspaces.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 4 — GitCheckoutController
|
||||
|
||||
**Move:** ~22 `checkout_*`/`stash_*`/PR/github handlers + `handleSubscribeCheckoutDiffRequest`/`handleUnsubscribeCheckoutDiffRequest` + `emitCheckoutStatusUpdate` + `checkoutDiffSubscriptions` → `packages/server/src/server/checkout/git-checkout-controller.ts`. Collapse `dispatchCheckoutMessage`.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `checkoutDiffManager` (move in + dispose teardown), `github` (shared), `workspaceGitService` (**shared spine**), `workspaceGitWatchTargets`/`workspaceGitSubscriptions` (**shared**), `providerSnapshotManager.listRegisteredProviderIds`. `emitCheckoutStatusUpdate` is now owned here and injected back into the workspace observer seam from Slice 3.
|
||||
|
||||
**Behavior note:** safe now that Slice 3 split the observer. `checkoutDiffSubscriptions` teardown moves to `dispose()`, called by `cleanup()` at its current ordinal (8530). **Tests:** `session.dispatch-seam.test.ts`, `checkout-diff-manager.test.ts`, `daemon-e2e/checkout-diff-subscription.e2e.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 5 — WorkspaceController (XL)
|
||||
|
||||
**Move:** all workspace handlers (incl. re-homed `handleProjectRenameRequest` and `start_workspace_script_request`) + ~25 private workspace helpers + the whole `workspaceUpdatesSubscription` state machine → `packages/server/src/server/workspace/workspace-controller.ts`.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `projectRegistry`/`workspaceRegistry`/`downloadTokenStore`/script stores/editor cache (**owned**), `workspaceGitService` + watch maps (**shared with checkout**), injected `emitCheckoutStatusUpdate`/`onBranchChanged`, `terminalManager`/`killTerminalsUnderPath`, an `agentUpdatesSubscription` write via a narrow `bufferAgentUpdate` command, `providerSnapshotManager.listRegisteredProviderIds`.
|
||||
|
||||
**Behavior note:** the workspaceUpdatesSubscription machine moves WHOLE. The eight already-public workspace methods stay a public surface re-exposed via the shell. Re-homes are atomic remove-from-old-dispatcher + add-to-new-owned-set. **Tests:** `session.workspaces.test.ts`, `session.workspace-git-watch.test.ts`, `session.workspace-resolution-invariants.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — Voice prerequisites (emit purity + abort ownership)
|
||||
|
||||
In-place, separately reviewable. Split the `audio_output` TTS-debug branch out of `emit()` (8421-8468, bypasses to `onMessage` at 8454) so `emit` is a pure trace+onMessage sink. Move `convertPCMToWavBuffer` (674-701) to `speech/audio.ts`. Decide abortController ownership.
|
||||
|
||||
**Behavior note:** TTS-debug split and abortController ownership are the SAME decision (`ttsDebugStreams.clear()` is tied to `createAbortController` reassignment at 8359). Keep `emit` (with the universal trace) on the shell and inject it everywhere — no trace-less emit. Do NOT inject the AbortController by value. Add: a TTS-debug persistence test (with the debug env flag) before the move, and a barge-in→cleanup regression test asserting the NEW run's signal is aborted. **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 7 — VoiceSessionController (XL)
|
||||
|
||||
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
|
||||
|
||||
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
|
||||
|
||||
**Behavior note:** depends on Slices 3 + 6. `cleanup()` stays the ordered orchestrator and calls `voiceController.dispose()` at the position the inlined voice teardown occupies today (8505-8525). **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-local-agent.e2e.test.ts`, `session.voice-mcp-config.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 8a — Agent-lifecycle config setters
|
||||
|
||||
Parameterize the 4 setter envelopes `handleSetAgentMode/Model/Feature/Thinking` (4209-4390) into one helper; re-home `handleListCommandsRequest` (misfiled in `dispatchMiscMessage`). Add a handleMessage-driven **failure** test per setter (force the command to reject, assert both the `*_response{accepted:false}` AND the `activity_log` error frame in order) BEFORE collapsing. **Tests:** `session.test.ts`, `session.lifecycle-boundary.test.ts`.
|
||||
|
||||
## Slice 8b — AgentLifecycleController (XL, LAST)
|
||||
|
||||
**Move:** remaining lifecycle handlers + the `agentUpdatesSubscription` fan-out (`bufferOrEmitAgentUpdate`, `flushBootstrappedAgentUpdates`, `matchesAgentFilter`, `forwardAgentUpdate`) → `packages/server/src/server/agent/agent-lifecycle-controller.ts`. Collapse the three lifecycle dispatchers.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `agentManager`/`agentStorage` (**owned**), injected `forwardAgentUpdate` → `buildProjectPlacementForCwd` (backed by WorkspaceController), `agentUpdatesSubscription` accessor (owned; workspace writes via `bufferAgentUpdate`), `isProviderVisibleToClient`, `resolveCreateAgentWorkspace`, `supports`, `mcpBaseUrl`, `terminalController.killTerminalForClose`.
|
||||
|
||||
**Behavior note:** done LAST — the shared-projection hub. `handleCloseItemsRequest` splits its terminal-kill half from its agent-archive half. **Tests:** `session.test.ts`, `session.wait-for-finish.test.ts`, `session.create-agent-title.test.ts`, `session.lifecycle-boundary.test.ts`, `daemon-client.e2e.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting invariants (every slice)
|
||||
|
||||
- **Always** run `npm run typecheck` and `npm run lint` after each slice; run `npm run build:server` before diagnosing cross-package type errors.
|
||||
- Controller `dispatch` is **NON-async**, guarded by an owned-type `ReadonlySet` check returning `undefined` synchronously on miss. Never `async dispatch`.
|
||||
- Controllers add **no** try/catch inside `dispatch` — error handling stays in `handleMessage`.
|
||||
- `cleanup()` stays the single ordered teardown orchestrator on the shell.
|
||||
- Move domain error emitters **verbatim**; treat any cross-domain emitter merge as a separate, test-guarded change.
|
||||
- Per-slice typecheck/lint/format via `npm run` scripts; never re-run the full suite locally (run only the listed files with `--bail=1`).
|
||||
@@ -1 +1 @@
|
||||
sha256-PYbY3Lk9+y2byl1mN9dVO4YGXLEZrmnuPYxDw59LE5g=
|
||||
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
|
||||
|
||||
676
package-lock.json
generated
676
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -10120,6 +10120,588 @@
|
||||
"node": ">= 20.19.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer": {
|
||||
"version": "0.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz",
|
||||
"integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@react-native/babel-preset": "0.85.3",
|
||||
"hermes-parser": "0.33.3",
|
||||
"nullthrows": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-plugin-codegen": {
|
||||
"version": "0.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz",
|
||||
"integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@react-native/codegen": "0.85.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-preset": {
|
||||
"version": "0.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz",
|
||||
"integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-proposal-export-default-from": "^7.24.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-syntax-export-default-from": "^7.24.7",
|
||||
"@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
|
||||
"@babel/plugin-syntax-optional-chaining": "^7.8.3",
|
||||
"@babel/plugin-transform-async-generator-functions": "^7.25.4",
|
||||
"@babel/plugin-transform-async-to-generator": "^7.24.7",
|
||||
"@babel/plugin-transform-block-scoping": "^7.25.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.4",
|
||||
"@babel/plugin-transform-classes": "^7.25.4",
|
||||
"@babel/plugin-transform-destructuring": "^7.24.8",
|
||||
"@babel/plugin-transform-flow-strip-types": "^7.25.2",
|
||||
"@babel/plugin-transform-for-of": "^7.24.7",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.24.8",
|
||||
"@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
|
||||
"@babel/plugin-transform-optional-catch-binding": "^7.24.7",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.24.8",
|
||||
"@babel/plugin-transform-private-methods": "^7.24.7",
|
||||
"@babel/plugin-transform-private-property-in-object": "^7.24.7",
|
||||
"@babel/plugin-transform-react-display-name": "^7.24.7",
|
||||
"@babel/plugin-transform-react-jsx": "^7.25.2",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.24.7",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.24.7",
|
||||
"@babel/plugin-transform-regenerator": "^7.24.7",
|
||||
"@babel/plugin-transform-runtime": "^7.24.7",
|
||||
"@babel/plugin-transform-typescript": "^7.25.2",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.24.7",
|
||||
"@react-native/babel-plugin-codegen": "0.85.3",
|
||||
"babel-plugin-syntax-hermes-parser": "0.33.3",
|
||||
"babel-plugin-transform-flow-enums": "^0.0.2",
|
||||
"react-refresh": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/codegen": {
|
||||
"version": "0.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz",
|
||||
"integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"hermes-parser": "0.33.3",
|
||||
"invariant": "^2.2.4",
|
||||
"nullthrows": "^1.1.1",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"yargs": "^17.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer/node_modules/babel-plugin-syntax-hermes-parser": {
|
||||
"version": "0.33.3",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
|
||||
"integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"hermes-parser": "0.33.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": {
|
||||
"version": "0.33.3",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
|
||||
"integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": {
|
||||
"version": "0.33.3",
|
||||
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
|
||||
"integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"hermes-estree": "0.33.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-babel-transformer/node_modules/react-refresh": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config": {
|
||||
"version": "0.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz",
|
||||
"integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-native/js-polyfills": "0.85.3",
|
||||
"@react-native/metro-babel-transformer": "0.85.3",
|
||||
"metro-config": "^0.84.3",
|
||||
"metro-runtime": "^0.84.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": {
|
||||
"version": "0.85.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
|
||||
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/ci-info": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
|
||||
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/hermes-estree": {
|
||||
"version": "0.35.0",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz",
|
||||
"integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/hermes-parser": {
|
||||
"version": "0.35.0",
|
||||
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz",
|
||||
"integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"hermes-estree": "0.35.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz",
|
||||
"integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/generator": "^7.29.1",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"accepts": "^2.0.0",
|
||||
"ci-info": "^2.0.0",
|
||||
"connect": "^3.6.5",
|
||||
"debug": "^4.4.0",
|
||||
"error-stack-parser": "^2.0.6",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"graceful-fs": "^4.2.4",
|
||||
"hermes-parser": "0.35.0",
|
||||
"image-size": "^1.0.2",
|
||||
"invariant": "^2.2.4",
|
||||
"jest-worker": "^29.7.0",
|
||||
"jsc-safe-url": "^0.2.2",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"metro-babel-transformer": "0.84.4",
|
||||
"metro-cache": "0.84.4",
|
||||
"metro-cache-key": "0.84.4",
|
||||
"metro-config": "0.84.4",
|
||||
"metro-core": "0.84.4",
|
||||
"metro-file-map": "0.84.4",
|
||||
"metro-resolver": "0.84.4",
|
||||
"metro-runtime": "0.84.4",
|
||||
"metro-source-map": "0.84.4",
|
||||
"metro-symbolicate": "0.84.4",
|
||||
"metro-transform-plugins": "0.84.4",
|
||||
"metro-transform-worker": "0.84.4",
|
||||
"mime-types": "^3.0.1",
|
||||
"nullthrows": "^1.1.1",
|
||||
"serialize-error": "^2.1.0",
|
||||
"source-map": "^0.5.6",
|
||||
"throat": "^5.0.0",
|
||||
"ws": "^7.5.10",
|
||||
"yargs": "^17.6.2"
|
||||
},
|
||||
"bin": {
|
||||
"metro": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-babel-transformer": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz",
|
||||
"integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"hermes-parser": "0.35.0",
|
||||
"metro-cache-key": "0.84.4",
|
||||
"nullthrows": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-cache": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz",
|
||||
"integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"metro-core": "0.84.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-cache-key": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz",
|
||||
"integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flow-enums-runtime": "^0.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-config": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz",
|
||||
"integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"connect": "^3.6.5",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"jest-validate": "^29.7.0",
|
||||
"metro": "0.84.4",
|
||||
"metro-cache": "0.84.4",
|
||||
"metro-core": "0.84.4",
|
||||
"metro-runtime": "0.84.4",
|
||||
"yaml": "^2.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-core": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz",
|
||||
"integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"metro-resolver": "0.84.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-file-map": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz",
|
||||
"integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"fb-watchman": "^2.0.0",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"graceful-fs": "^4.2.4",
|
||||
"invariant": "^2.2.4",
|
||||
"jest-worker": "^29.7.0",
|
||||
"micromatch": "^4.0.4",
|
||||
"nullthrows": "^1.1.1",
|
||||
"walker": "^1.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-minify-terser": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz",
|
||||
"integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"terser": "^5.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-resolver": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz",
|
||||
"integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flow-enums-runtime": "^0.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-runtime": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz",
|
||||
"integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"flow-enums-runtime": "^0.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-source-map": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz",
|
||||
"integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"invariant": "^2.2.4",
|
||||
"metro-symbolicate": "0.84.4",
|
||||
"nullthrows": "^1.1.1",
|
||||
"ob1": "0.84.4",
|
||||
"source-map": "^0.5.6",
|
||||
"vlq": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-symbolicate": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz",
|
||||
"integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"invariant": "^2.2.4",
|
||||
"metro-source-map": "0.84.4",
|
||||
"nullthrows": "^1.1.1",
|
||||
"source-map": "^0.5.6",
|
||||
"vlq": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"metro-symbolicate": "src/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-transform-plugins": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz",
|
||||
"integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/generator": "^7.29.1",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"nullthrows": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/metro-transform-worker": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz",
|
||||
"integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/generator": "^7.29.1",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"flow-enums-runtime": "^0.0.6",
|
||||
"metro": "0.84.4",
|
||||
"metro-babel-transformer": "0.84.4",
|
||||
"metro-cache": "0.84.4",
|
||||
"metro-cache-key": "0.84.4",
|
||||
"metro-minify-terser": "0.84.4",
|
||||
"metro-source-map": "0.84.4",
|
||||
"metro-transform-plugins": "0.84.4",
|
||||
"nullthrows": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/ob1": {
|
||||
"version": "0.84.4",
|
||||
"resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz",
|
||||
"integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flow-enums-runtime": "^0.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/metro-config/node_modules/ws": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/normalize-colors": {
|
||||
"version": "0.81.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.6.tgz",
|
||||
@@ -30808,19 +31390,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
|
||||
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
|
||||
"integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.2.1",
|
||||
"semver": "7.7.2"
|
||||
"react-native-is-edge-to-edge": "^1.3.1",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-worklets": ">=0.5.0"
|
||||
"react-native": "0.81 - 0.85",
|
||||
"react-native-worklets": "0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated/node_modules/semver": {
|
||||
@@ -30962,27 +31543,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
|
||||
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
|
||||
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
|
||||
"@babel/plugin-transform-class-properties": "^7.0.0-0",
|
||||
"@babel/plugin-transform-classes": "^7.0.0-0",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.0.0-0",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
|
||||
"@babel/plugin-transform-template-literals": "^7.0.0-0",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.0.0-0",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-classes": "^7.28.4",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-template-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"semver": "7.7.2"
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0",
|
||||
"@babel/core": "*",
|
||||
"@react-native/metro-config": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
"react-native": "0.81 - 0.85"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets/node_modules/semver": {
|
||||
@@ -36303,7 +36885,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36360,14 +36942,14 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
@@ -36375,7 +36957,7 @@
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-worklets": "~0.8.3",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"zod": "^3.23.8",
|
||||
@@ -36528,12 +37110,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.86",
|
||||
"@getpaseo/protocol": "0.1.86",
|
||||
"@getpaseo/server": "0.1.86",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/server": "0.1.87",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36779,10 +37361,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.86",
|
||||
"@getpaseo/relay": "0.1.86",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36802,7 +37384,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37054,7 +37636,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37090,7 +37672,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37319,7 +37901,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37340,7 +37922,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -37558,14 +38140,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.86",
|
||||
"@getpaseo/highlight": "0.1.86",
|
||||
"@getpaseo/protocol": "0.1.86",
|
||||
"@getpaseo/relay": "0.1.86",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/highlight": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38337,7 +38919,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -121,6 +121,8 @@
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-worklets": "0.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
126
packages/app/e2e/bottom-sheet-reopen.spec.ts
Normal file
126
packages/app/e2e/bottom-sheet-reopen.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "bottom-sheet-reopen-",
|
||||
title: "Bottom sheet reopen e2e",
|
||||
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function bottomSheetBackdrop(page: Page) {
|
||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
}
|
||||
|
||||
function bottomSheetHandle(page: Page) {
|
||||
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||
}
|
||||
|
||||
async function expectBottomSheetOpen(page: Page) {
|
||||
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function closeBottomSheetWithBackdrop(page: Page) {
|
||||
const backdrop = bottomSheetBackdrop(page);
|
||||
const handle = bottomSheetHandle(page);
|
||||
// Tapping the backdrop is the close path under test, but on a loaded CI runner
|
||||
// the model-selector sheet re-renders as its model list settles and Gorhom
|
||||
// drops backdrop presses during that churn — so a tap (even retried) can fail
|
||||
// to dismiss. Tap the backdrop first; if it survives, drag the handle down,
|
||||
// which drives Gorhom's pan-to-close directly and is unaffected by the churn.
|
||||
// The post-close guard below still protects the regression this test exists
|
||||
// for: a sheet that dismisses, then re-presents.
|
||||
await expect(async () => {
|
||||
if (!(await backdrop.isVisible())) {
|
||||
return;
|
||||
}
|
||||
const box = await backdrop.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + 24);
|
||||
}
|
||||
await page.waitForTimeout(150);
|
||||
if (await backdrop.isVisible()) {
|
||||
const handleBox = await handle.boundingBox();
|
||||
if (handleBox) {
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX, startY + 400, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
}
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
// Guard against the regression where the sheet starts dismissing, then re-presents.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
||||
}
|
||||
|
||||
async function openTabSwitcher(page: Page) {
|
||||
const trigger = page.getByRole("button", { name: /Switch tabs/ });
|
||||
await trigger.click();
|
||||
await expectBottomSheetOpen(page);
|
||||
}
|
||||
|
||||
async function openModelSelector(page: Page) {
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
await expectBottomSheetOpen(page);
|
||||
await expect(
|
||||
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function openAndCloseTabSwitcherTwice(page: Page) {
|
||||
await openTabSwitcher(page);
|
||||
await closeBottomSheetWithBackdrop(page);
|
||||
await openTabSwitcher(page);
|
||||
await closeBottomSheetWithBackdrop(page);
|
||||
}
|
||||
|
||||
async function openAndCloseModelSelectorTwice(page: Page) {
|
||||
await openModelSelector(page);
|
||||
await closeBottomSheetWithBackdrop(page);
|
||||
await openModelSelector(page);
|
||||
await closeBottomSheetWithBackdrop(page);
|
||||
}
|
||||
|
||||
test.describe("mobile bottom sheet reopen", () => {
|
||||
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openAndCloseTabSwitcherTwice(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("model selector can open, close, reopen, and close again", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -85,14 +85,14 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-worklets": "~0.8.3",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"zod": "^3.23.8",
|
||||
|
||||
@@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type {
|
||||
AgentPlanAction,
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -113,6 +114,86 @@ function renderPendingPermissionsNode(input: {
|
||||
);
|
||||
}
|
||||
|
||||
function PlanTimelineCard({
|
||||
item,
|
||||
agentId,
|
||||
client,
|
||||
}: {
|
||||
item: Extract<StreamItem, { kind: "plan" }>;
|
||||
agentId: string;
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||
const respondToPlan = useMutation({
|
||||
mutationFn: async (action: AgentPlanAction) => {
|
||||
if (!client) {
|
||||
throw new Error("No daemon connection");
|
||||
}
|
||||
setRespondingActionId(action.id);
|
||||
const result = await client.respondToPlan(agentId, item.planId, { actionId: action.id });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? "Failed to respond to plan");
|
||||
}
|
||||
},
|
||||
onSettled: () => setRespondingActionId(null),
|
||||
});
|
||||
|
||||
const actions = item.actions ?? [];
|
||||
|
||||
return (
|
||||
<View>
|
||||
<PlanCard title="Plan" text={item.text} testID="timeline-plan-card" />
|
||||
{actions.length > 0 ? (
|
||||
<View style={permissionStyles.optionsContainer}>
|
||||
{actions.map((action) => (
|
||||
<PlanActionButton
|
||||
key={action.id}
|
||||
action={action}
|
||||
respondingActionId={respondingActionId}
|
||||
isResponding={respondToPlan.isPending}
|
||||
onRespond={respondToPlan.mutate}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanActionButton({
|
||||
action,
|
||||
respondingActionId,
|
||||
isResponding,
|
||||
onRespond,
|
||||
}: {
|
||||
action: AgentPlanAction;
|
||||
respondingActionId: string | null;
|
||||
isResponding: boolean;
|
||||
onRespond: (action: AgentPlanAction) => void;
|
||||
}) {
|
||||
const Icon = action.variant === "danger" ? ThemedXIcon : ThemedCheckIcon;
|
||||
const permissionAction = useMemo<AgentPermissionAction>(
|
||||
() => ({
|
||||
...action,
|
||||
behavior: action.variant === "danger" ? "deny" : "allow",
|
||||
}),
|
||||
[action],
|
||||
);
|
||||
const handlePress = useCallback(() => onRespond(action), [action, onRespond]);
|
||||
|
||||
return (
|
||||
<PermissionActionButton
|
||||
action={permissionAction}
|
||||
isRespondingAction={respondingActionId === action.id}
|
||||
isResponding={isResponding}
|
||||
isPrimary={action.variant === "primary"}
|
||||
Icon={Icon}
|
||||
testID={`plan-action-${action.id}`}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
@@ -549,6 +630,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
case "todo_list":
|
||||
return <TodoListCard items={item.items} />;
|
||||
|
||||
case "plan":
|
||||
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
|
||||
|
||||
case "compaction":
|
||||
return (
|
||||
<CompactionMarker
|
||||
@@ -562,7 +646,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
|
||||
[
|
||||
agentId,
|
||||
client,
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderThoughtItem,
|
||||
renderToolCallItem,
|
||||
],
|
||||
);
|
||||
|
||||
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
|
||||
|
||||
@@ -118,4 +118,44 @@ describe("bottom sheet visibility tracker", () => {
|
||||
tracker.handleSheetIndexChange(-1);
|
||||
expect(closeCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("does not re-present when the controller reattaches before parent state acknowledges a user dismiss", () => {
|
||||
const { sheet, tracker, closeCount } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
tracker.handleSheetIndexChange(-1);
|
||||
tracker.attachController(null);
|
||||
tracker.attachController(sheet);
|
||||
|
||||
expect(closeCount()).toBe(1);
|
||||
expect(sheet.events).toEqual([{ type: "present" }]);
|
||||
});
|
||||
|
||||
it("does not re-present when dismiss fires before parent state acknowledges a user dismiss", () => {
|
||||
const { sheet, tracker, closeCount } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
tracker.handleSheetDismiss();
|
||||
tracker.attachController(null);
|
||||
tracker.attachController(sheet);
|
||||
|
||||
expect(closeCount()).toBe(1);
|
||||
expect(sheet.events).toEqual([{ type: "present" }]);
|
||||
});
|
||||
|
||||
it("allows a fresh open after parent state acknowledges a dismissed sheet", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
tracker.handleSheetIndexChange(-1);
|
||||
tracker.attachController(null);
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: false });
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,25 +15,27 @@ export interface BottomSheetVisibilityTracker {
|
||||
handleSheetDismiss(): void;
|
||||
}
|
||||
|
||||
type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
|
||||
|
||||
export function createBottomSheetVisibilityTracker(opts: {
|
||||
onClose: () => void;
|
||||
}): BottomSheetVisibilityTracker {
|
||||
let controller: BottomSheetController | null = null;
|
||||
let visible = false;
|
||||
let isEnabled: boolean | undefined;
|
||||
let isPresented = false;
|
||||
let phase: BottomSheetPhase = "closed";
|
||||
let hasNotifiedClose = false;
|
||||
|
||||
function present(): void {
|
||||
if (!controller || isPresented) return;
|
||||
isPresented = true;
|
||||
if (!controller || phase !== "closed") return;
|
||||
phase = "presenting";
|
||||
hasNotifiedClose = false;
|
||||
controller.present();
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
if (!controller || !isPresented) return;
|
||||
isPresented = false;
|
||||
if (!controller || phase === "closed" || phase === "dismissing") return;
|
||||
phase = "dismissing";
|
||||
controller.dismiss();
|
||||
}
|
||||
|
||||
@@ -58,19 +60,34 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
present();
|
||||
return;
|
||||
}
|
||||
if (phase === "dismissing") {
|
||||
phase = "closed";
|
||||
hasNotifiedClose = false;
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
handleSheetIndexChange(index) {
|
||||
if (index === -1 && visible) {
|
||||
if (index !== -1) {
|
||||
if (phase === "presenting") {
|
||||
phase = "presented";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
phase = "dismissing";
|
||||
}
|
||||
if (visible) {
|
||||
notifyClose();
|
||||
}
|
||||
},
|
||||
handleSheetDismiss() {
|
||||
isPresented = false;
|
||||
if (visible) {
|
||||
phase = "dismissing";
|
||||
notifyClose();
|
||||
return;
|
||||
}
|
||||
phase = "closed";
|
||||
hasNotifiedClose = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,6 +99,19 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
|
||||
};
|
||||
}
|
||||
|
||||
function planTimeline(): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "plan",
|
||||
planId: "plan-1",
|
||||
text: "# Plan\n\n- Implement it",
|
||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
|
||||
return state.find(
|
||||
(item): item is AgentToolCallItem =>
|
||||
@@ -693,6 +706,26 @@ describe("stream reducer canonical tool calls", () => {
|
||||
assert.strictEqual(todos.items[1]?.completed, true);
|
||||
});
|
||||
|
||||
it("converts plan timeline updates to plan items", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: planTimeline(),
|
||||
timestamp: new Date("2025-01-01T10:55:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const plan = state.find(
|
||||
(item): item is Extract<StreamItem, { kind: "plan" }> => item.kind === "plan",
|
||||
);
|
||||
|
||||
assert.ok(plan);
|
||||
assert.strictEqual(plan.planId, "plan-1");
|
||||
assert.strictEqual(plan.text, "# Plan\n\n- Implement it");
|
||||
assert.deepStrictEqual(plan.actions, [
|
||||
{ id: "implement", label: "Implement", variant: "primary" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentPlanAction,
|
||||
AgentProvider,
|
||||
ToolCallDetail,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||
@@ -48,6 +52,7 @@ export type StreamItem =
|
||||
| AssistantMessageItem
|
||||
| ThoughtItem
|
||||
| ToolCallItem
|
||||
| PlanItem
|
||||
| TodoListItem
|
||||
| ActivityLogItem
|
||||
| CompactionItem;
|
||||
@@ -168,6 +173,16 @@ export interface TodoListItem {
|
||||
items: TodoEntry[];
|
||||
}
|
||||
|
||||
export interface PlanItem {
|
||||
kind: "plan";
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
provider: AgentProvider;
|
||||
planId: string;
|
||||
text: string;
|
||||
actions?: AgentPlanAction[];
|
||||
}
|
||||
|
||||
export type StreamUpdateSource = "live" | "canonical";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -653,6 +668,34 @@ function appendTodoList(
|
||||
return [...state, entry];
|
||||
}
|
||||
|
||||
function appendPlan(
|
||||
state: StreamItem[],
|
||||
provider: AgentProvider,
|
||||
plan: { planId: string; text: string; actions?: AgentPlanAction[] },
|
||||
timestamp: Date,
|
||||
): StreamItem[] {
|
||||
const existingIndex = state.findIndex(
|
||||
(item) => item.kind === "plan" && item.provider === provider && item.planId === plan.planId,
|
||||
);
|
||||
const entry: PlanItem = {
|
||||
kind: "plan",
|
||||
id: `plan_${plan.planId}`,
|
||||
timestamp,
|
||||
provider,
|
||||
planId: plan.planId,
|
||||
text: plan.text,
|
||||
actions: plan.actions,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...state];
|
||||
next[existingIndex] = entry;
|
||||
return next;
|
||||
}
|
||||
|
||||
return [...state, entry];
|
||||
}
|
||||
|
||||
function reduceTimelineToolCall(
|
||||
state: StreamItem[],
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||
@@ -774,6 +817,8 @@ function reduceTimelineEvent(
|
||||
}));
|
||||
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
|
||||
}
|
||||
case "plan":
|
||||
return finalizeActiveThoughts(appendPlan(state, event.provider, item, timestamp));
|
||||
case "error": {
|
||||
const activity: ActivityLogItem = {
|
||||
kind: "activity_log",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -25,9 +25,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.86",
|
||||
"@getpaseo/protocol": "0.1.86",
|
||||
"@getpaseo/server": "0.1.86",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/server": "0.1.87",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -33,8 +33,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.86",
|
||||
"@getpaseo/relay": "0.1.86",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -255,6 +255,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
custom_mode_icons: true,
|
||||
first_class_plans: true,
|
||||
reasoning_merge_enum: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ import type {
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPlanResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
@@ -363,6 +364,10 @@ type DictationFinishAcceptedPayload = Extract<
|
||||
{ type: "dictation_stream_finish_accepted" }
|
||||
>["payload"];
|
||||
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
||||
type AgentPlanRespondPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.plan.respond.response" }
|
||||
>["payload"];
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
|
||||
@@ -3613,6 +3618,38 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async respondToPlan(
|
||||
agentId: string,
|
||||
planId: string,
|
||||
response: AgentPlanResponse,
|
||||
requestId = `plan-response-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
timeout = 15000,
|
||||
): Promise<AgentPlanRespondPayload> {
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "agent.plan.respond.request",
|
||||
agentId,
|
||||
planId,
|
||||
actionId: response.actionId,
|
||||
...(response.feedback !== undefined ? { feedback: response.feedback } : {}),
|
||||
requestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "agent.plan.respond.response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== requestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Waiting / Streaming Helpers
|
||||
// ============================================================================
|
||||
@@ -4278,6 +4315,7 @@ export class DaemonClient {
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.customModeIcons]: true,
|
||||
[CLIENT_CAPS.firstClassPlans]: true,
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
},
|
||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -313,12 +313,32 @@ export interface CompactionTimelineItem {
|
||||
preTokens?: number;
|
||||
}
|
||||
|
||||
export interface AgentPlanAction {
|
||||
id: string;
|
||||
label: string;
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
}
|
||||
|
||||
export interface PlanTimelineItem {
|
||||
[key: string]: unknown;
|
||||
type: "plan";
|
||||
planId: string;
|
||||
text: string;
|
||||
actions?: AgentPlanAction[];
|
||||
}
|
||||
|
||||
export interface AgentPlanResponse {
|
||||
actionId: string;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string }
|
||||
| { type: "assistant_message"; text: string; messageId?: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| ToolCallTimelineItem
|
||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| PlanTimelineItem
|
||||
| { type: "error"; message: string }
|
||||
| CompactionTimelineItem;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CLIENT_CAPS = {
|
||||
firstClassPlans: "first_class_plans",
|
||||
reasoningMergeEnum: "reasoning_merge_enum",
|
||||
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
|
||||
// a closed enum and crash rendering unknown values; daemon downgrades icons
|
||||
|
||||
@@ -179,11 +179,13 @@ describe("checkout PR schemas", () => {
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
firstClassPlans: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({
|
||||
providersSnapshot: true,
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
firstClassPlans: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,7 @@ import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPlanAction,
|
||||
ProviderStatus,
|
||||
AgentRuntimeInfo,
|
||||
AgentTimelineItem,
|
||||
@@ -334,6 +335,12 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
|
||||
}),
|
||||
]);
|
||||
|
||||
const AgentPlanActionSchema: z.ZodType<AgentPlanAction> = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
||||
});
|
||||
|
||||
export const AgentPermissionRequestPayloadSchema: z.ZodType<
|
||||
AgentPermissionRequest,
|
||||
z.ZodTypeDef,
|
||||
@@ -549,6 +556,12 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodT
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("plan"),
|
||||
planId: z.string(),
|
||||
text: z.string(),
|
||||
actions: z.array(AgentPlanActionSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("error"),
|
||||
message: z.string(),
|
||||
@@ -1324,6 +1337,15 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
||||
response: AgentPermissionResponseSchema,
|
||||
});
|
||||
|
||||
export const AgentPlanRespondRequestMessageSchema = z.object({
|
||||
type: z.literal("agent.plan.respond.request"),
|
||||
agentId: z.string(),
|
||||
planId: z.string(),
|
||||
actionId: z.string(),
|
||||
feedback: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const CheckoutErrorCodeSchema = z.enum([
|
||||
"NOT_GIT_REPO",
|
||||
"NOT_ALLOWED",
|
||||
@@ -1901,6 +1923,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentFeatureRequestMessageSchema,
|
||||
AgentRewindRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
AgentPlanRespondRequestMessageSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
UnsubscribeCheckoutDiffRequestSchema,
|
||||
@@ -2138,6 +2161,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
.object({
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
||||
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
|
||||
firstClassPlans: z.boolean().optional(),
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
@@ -2704,6 +2729,17 @@ export const SendAgentMessageResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentPlanRespondResponseMessageSchema = z.object({
|
||||
type: z.literal("agent.plan.respond.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
planId: z.string(),
|
||||
ok: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WaitForFinishResponseMessageSchema = z.object({
|
||||
type: z.literal("wait_for_finish_response"),
|
||||
payload: z.object({
|
||||
@@ -3693,6 +3729,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CancelAgentResponseMessageSchema,
|
||||
ClearAgentAttentionResponseMessageSchema,
|
||||
SendAgentMessageResponseMessageSchema,
|
||||
AgentPlanRespondResponseMessageSchema,
|
||||
SetVoiceModeResponseMessageSchema,
|
||||
DaemonGetStatusResponseSchema,
|
||||
DaemonGetPairingOfferResponseSchema,
|
||||
@@ -4106,6 +4143,7 @@ export const WSHelloMessageSchema = z.object({
|
||||
.object({
|
||||
voice: z.boolean().optional(),
|
||||
pushNotifications: z.boolean().optional(),
|
||||
[CLIENT_CAPS.firstClassPlans]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -57,10 +57,10 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.86",
|
||||
"@getpaseo/highlight": "0.1.86",
|
||||
"@getpaseo/protocol": "0.1.86",
|
||||
"@getpaseo/relay": "0.1.86",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/highlight": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type AgentLaunchContext,
|
||||
type AgentSlashCommand,
|
||||
type AgentMode,
|
||||
type AgentPlanResponse,
|
||||
type AgentPlanResult,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionResult,
|
||||
@@ -1836,6 +1838,31 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async respondToPlan(
|
||||
agentId: string,
|
||||
planId: string,
|
||||
response: AgentPlanResponse,
|
||||
): Promise<AgentPlanResult | void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (!agent.session.respondToPlan) {
|
||||
throw new Error(`Agent provider '${agent.provider}' does not support plan responses`);
|
||||
}
|
||||
|
||||
const result = await agent.session.respondToPlan(planId, response);
|
||||
|
||||
try {
|
||||
await this.refreshSessionState(agent);
|
||||
} catch {
|
||||
// Ignore refresh errors - state sync after plan response is best effort.
|
||||
}
|
||||
|
||||
this.touchUpdatedAt(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const pendingRun = this.foregroundRuns.getPendingRun(agentId);
|
||||
|
||||
@@ -342,12 +342,27 @@ export interface CompactionTimelineItem {
|
||||
preTokens?: number;
|
||||
}
|
||||
|
||||
export interface AgentPlanAction {
|
||||
id: string;
|
||||
label: string;
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
}
|
||||
|
||||
export interface PlanTimelineItem {
|
||||
[key: string]: unknown;
|
||||
type: "plan";
|
||||
planId: string;
|
||||
text: string;
|
||||
actions?: AgentPlanAction[];
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string }
|
||||
| { type: "assistant_message"; text: string; messageId?: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| ToolCallTimelineItem
|
||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| PlanTimelineItem
|
||||
| { type: "error"; message: string }
|
||||
| CompactionTimelineItem;
|
||||
|
||||
@@ -551,6 +566,15 @@ export interface AgentPermissionResult {
|
||||
followUpPrompt?: AgentPromptInput;
|
||||
}
|
||||
|
||||
export interface AgentPlanResponse {
|
||||
actionId: string;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export interface AgentPlanResult {
|
||||
followUpPrompt?: AgentPromptInput;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
@@ -569,6 +593,7 @@ export interface AgentSession {
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
respondToPlan?(planId: string, response: AgentPlanResponse): Promise<AgentPlanResult | void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
|
||||
41
packages/server/src/server/agent/plan-files.test.ts
Normal file
41
packages/server/src/server/agent/plan-files.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isPlanFilePath, planItemFromToolCall } from "./plan-files.js";
|
||||
|
||||
describe("plan file detection", () => {
|
||||
test("accepts only narrow Paseo and OpenCode plan markdown paths", () => {
|
||||
expect(isPlanFilePath(".paseo/plans/feature.md")).toBe(true);
|
||||
expect(isPlanFilePath("/Users/me/project/.paseo/plans/feature.markdown")).toBe(true);
|
||||
expect(isPlanFilePath(".opencode/plans/refactor.md")).toBe(true);
|
||||
expect(isPlanFilePath("/Users/me/.opencode/plans/refactor.markdown")).toBe(true);
|
||||
|
||||
expect(isPlanFilePath("PLAN.md")).toBe(false);
|
||||
expect(isPlanFilePath("docs/plan.md")).toBe(false);
|
||||
expect(isPlanFilePath(".paseo/notes/feature.md")).toBe(false);
|
||||
expect(isPlanFilePath(".paseo/plans/feature.txt")).toBe(false);
|
||||
});
|
||||
|
||||
test("turns successful plan writes into non-actionable plan items", async () => {
|
||||
const item = await planItemFromToolCall({
|
||||
cwd: "/workspace",
|
||||
homeDir: "/Users/me",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "write-plan",
|
||||
name: "write",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: ".paseo/plans/feature.md",
|
||||
content: "# Plan\n\n- Implement it",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(item).toEqual({
|
||||
type: "plan",
|
||||
planId: "plan-file:.paseo/plans/feature.md",
|
||||
text: "# Plan\n\n- Implement it",
|
||||
});
|
||||
});
|
||||
});
|
||||
70
packages/server/src/server/agent/plan-files.ts
Normal file
70
packages/server/src/server/agent/plan-files.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
const PLAN_FILE_EXTENSIONS = new Set([".md", ".markdown"]);
|
||||
const PLAN_DIRECTORIES = new Set(["/.paseo/plans/", "/.opencode/plans/"]);
|
||||
|
||||
export function isPlanFilePath(filePath: string): boolean {
|
||||
const normalized = normalizePlanPath(filePath);
|
||||
const ext = path.posix.extname(normalized).toLowerCase();
|
||||
if (!PLAN_FILE_EXTENSIONS.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
const searchable = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return Array.from(PLAN_DIRECTORIES).some((dir) => searchable.includes(dir));
|
||||
}
|
||||
|
||||
export async function planItemFromToolCall(params: {
|
||||
item: ToolCallTimelineItem;
|
||||
cwd: string;
|
||||
homeDir: string;
|
||||
}): Promise<AgentTimelineItem | null> {
|
||||
const { item, cwd, homeDir } = params;
|
||||
if (item.status !== "completed") {
|
||||
return null;
|
||||
}
|
||||
const detail = item.detail;
|
||||
if (detail.type !== "write" && detail.type !== "edit") {
|
||||
return null;
|
||||
}
|
||||
if (!isPlanFilePath(detail.filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitContent = detail.type === "write" ? detail.content : undefined;
|
||||
const text = explicitContent ?? (await readPlanFile(detail.filePath, cwd, homeDir));
|
||||
if (!text?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "plan",
|
||||
planId: `plan-file:${normalizePlanPath(detail.filePath)}`,
|
||||
text: text.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlanPath(filePath: string): string {
|
||||
return path.posix.normalize(filePath.replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
async function readPlanFile(
|
||||
filePath: string,
|
||||
cwd: string,
|
||||
homeDir: string,
|
||||
): Promise<string | null> {
|
||||
const candidates = path.isAbsolute(filePath)
|
||||
? [filePath]
|
||||
: [path.resolve(cwd, filePath), path.resolve(homeDir, filePath)];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await fs.readFile(candidate, "utf8");
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -363,6 +363,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
setMode: (modeId) => inner.setMode(modeId),
|
||||
getPendingPermissions: () => inner.getPendingPermissions(),
|
||||
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
|
||||
respondToPlan: inner.respondToPlan?.bind(inner),
|
||||
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
||||
interrupt: () => inner.interrupt(),
|
||||
close: () => inner.close(),
|
||||
|
||||
@@ -997,7 +997,7 @@ test("preserves bypass capability across query restarts triggered by thinking ch
|
||||
}
|
||||
});
|
||||
|
||||
test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
|
||||
test("plan item exposes a resume-bypass action and can return to bypassPermissions", async () => {
|
||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
|
||||
@@ -1023,44 +1023,36 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
|
||||
{},
|
||||
);
|
||||
|
||||
const requestEvent = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested" && event.request.kind === "plan",
|
||||
const planEvent = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "plan",
|
||||
);
|
||||
|
||||
expect(requestEvent).toBeDefined();
|
||||
expect(requestEvent?.request.actions).toEqual([
|
||||
expect(planEvent).toBeDefined();
|
||||
expect(planEvent?.item.type === "plan" ? planEvent.item.actions : undefined).toEqual([
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
intent: "implement",
|
||||
},
|
||||
{
|
||||
id: "implement_resume",
|
||||
label: "Implement with Bypass",
|
||||
behavior: "allow",
|
||||
variant: "secondary",
|
||||
intent: "implement_resume",
|
||||
},
|
||||
]);
|
||||
|
||||
if (!requestEvent) {
|
||||
throw new Error("Expected plan permission request");
|
||||
if (!planEvent || planEvent.item.type !== "plan") {
|
||||
throw new Error("Expected plan item");
|
||||
}
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
|
||||
await session.respondToPermission(requestEvent.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement_resume",
|
||||
});
|
||||
await session.respondToPlan?.(planEvent.item.planId, { actionId: "implement_resume" });
|
||||
|
||||
await expect(pendingResolution).resolves.toMatchObject({
|
||||
behavior: "allow",
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPlanAction,
|
||||
type AgentPlanResponse,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionRequestKind,
|
||||
type AgentPermissionResponse,
|
||||
@@ -897,6 +899,14 @@ function buildClaudePlanPermissionActions(
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildClaudePlanActions(resumeMode: PermissionMode | null): AgentPlanAction[] {
|
||||
return buildClaudePlanPermissionActions(resumeMode).map(({ id, label, variant }) => ({
|
||||
id,
|
||||
label,
|
||||
variant,
|
||||
}));
|
||||
}
|
||||
|
||||
interface TimelineFragment {
|
||||
kind: "assistant" | "reasoning";
|
||||
text: string;
|
||||
@@ -1584,6 +1594,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private toolUseIndexToId = new Map<number, string>();
|
||||
private toolUseInputBuffers = new Map<string, string>();
|
||||
private pendingPermissions = new Map<string, PendingPermission>();
|
||||
private pendingPlans = new Map<string, string>();
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private autonomousTurn: AutonomousTurnState | null = null;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
@@ -1919,82 +1930,140 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
|
||||
const hiddenPlanRequestIds = new Set(this.pendingPlans.values());
|
||||
return Array.from(this.pendingPermissions.values())
|
||||
.filter((entry) => !hiddenPlanRequestIds.has(entry.request.id))
|
||||
.map((entry) => entry.request);
|
||||
}
|
||||
|
||||
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
|
||||
private clearPendingPlanForPermission(requestId: string): void {
|
||||
for (const [planId, pendingRequestId] of this.pendingPlans) {
|
||||
if (pendingRequestId === requestId) {
|
||||
this.pendingPlans.delete(planId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
emitResolution = true,
|
||||
): Promise<void> {
|
||||
const pending = this.pendingPermissions.get(requestId);
|
||||
if (!pending) {
|
||||
throw new Error(`No pending permission request with id '${requestId}'`);
|
||||
}
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.clearPendingPlanForPermission(requestId);
|
||||
pending.cleanup?.();
|
||||
|
||||
if (response.behavior === "allow") {
|
||||
if (pending.request.kind === "plan") {
|
||||
const selectedActionId = response.selectedActionId;
|
||||
const shouldResumePriorMode =
|
||||
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
|
||||
const targetMode: PermissionMode = shouldResumePriorMode
|
||||
? "bypassPermissions"
|
||||
: "acceptEdits";
|
||||
await this.setMode(targetMode);
|
||||
this.pushToolCall(
|
||||
mapClaudeCompletedToolCall({
|
||||
name: "plan_approval",
|
||||
callId: pending.request.id,
|
||||
input: pending.request.input ?? null,
|
||||
output: {
|
||||
approved: true,
|
||||
actionId: selectedActionId ?? "implement",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const updatedInput =
|
||||
pending.request.kind === "question"
|
||||
? normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
response.updatedInput,
|
||||
pending.request.input ?? undefined,
|
||||
)
|
||||
: (response.updatedInput ?? pending.request.input ?? {});
|
||||
const result: PermissionResult = {
|
||||
behavior: "allow",
|
||||
updatedInput,
|
||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||
};
|
||||
pending.resolve(result);
|
||||
await this.resolveAllowedPermission(pending, response);
|
||||
} else {
|
||||
if (pending.request.kind === "tool") {
|
||||
this.pushToolCall(
|
||||
mapClaudeFailedToolCall({
|
||||
name: pending.request.name,
|
||||
callId:
|
||||
(typeof pending.request.metadata?.toolUseId === "string"
|
||||
? pending.request.metadata.toolUseId
|
||||
: null) ?? pending.request.id,
|
||||
input: pending.request.input ?? null,
|
||||
output: null,
|
||||
error: { message: response.message ?? "Permission denied" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const result: PermissionResult = {
|
||||
behavior: "deny",
|
||||
message: response.message ?? "Permission request denied",
|
||||
interrupt: response.interrupt,
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.resolveDeniedPermission(pending, response);
|
||||
}
|
||||
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: "claude",
|
||||
requestId,
|
||||
resolution: response,
|
||||
if (emitResolution) {
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: "claude",
|
||||
requestId,
|
||||
resolution: response,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAllowedPermission(
|
||||
pending: PendingPermission,
|
||||
response: Extract<AgentPermissionResponse, { behavior: "allow" }>,
|
||||
): Promise<void> {
|
||||
if (pending.request.kind === "plan") {
|
||||
const selectedActionId = response.selectedActionId;
|
||||
const shouldResumePriorMode =
|
||||
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
|
||||
const targetMode: PermissionMode = shouldResumePriorMode
|
||||
? "bypassPermissions"
|
||||
: "acceptEdits";
|
||||
await this.setMode(targetMode);
|
||||
this.pushToolCall(
|
||||
mapClaudeCompletedToolCall({
|
||||
name: "plan_approval",
|
||||
callId: pending.request.id,
|
||||
input: pending.request.input ?? null,
|
||||
output: {
|
||||
approved: true,
|
||||
actionId: selectedActionId ?? "implement",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const updatedInput =
|
||||
pending.request.kind === "question"
|
||||
? normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
response.updatedInput,
|
||||
pending.request.input ?? undefined,
|
||||
)
|
||||
: (response.updatedInput ?? pending.request.input ?? {});
|
||||
pending.resolve({
|
||||
behavior: "allow",
|
||||
updatedInput,
|
||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||
});
|
||||
}
|
||||
|
||||
private resolveDeniedPermission(
|
||||
pending: PendingPermission,
|
||||
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
|
||||
): void {
|
||||
if (pending.request.kind === "tool") {
|
||||
this.pushToolCall(
|
||||
mapClaudeFailedToolCall({
|
||||
name: pending.request.name,
|
||||
callId:
|
||||
(typeof pending.request.metadata?.toolUseId === "string"
|
||||
? pending.request.metadata.toolUseId
|
||||
: null) ?? pending.request.id,
|
||||
input: pending.request.input ?? null,
|
||||
output: null,
|
||||
error: { message: response.message ?? "Permission denied" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
pending.resolve({
|
||||
behavior: "deny",
|
||||
message: response.message ?? "Permission request denied",
|
||||
interrupt: response.interrupt,
|
||||
});
|
||||
}
|
||||
|
||||
async respondToPlan(planId: string, response: AgentPlanResponse): Promise<void> {
|
||||
const requestId = this.pendingPlans.get(planId);
|
||||
if (!requestId) {
|
||||
throw new Error(`No pending Claude plan with id '${planId}'`);
|
||||
}
|
||||
this.pendingPlans.delete(planId);
|
||||
|
||||
if (response.actionId === "implement" || response.actionId === "implement_resume") {
|
||||
await this.respondToPermission(
|
||||
requestId,
|
||||
{ behavior: "allow", selectedActionId: response.actionId },
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.actionId === "reject") {
|
||||
await this.respondToPermission(
|
||||
requestId,
|
||||
{ behavior: "deny", selectedActionId: response.actionId, message: response.feedback },
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown Claude plan action '${response.actionId}'`);
|
||||
}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle | null {
|
||||
if (this.persistence) {
|
||||
return this.persistence;
|
||||
@@ -3774,11 +3843,26 @@ class ClaudeAgentSession implements AgentSession {
|
||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||
};
|
||||
|
||||
this.pushEvent({
|
||||
type: "permission_requested",
|
||||
provider: "claude",
|
||||
request,
|
||||
});
|
||||
if (kind === "plan" && typeof input.plan === "string") {
|
||||
const planId = `plan-${randomUUID()}`;
|
||||
this.pendingPlans.set(planId, requestId);
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "plan",
|
||||
planId,
|
||||
text: input.plan,
|
||||
actions: buildClaudePlanActions(this.planResumeMode),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.pushEvent({
|
||||
type: "permission_requested",
|
||||
provider: "claude",
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
return await new Promise<PermissionResult>((resolve, reject) => {
|
||||
const cleanupFns: Array<() => void> = [];
|
||||
@@ -3795,6 +3879,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
const abortHandler = () => {
|
||||
this.pendingPermissions.delete(requestId);
|
||||
for (const [planId, pendingRequestId] of this.pendingPlans) {
|
||||
if (pendingRequestId === requestId) {
|
||||
this.pendingPlans.delete(planId);
|
||||
}
|
||||
}
|
||||
cleanup();
|
||||
reject(new Error("Permission request aborted"));
|
||||
};
|
||||
|
||||
@@ -1989,7 +1989,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
|
||||
test("emits an actionable plan item after a successful Codex plan turn", () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
@@ -2018,27 +2018,20 @@ describe("Codex app-server provider", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
expect(events.at(-2)).toEqual({
|
||||
type: "permission_requested",
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
request: expect.objectContaining({
|
||||
provider: "codex",
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
title: "Plan",
|
||||
input: {
|
||||
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
|
||||
},
|
||||
item: expect.objectContaining({
|
||||
type: "plan",
|
||||
text: "- Inspect the existing auth flow\n- Implement the button behavior",
|
||||
actions: [
|
||||
expect.objectContaining({
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -2082,16 +2075,12 @@ describe("Codex app-server provider", () => {
|
||||
}),
|
||||
);
|
||||
expect(events.at(-2)).toEqual({
|
||||
type: "permission_requested",
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
request: expect.objectContaining({
|
||||
provider: "codex",
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
input: {
|
||||
plan: "- Inspect README\n- Add a short note",
|
||||
},
|
||||
item: expect.objectContaining({
|
||||
type: "plan",
|
||||
text: "- Inspect README\n- Add a short note",
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -2469,7 +2458,7 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
||||
test("responding to a Codex plan item disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
@@ -2486,19 +2475,16 @@ describe("Codex app-server provider", () => {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
const request = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested" && event.request.kind === "plan",
|
||||
const plan = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "plan",
|
||||
);
|
||||
expect(request).toBeDefined();
|
||||
if (!request) {
|
||||
throw new Error("Expected synthetic plan approval permission");
|
||||
expect(plan).toBeDefined();
|
||||
if (!plan || plan.item.type !== "plan") {
|
||||
throw new Error("Expected plan item");
|
||||
}
|
||||
|
||||
const result = await session.respondToPermission(request.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
|
||||
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
@@ -2512,18 +2498,10 @@ describe("Codex app-server provider", () => {
|
||||
expect(result!.followUpPrompt).toEqual(
|
||||
expect.stringContaining("The user approved the plan. Implement it now."),
|
||||
);
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId: request.request.id,
|
||||
resolution: {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
},
|
||||
});
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_resolved" }));
|
||||
});
|
||||
|
||||
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
|
||||
test("responding to a Codex plan item keeps fast mode disabled when it started disabled", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: false },
|
||||
});
|
||||
@@ -2540,19 +2518,16 @@ describe("Codex app-server provider", () => {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
const request = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested" && event.request.kind === "plan",
|
||||
const plan = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "plan",
|
||||
);
|
||||
expect(request).toBeDefined();
|
||||
if (!request) {
|
||||
throw new Error("Expected synthetic plan approval permission");
|
||||
expect(plan).toBeDefined();
|
||||
if (!plan || plan.item.type !== "plan") {
|
||||
throw new Error("Expected plan item");
|
||||
}
|
||||
|
||||
const result = await session.respondToPermission(request.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
|
||||
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
@@ -2608,19 +2583,16 @@ describe("Codex app-server provider", () => {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
const permissionRequest = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested" && event.request.kind === "plan",
|
||||
const plan = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "plan",
|
||||
);
|
||||
expect(permissionRequest).toBeDefined();
|
||||
if (!permissionRequest) {
|
||||
throw new Error("Expected synthetic plan approval permission");
|
||||
expect(plan).toBeDefined();
|
||||
if (!plan || plan.item.type !== "plan") {
|
||||
throw new Error("Expected plan item");
|
||||
}
|
||||
|
||||
const result = await session.respondToPermission(permissionRequest.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
|
||||
expect(result?.followUpPrompt).toEqual(expect.any(String));
|
||||
|
||||
await session.startTurn(result!.followUpPrompt!);
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPlanAction,
|
||||
type AgentPlanResponse,
|
||||
type AgentPlanResult,
|
||||
type McpServerConfig,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPermissionRequest,
|
||||
@@ -41,6 +44,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { planItemFromToolCall } from "../plan-files.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
import {
|
||||
@@ -941,6 +945,10 @@ function buildPlanPermissionActions(options?: {
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildPlanActions(): AgentPlanAction[] {
|
||||
return buildPlanPermissionActions().map(({ id, label, variant }) => ({ id, label, variant }));
|
||||
}
|
||||
|
||||
function buildCodexPlanImplementationPrompt(planText: string): string {
|
||||
const normalizedPlan = normalizePlanMarkdown(planText);
|
||||
if (!normalizedPlan) {
|
||||
@@ -2925,6 +2933,7 @@ 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 pendingPlans = new Map<string, { text: string }>();
|
||||
private pendingManualCompactionStarts = 0;
|
||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||
// Codex can report one completed compaction through both channels:
|
||||
@@ -3187,30 +3196,36 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
private emitSyntheticPlanApprovalRequest(planText: string): void {
|
||||
const requestId = `permission-${randomUUID()}`;
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
provider: CODEX_PROVIDER,
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
title: "Plan",
|
||||
description: "Review the proposed plan before implementation starts.",
|
||||
input: { plan: planText },
|
||||
actions: buildPlanPermissionActions(),
|
||||
metadata: {
|
||||
planText,
|
||||
source: "codex_plan_approval",
|
||||
},
|
||||
};
|
||||
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem): void {
|
||||
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
|
||||
.then((planItem) => {
|
||||
if (planItem) {
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: planItem });
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
|
||||
});
|
||||
}
|
||||
|
||||
this.pendingPermissions.set(requestId, request);
|
||||
this.pendingPermissionHandlers.set(requestId, {
|
||||
resolve: () => undefined,
|
||||
kind: "plan",
|
||||
planText,
|
||||
private emitPlanApprovalItem(planText: string): void {
|
||||
const text = normalizePlanMarkdown(planText);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const planId = `plan-${randomUUID()}`;
|
||||
this.pendingPlans.set(planId, { text });
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: {
|
||||
type: "plan",
|
||||
planId,
|
||||
text,
|
||||
actions: buildPlanActions(),
|
||||
},
|
||||
});
|
||||
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3770,6 +3785,29 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
pending.resolve({ answers: {} });
|
||||
}
|
||||
|
||||
async respondToPlan(
|
||||
planId: string,
|
||||
response: AgentPlanResponse,
|
||||
): Promise<AgentPlanResult | void> {
|
||||
const pending = this.pendingPlans.get(planId);
|
||||
if (!pending) {
|
||||
throw new Error(`No pending Codex app-server plan with id '${planId}'`);
|
||||
}
|
||||
this.pendingPlans.delete(planId);
|
||||
|
||||
if (response.actionId === "implement" || response.actionId === "implement_resume") {
|
||||
return {
|
||||
followUpPrompt: this.preparePlanImplementation({ planText: pending.text }),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.actionId === "reject") {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown Codex plan action '${response.actionId}'`);
|
||||
}
|
||||
|
||||
private handlePlanPermissionResponse(params: {
|
||||
requestId: string;
|
||||
response: AgentPermissionResponse;
|
||||
@@ -4569,7 +4607,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||
} else {
|
||||
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
|
||||
this.emitPlanApprovalItem(this.latestPlanResult.text);
|
||||
}
|
||||
this.emitEvent({
|
||||
type: "turn_completed",
|
||||
@@ -4883,6 +4921,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (timelineItem.type === "tool_call") {
|
||||
this.emitPlanFileItemFromToolCall(timelineItem);
|
||||
}
|
||||
if (timelineItem.type === "assistant_message") {
|
||||
this.pendingAssistantMessageBoundary = true;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("Codex app-server provider (real) plan mode", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => {
|
||||
test("maps gpt-5.4 markdown plans to a normalized plan item instead of todo items", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
|
||||
@@ -50,18 +50,16 @@ describe("Codex app-server provider (real) plan mode", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const planCall = result.timeline.find(
|
||||
(item) => item.type === "tool_call" && item.detail.type === "plan",
|
||||
);
|
||||
const planItem = result.timeline.find((item) => item.type === "plan");
|
||||
|
||||
expect(planCall).toBeDefined();
|
||||
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
|
||||
throw new Error("Expected a plan tool call");
|
||||
expect(planItem).toBeDefined();
|
||||
if (!planItem || planItem.type !== "plan") {
|
||||
throw new Error("Expected a normalized plan item");
|
||||
}
|
||||
|
||||
expect(planCall.detail.text).toContain("Login");
|
||||
expect(planCall.detail.text).toContain("- ");
|
||||
expect(result.finalText).toBe(planCall.detail.text);
|
||||
expect(planItem.text).toContain("Login");
|
||||
expect(planItem.text).toContain("- ");
|
||||
expect(planItem.actions?.some((action) => action.id === "implement")).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
resolveProviderLaunch,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { isPlanFilePath, planItemFromToolCall } from "../plan-files.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
@@ -1954,6 +1955,15 @@ function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): {
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenCodeSessionTrackedByParent(
|
||||
sessionId: string,
|
||||
state: OpenCodeEventTranslationState,
|
||||
): boolean {
|
||||
return (
|
||||
sessionId === state.sessionId || state.subAgentCallIdByChildSessionId?.has(sessionId) === true
|
||||
);
|
||||
}
|
||||
|
||||
function getOpenCodeSubAgentState(
|
||||
callId: string,
|
||||
state: OpenCodeEventTranslationState,
|
||||
@@ -2145,6 +2155,22 @@ function appendOpenCodeToolCallTimelineItem(
|
||||
provider: "opencode",
|
||||
item: timelineItem,
|
||||
});
|
||||
if (
|
||||
timelineItem.status === "completed" &&
|
||||
timelineItem.detail.type === "write" &&
|
||||
timelineItem.detail.content?.trim() &&
|
||||
isPlanFilePath(timelineItem.detail.filePath)
|
||||
) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "plan",
|
||||
planId: `plan-file:${timelineItem.detail.filePath}`,
|
||||
text: timelineItem.detail.content.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) {
|
||||
flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events);
|
||||
}
|
||||
@@ -2438,7 +2464,7 @@ function appendOpenCodePermissionAsked(
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
if (!isOpenCodeSessionTrackedByParent(event.properties.sessionID, state)) {
|
||||
return;
|
||||
}
|
||||
const metadata = readOpenCodeRecord(event.properties.metadata);
|
||||
@@ -3204,9 +3230,28 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.notifySubscribers(e, turnId);
|
||||
if (e.type === "timeline" && e.item.type === "tool_call") {
|
||||
this.emitPlanFileItemFromToolCall(e.item, turnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem, turnId: string): void {
|
||||
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
|
||||
.then((planItem) => {
|
||||
if (planItem) {
|
||||
this.notifySubscribers(
|
||||
{ type: "timeline", provider: "opencode", item: planItem },
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
|
||||
});
|
||||
}
|
||||
|
||||
private finishForegroundTurn(
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
turnId: string,
|
||||
|
||||
@@ -387,6 +387,97 @@ describe("translateOpenCodeEvent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards permission requests from linked OpenCode subagent sessions", () => {
|
||||
const state = createState();
|
||||
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-subagent",
|
||||
sessionID: "session-1",
|
||||
messageID: "message-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
callID: "call-subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
subagent_type: "explore",
|
||||
description: "Explore external config",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID: "child-session-1",
|
||||
info: {
|
||||
id: "child-session-1",
|
||||
parentID: "session-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-child-1",
|
||||
sessionID: "child-session-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/Users/example/.config/nvim"],
|
||||
metadata: {
|
||||
reason: "Need to inspect the requested config directory",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: "perm-child-1",
|
||||
provider: "opencode",
|
||||
name: "external_directory",
|
||||
kind: "tool",
|
||||
title: "Access external directory",
|
||||
description:
|
||||
"Need to inspect the requested config directory - Scope: /Users/example/.config/nvim",
|
||||
input: {
|
||||
patterns: ["/Users/example/.config/nvim"],
|
||||
metadata: {
|
||||
reason: "Need to inspect the requested config directory",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
permission: "external_directory",
|
||||
patterns: ["/Users/example/.config/nvim"],
|
||||
metadata: {
|
||||
reason: "Need to inspect the requested config directory",
|
||||
},
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
actions: openCodePermissionActions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits usage_updated after step-finish parts", () => {
|
||||
const state = createState();
|
||||
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
|
||||
|
||||
273
packages/server/src/server/daemon-e2e/plans.e2e.test.ts
Normal file
273
packages/server/src/server/daemon-e2e/plans.e2e.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { isProviderAvailable } from "./agent-configs.js";
|
||||
import type { PlanTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-plans-"));
|
||||
}
|
||||
|
||||
function waitForPlanMessage(
|
||||
collector: MessageCollector,
|
||||
agentId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<PlanTimelineItem> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const timer = setInterval(() => {
|
||||
const message = collector.messages.find((candidate) => {
|
||||
if (candidate.type !== "agent_stream") return false;
|
||||
if (candidate.payload.agentId !== agentId) return false;
|
||||
return (
|
||||
candidate.payload.event.type === "timeline" &&
|
||||
candidate.payload.event.item.type === "plan"
|
||||
);
|
||||
});
|
||||
if (message?.type === "agent_stream") {
|
||||
const event = message.payload.event;
|
||||
if (event.type === "timeline" && event.item.type === "plan") {
|
||||
clearInterval(timer);
|
||||
resolve(event.item);
|
||||
}
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
clearInterval(timer);
|
||||
reject(new Error(`Timed out waiting for plan item after ${timeoutMs}ms`));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
describe("daemon E2E - first-class plans", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
let collector: MessageCollector;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
collector = createMessageCollector(ctx.client);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
collector.unsubscribe();
|
||||
await ctx.cleanup();
|
||||
}, 60_000);
|
||||
|
||||
test("surfaces an actionable plan and routes the response through the daemon", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Plan E2E",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
collector.clear();
|
||||
await ctx.client.sendMessage(agent.id, "Emit an actionable plan.");
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
const planMessage = collector.messages.find((message) => {
|
||||
if (message.type !== "agent_stream") return false;
|
||||
if (message.payload.agentId !== agent.id) return false;
|
||||
return (
|
||||
message.payload.event.type === "timeline" && message.payload.event.item.type === "plan"
|
||||
);
|
||||
});
|
||||
expect(planMessage?.type).toBe("agent_stream");
|
||||
if (planMessage?.type !== "agent_stream") {
|
||||
throw new Error("Expected plan stream message");
|
||||
}
|
||||
const event = planMessage.payload.event;
|
||||
if (event.type !== "timeline" || event.item.type !== "plan") {
|
||||
throw new Error("Expected normalized plan item");
|
||||
}
|
||||
expect(event.item.actions).toEqual([
|
||||
{ id: "implement", label: "Implement", variant: "primary" },
|
||||
]);
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(
|
||||
timeline.entries.some(
|
||||
(entry) => entry.item.type === "plan" && entry.item.planId === event.item.planId,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const response = await ctx.client.respondToPlan(agent.id, event.item.planId, {
|
||||
actionId: "implement",
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
agentId: agent.id,
|
||||
planId: event.item.planId,
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("surfaces a plan file as a non-actionable plan", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "Plan File E2E",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
collector.clear();
|
||||
await ctx.client.sendMessage(agent.id, "Emit a plan file.");
|
||||
await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const plan = timeline.entries.find(
|
||||
(entry) =>
|
||||
entry.item.type === "plan" && entry.item.planId === "plan-file:.paseo/plans/fake.md",
|
||||
);
|
||||
|
||||
expect(plan?.item).toEqual({
|
||||
type: "plan",
|
||||
planId: "plan-file:.paseo/plans/fake.md",
|
||||
text: "# File plan\n\n- From disk",
|
||||
});
|
||||
const response = await ctx.client.respondToPlan(agent.id, "plan-file:.paseo/plans/fake.md", {
|
||||
actionId: "implement",
|
||||
});
|
||||
expect(response.ok).toBe(false);
|
||||
expect(response.error).toContain("No pending fake plan");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("daemon E2E - first-class plans with real providers", () => {
|
||||
test("real Codex plan mode surfaces a normalized actionable plan", async (context) => {
|
||||
if (!(await isProviderAvailable("codex"))) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const cwd = tmpCwd();
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "real-codex-plan" } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Real Codex Plan E2E",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "medium",
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
|
||||
);
|
||||
await client.waitForFinish(agent.id, 240_000);
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const plan = timeline.entries.find((entry) => entry.item.type === "plan");
|
||||
|
||||
expect(plan?.item.type).toBe("plan");
|
||||
if (!plan || plan.item.type !== "plan") {
|
||||
throw new Error("Expected normalized plan item");
|
||||
}
|
||||
expect(plan.item.text).toContain("Login");
|
||||
expect(plan.item.actions?.some((action) => action.id === "implement")).toBe(true);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("real Claude plan mode surfaces a normalized actionable plan", async (context) => {
|
||||
if (!(await isProviderAvailable("claude"))) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const cwd = tmpCwd();
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const collector = createMessageCollector(client);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "real-claude-plan" } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "Real Claude Plan E2E",
|
||||
modeId: "plan",
|
||||
model: "haiku",
|
||||
});
|
||||
|
||||
collector.clear();
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Create a short implementation plan for a login screen.",
|
||||
"Use plan mode and call ExitPlanMode with a markdown plan.",
|
||||
"Do not edit files.",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const plan = await waitForPlanMessage(collector, agent.id, 120_000);
|
||||
expect(plan.text).toContain("login");
|
||||
expect(plan.actions?.some((action) => action.id === "implement")).toBe(true);
|
||||
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
expect(snapshot.agent?.pendingPermissions ?? []).toEqual([]);
|
||||
|
||||
const response = await client.respondToPlan(agent.id, plan.planId, { actionId: "reject" });
|
||||
expect(response).toMatchObject({
|
||||
agentId: agent.id,
|
||||
planId: plan.planId,
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
} finally {
|
||||
collector.unsubscribe();
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -63,6 +63,49 @@ describe("serializeAgentStreamEvent", () => {
|
||||
expect(serialized.item.messageId).toBe("m1");
|
||||
});
|
||||
|
||||
test("accepts normalized plan timeline items", () => {
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "plan",
|
||||
planId: "plan-1",
|
||||
text: "# Plan\n\n- Ship it",
|
||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
||||
},
|
||||
};
|
||||
|
||||
const serialized = serializeAgentStreamEvent(event);
|
||||
|
||||
expect(serialized).toMatchObject({
|
||||
type: "timeline",
|
||||
item: {
|
||||
type: "plan",
|
||||
planId: "plan-1",
|
||||
text: "# Plan\n\n- Ship it",
|
||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts plan response requests", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "agent.plan.respond.request",
|
||||
agentId: "agent-1",
|
||||
planId: "plan-1",
|
||||
actionId: "implement",
|
||||
requestId: "req-plan-1",
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
type: "agent.plan.respond.request",
|
||||
agentId: "agent-1",
|
||||
planId: "plan-1",
|
||||
actionId: "implement",
|
||||
requestId: "req-plan-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("passes canonical tool_call payloads through unchanged", () => {
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
|
||||
@@ -68,6 +68,7 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
|
||||
import {
|
||||
formatSystemNotificationPrompt,
|
||||
sendPromptToAgent,
|
||||
startAgentRun,
|
||||
waitForAgentRunStartWithTimeout,
|
||||
unarchiveAgentState,
|
||||
} from "./agent/agent-prompt.js";
|
||||
@@ -142,6 +143,8 @@ import {
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentSessionConfig,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type ProviderSnapshotEntry,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
@@ -716,6 +719,45 @@ function parseClientCapabilities(
|
||||
return new Set(result);
|
||||
}
|
||||
|
||||
function projectTimelineItemForClient(
|
||||
item: AgentTimelineItem,
|
||||
capabilities: ReadonlySet<ClientCapability>,
|
||||
): AgentTimelineItem {
|
||||
if (item.type !== "plan" || capabilities.has(CLIENT_CAPS.firstClassPlans)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// COMPAT(firstClassPlans): added in v0.1.82, remove shim after 2026-11-28.
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: item.planId,
|
||||
name: "Plan",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plan",
|
||||
text: item.text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function projectAgentStreamEventForClient(
|
||||
event: AgentStreamEvent,
|
||||
capabilities: ReadonlySet<ClientCapability>,
|
||||
): AgentStreamEvent {
|
||||
if (event.type !== "timeline") {
|
||||
return event;
|
||||
}
|
||||
const item = projectTimelineItemForClient(event.item, capabilities);
|
||||
if (item === event.item) {
|
||||
return event;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
item,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Session represents a single connected client session.
|
||||
* It owns all state management, orchestration logic, and message processing.
|
||||
@@ -1335,7 +1377,11 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
const serializedEvent = serializeAgentStreamEvent(event.event);
|
||||
const projectedEvent = projectAgentStreamEventForClient(
|
||||
event.event,
|
||||
this.clientCapabilities,
|
||||
);
|
||||
const serializedEvent = serializeAgentStreamEvent(projectedEvent);
|
||||
if (!serializedEvent) {
|
||||
return;
|
||||
}
|
||||
@@ -1740,6 +1786,7 @@ export class Session {
|
||||
const promise =
|
||||
this.dispatchVoiceAndControlMessage(msg) ??
|
||||
this.dispatchAgentRewindMessage(msg) ??
|
||||
this.dispatchAgentPlanMessage(msg) ??
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(msg) ??
|
||||
this.dispatchCheckoutMessage(msg) ??
|
||||
@@ -1751,6 +1798,13 @@ export class Session {
|
||||
if (promise) await promise;
|
||||
}
|
||||
|
||||
private dispatchAgentPlanMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
if (msg.type === "agent.plan.respond.request") {
|
||||
return this.handleAgentPlanRespondRequest(msg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "voice_audio_chunk":
|
||||
@@ -4561,6 +4615,47 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentPlanRespondRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "agent.plan.respond.request" }>,
|
||||
): Promise<void> {
|
||||
const { agentId, planId, actionId, feedback, requestId } = msg;
|
||||
try {
|
||||
const result = await this.agentManager.respondToPlan(agentId, planId, { actionId, feedback });
|
||||
this.emit({
|
||||
type: "agent.plan.respond.response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
planId,
|
||||
ok: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.followUpPrompt) {
|
||||
startAgentRun(this.agentManager, agentId, result.followUpPrompt, this.sessionLogger, {
|
||||
replaceRunning: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId, planId, actionId },
|
||||
"Failed to respond to plan",
|
||||
);
|
||||
this.emit({
|
||||
type: "agent.plan.respond.response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
planId,
|
||||
ok: false,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutStatusRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -7565,7 +7660,7 @@ export class Session {
|
||||
hasNewer,
|
||||
entries: entries.map((entry) => ({
|
||||
provider: snapshot.provider,
|
||||
item: entry.item,
|
||||
item: projectTimelineItemForClient(entry.item, this.clientCapabilities),
|
||||
timestamp: entry.timestamp,
|
||||
seqStart: entry.seqStart,
|
||||
seqEnd: entry.seqEnd,
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPlanResponse,
|
||||
AgentPlanResult,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
@@ -304,6 +306,7 @@ class FakeAgentSession implements AgentSession {
|
||||
private memoryMarker: string | null = null;
|
||||
private pendingPermissions: AgentPermissionRequest[] = [];
|
||||
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
|
||||
private pendingPlans = new Map<string, { text: string }>();
|
||||
private readonly historyPath: string;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private nextTurnOrdinal = 0;
|
||||
@@ -522,6 +525,55 @@ class FakeAgentSession implements AgentSession {
|
||||
this.notifySubscribers(completed);
|
||||
}
|
||||
|
||||
private async emitActionablePlanTurn(text: string): Promise<void> {
|
||||
const planId = `fake-plan-${randomUUID()}`;
|
||||
const planText = text.includes("custom plan body") ? "custom plan body" : "# Plan\n\n- Test it";
|
||||
this.pendingPlans.set(planId, { text: planText });
|
||||
|
||||
const planEvent: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
provider: this.providerName,
|
||||
item: {
|
||||
type: "plan",
|
||||
planId,
|
||||
text: planText,
|
||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
||||
},
|
||||
};
|
||||
await this.appendHistoryEvent(planEvent);
|
||||
this.notifySubscribers(planEvent);
|
||||
|
||||
const completed: AgentStreamEvent = {
|
||||
type: "turn_completed",
|
||||
provider: this.providerName,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
};
|
||||
await this.appendHistoryEvent(completed);
|
||||
this.notifySubscribers(completed);
|
||||
}
|
||||
|
||||
private async emitPlanFileTurn(): Promise<void> {
|
||||
const planEvent: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
provider: this.providerName,
|
||||
item: {
|
||||
type: "plan",
|
||||
planId: "plan-file:.paseo/plans/fake.md",
|
||||
text: "# File plan\n\n- From disk",
|
||||
},
|
||||
};
|
||||
await this.appendHistoryEvent(planEvent);
|
||||
this.notifySubscribers(planEvent);
|
||||
|
||||
const completed: AgentStreamEvent = {
|
||||
type: "turn_completed",
|
||||
provider: this.providerName,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
};
|
||||
await this.appendHistoryEvent(completed);
|
||||
this.notifySubscribers(completed);
|
||||
}
|
||||
|
||||
private async resolveToolPermission(tool: {
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
@@ -729,6 +781,16 @@ class FakeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textPrompt.toLowerCase().includes("emit an actionable plan")) {
|
||||
await this.emitActionablePlanTurn(textPrompt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (textPrompt.toLowerCase().includes("emit a plan file")) {
|
||||
await this.emitPlanFileTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = buildToolCallForPrompt(this.providerName, textPrompt);
|
||||
if (tool) {
|
||||
const returnedEarly = await this.emitToolCallTurn(tool, textPrompt);
|
||||
@@ -834,6 +896,20 @@ class FakeAgentSession implements AgentSession {
|
||||
this.permissionGate = null;
|
||||
}
|
||||
|
||||
async respondToPlan(
|
||||
planId: string,
|
||||
response: AgentPlanResponse,
|
||||
): Promise<AgentPlanResult | void> {
|
||||
const pending = this.pendingPlans.get(planId);
|
||||
if (!pending) {
|
||||
throw new Error(`No pending fake plan with id '${planId}'`);
|
||||
}
|
||||
this.pendingPlans.delete(planId);
|
||||
if (response.actionId === "implement") {
|
||||
return { followUpPrompt: `Implement fake plan:\n${pending.text}` };
|
||||
}
|
||||
}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle | null {
|
||||
return buildPersistence(
|
||||
this.providerName,
|
||||
|
||||
@@ -1043,6 +1043,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
providersSnapshot: true,
|
||||
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
|
||||
firstClassPlans: true,
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
|
||||
@@ -73,6 +73,18 @@ const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
|
||||
capabilities: LegacyAgentCapabilityFlagsSchema,
|
||||
});
|
||||
|
||||
const LegacyPlanToolCallSchema = z.object({
|
||||
type: z.literal("tool_call"),
|
||||
callId: z.string(),
|
||||
name: z.string(),
|
||||
status: z.enum(["running", "completed", "failed", "canceled"]),
|
||||
error: z.unknown().nullable(),
|
||||
detail: z.object({
|
||||
type: z.literal("plan"),
|
||||
text: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
interface SessionInternals {
|
||||
handleFetchAgentTimelineRequest: (
|
||||
message: Extract<
|
||||
@@ -232,6 +244,16 @@ function createSessionForWireCompatTest(options?: {
|
||||
timestamp: "2026-05-02T00:00:00.200Z",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
timestamp: "2026-05-02T00:00:00.300Z",
|
||||
item: {
|
||||
type: "plan",
|
||||
planId: "plan-1",
|
||||
text: "# Plan\n\n- Do the thing",
|
||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const session = new Session({
|
||||
@@ -367,6 +389,38 @@ describe("wire compatibility", () => {
|
||||
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
|
||||
});
|
||||
|
||||
test("downgrades plan timeline items for clients that do not declare the capability", async () => {
|
||||
const response = await emitTimelineResponse();
|
||||
|
||||
const entry = response.payload.entries.find((item) => item.seqStart === 4);
|
||||
expect(entry?.item).toEqual({
|
||||
type: "tool_call",
|
||||
callId: "plan-1",
|
||||
name: "Plan",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plan",
|
||||
text: "# Plan\n\n- Do the thing",
|
||||
},
|
||||
});
|
||||
expect(() => LegacyPlanToolCallSchema.parse(entry?.item)).not.toThrow();
|
||||
});
|
||||
|
||||
test("preserves plan timeline items for clients that declare the capability", async () => {
|
||||
const response = await emitTimelineResponse({
|
||||
[CLIENT_CAPS.firstClassPlans]: true,
|
||||
});
|
||||
|
||||
const entry = response.payload.entries.find((item) => item.seqStart === 4);
|
||||
expect(entry?.item).toEqual({
|
||||
type: "plan",
|
||||
planId: "plan-1",
|
||||
text: "# Plan\n\n- Do the thing",
|
||||
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
||||
const parsed = LegacySubAgentToolCallSchema.parse({
|
||||
type: "tool_call",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -31,12 +31,6 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
|
||||
>
|
||||
Changelog
|
||||
</a>
|
||||
<a
|
||||
href="/cloud"
|
||||
className="block text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cloud
|
||||
</a>
|
||||
<a
|
||||
href="/docs/cli"
|
||||
className="block text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -28,12 +28,6 @@ export function SiteHeader() {
|
||||
>
|
||||
Changelog
|
||||
</a>
|
||||
<a
|
||||
href="/cloud"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cloud
|
||||
</a>
|
||||
<a
|
||||
href="/download"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user