mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
831dba9122 | ||
|
|
296f283d44 | ||
|
|
f27c803bf8 | ||
|
|
ce8128910e | ||
|
|
c2d8cbdb2e | ||
|
|
05094fe9e1 | ||
|
|
6027c38f8c | ||
|
|
eae83a53d8 | ||
|
|
17dc2801db | ||
|
|
3e9fd00501 | ||
|
|
307a6e85f9 | ||
|
|
7f11c0c64c | ||
|
|
214f85a150 | ||
|
|
acda34fc70 | ||
|
|
ae4f9e0410 | ||
|
|
b3cdfb203b | ||
|
|
6eb6be77df | ||
|
|
c202bd5ff7 | ||
|
|
cebde228b1 | ||
|
|
08f25f065a | ||
|
|
36327cad00 | ||
|
|
939b5c22db | ||
|
|
5567652c6c | ||
|
|
a65f4c1465 | ||
|
|
253319ee50 | ||
|
|
4c1b11f733 | ||
|
|
6b67e6569b | ||
|
|
f5a28a434d | ||
|
|
638c633a21 | ||
|
|
935e55eb27 | ||
|
|
c4782ec71c | ||
|
|
81ee887d03 | ||
|
|
c2c5bc815e | ||
|
|
bd635cf07e | ||
|
|
ad565c2555 | ||
|
|
ac991e064c | ||
|
|
fadeca82eb | ||
|
|
891dbb0f6e | ||
|
|
140e0ef965 | ||
|
|
8c49f74fd9 | ||
|
|
535774a235 | ||
|
|
fa45ab593b | ||
|
|
a54687d3ef | ||
|
|
6cc81e3ab4 | ||
|
|
7699d2fcbd | ||
|
|
77cdfbcb06 | ||
|
|
cbab9332a9 | ||
|
|
fa0346921a | ||
|
|
c6aff35db2 | ||
|
|
af8509d012 | ||
|
|
0e6aba2886 | ||
|
|
e969f42c68 | ||
|
|
876c08ebc2 | ||
|
|
8ba5df358a | ||
|
|
771acd11a1 | ||
|
|
d4735edd45 | ||
|
|
e36784e510 | ||
|
|
a237285808 | ||
|
|
ee6d8072ed | ||
|
|
c8d259b1df | ||
|
|
2da56634a1 | ||
|
|
27be8c3b1e |
@@ -26,6 +26,9 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/app
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.33 - 2026-03-23
|
||||
|
||||
### Fixed
|
||||
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
||||
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
||||
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
||||
- The notification test button now surfaces errors instead of failing silently.
|
||||
|
||||
## 0.1.32 - 2026-03-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
npm run db:query # Show DB table row counts
|
||||
npm run db:query -- "SELECT ..." # Run arbitrary SQL against PGlite
|
||||
```
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
@@ -46,9 +48,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Always run typecheck after every change.**
|
||||
|
||||
## Orchestrator mode
|
||||
## Debugging
|
||||
|
||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
||||
- Launch agents in the most permissive mode
|
||||
- Set cwd to the repository root
|
||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
||||
|
||||
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||
|
||||
@@ -35,6 +35,24 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
### Database queries
|
||||
|
||||
Run arbitrary SQL against the PGlite database:
|
||||
|
||||
```bash
|
||||
# Show table row counts
|
||||
npm run db:query
|
||||
|
||||
# Run any SQL
|
||||
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||
|
||||
# Point at a specific DB directory
|
||||
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||
```
|
||||
|
||||
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
# Storage Revamp Plan
|
||||
|
||||
Status: working implementation plan
|
||||
|
||||
This document is meant to guide implementation and review. It captures the decisions that have already converged, names the sequencing and test gates that should not be skipped, and leaves open the product and implementation details that are still legitimately undecided.
|
||||
|
||||
## Goals and scope
|
||||
|
||||
The storage revamp moves Paseo from a mixed JSON plus in-memory model to an explicit storage layer backed by Drizzle and PGlite.
|
||||
|
||||
In scope:
|
||||
|
||||
- durable storage for project records
|
||||
- durable storage for workspace records
|
||||
- durable storage for agent snapshot metadata
|
||||
- durable storage for committed agent timeline history
|
||||
- a simplified timeline contract: committed canonical history vs provisional live updates
|
||||
- removal of `epoch`/`reset`/projection-heavy timeline recovery paths
|
||||
- migration and cutover from existing JSON-backed Paseo homes where source data actually exists
|
||||
|
||||
## Non-goals
|
||||
|
||||
Out of scope for this plan:
|
||||
|
||||
- migrating config, keypairs, server identity, or push-token files into the DB
|
||||
- persisting raw provider deltas, chunk streams, or other transport-only live updates
|
||||
- designing a remote/cloud database story beyond keeping the schema portable
|
||||
- committing durable reasoning history in v1 unless product explicitly asks for it later
|
||||
- preserving legacy timeline semantics such as `epoch`, projection metadata, or provider-history rehydrate on refresh/load
|
||||
|
||||
## Current state and migration hazards
|
||||
|
||||
Today the server persists three different kinds of state in three different ways:
|
||||
|
||||
- projects in `projects/projects.json`
|
||||
- workspaces in `projects/workspaces.json`
|
||||
- agent snapshots in `agents/{cwd}/{agent-id}.json`
|
||||
|
||||
Committed timeline history is different. It is still effectively an in-memory `AgentManager` concern:
|
||||
|
||||
- rows live in `agent.timelineRows`
|
||||
- sequence numbers are assigned in memory
|
||||
- retention trimming can drop older rows
|
||||
- `fetchAgentTimeline` still carries `epoch`, `reset`, `gap`, projection mode, and seq-range metadata to compensate for that model
|
||||
|
||||
There are also two migration hazards that should be treated as foundation work, not cleanup:
|
||||
|
||||
1. Agent snapshot persistence is split across multiple writers.
|
||||
`attachAgentStoragePersistence()` flushes `agent_state` snapshots, while `Session` also writes agent records directly for archive/unarchive and related flows. The storage revamp should collapse that into clearer ownership before the DB swap broadens the write surface.
|
||||
|
||||
2. Agent load and refresh still depend on provider history hydration.
|
||||
`Session.ensureAgentLoaded()` resumes or recreates a provider session and then calls `hydrateTimelineFromProvider()`. That makes refresh/load behavior depend on provider-managed history, creates replay and dedup complexity, and blocks a clean storage-owned timeline contract.
|
||||
|
||||
The practical implication is that this is not just a database wiring task. The contract and ownership model have to be simplified first.
|
||||
|
||||
## Converged decisions and contracts
|
||||
|
||||
### Storage choice: Drizzle + PGlite
|
||||
|
||||
This plan assumes Drizzle plus PGlite.
|
||||
|
||||
Why this is the right default:
|
||||
|
||||
- one Postgres-flavored schema and migration pipeline for local and future hosted storage
|
||||
- no native addon packaging burden in the Electron and local-daemon environment
|
||||
- Drizzle keeps the schema in TypeScript and fits the existing codebase style better than a heavier codegen-oriented ORM
|
||||
- PGlite is fast enough for Paseo's local CRUD and timeline query workload
|
||||
|
||||
This does not lock the project into every detail of local database lifecycle yet. It does lock the plan onto a Postgres-shaped schema and Drizzle-based migration workflow.
|
||||
|
||||
### Storage boundary
|
||||
|
||||
The goal is not a grand generic repository framework. The useful seams are:
|
||||
|
||||
- `ProjectRegistry`
|
||||
- `WorkspaceRegistry`
|
||||
- `AgentSnapshotStore`
|
||||
- `AgentTimelineStore`
|
||||
- `WorkspaceReconciliationService`
|
||||
|
||||
The refactor should preserve clear domain ownership:
|
||||
|
||||
- project and workspace records stay distinct because reconciliation and placement logic are distinct
|
||||
- agent snapshot metadata stays separate from timeline history
|
||||
- filesystem reconciliation stays as a service over stored records, not an accidental side effect inside session code
|
||||
|
||||
### Timeline contract
|
||||
|
||||
The room has converged on a simplified contract:
|
||||
|
||||
- `timeline` event with `seq` present means committed canonical history owned by Paseo storage
|
||||
- `timeline` event with no `seq` means provisional live-only state and is never returned by catch-up fetches
|
||||
- committed rows must already be in canonical display/history shape, not raw provider chunk shape
|
||||
- `fetch-after-seq` returns committed rows only
|
||||
- Phase 0 policy: reconnect does not use a subscribe-time provisional seed
|
||||
- if a turn is still active after reconnect, the client resumes from future live provisional updates only
|
||||
- provisional-seed ordering is therefore out of scope for Phase 0 until product explicitly chooses to add that policy
|
||||
- at most one active provisional assistant stream and one active provisional reasoning stream exist per turn
|
||||
- tool progress continues to correlate by existing `callId`
|
||||
- reasoning is live-only for v1 unless product explicitly changes that call later
|
||||
|
||||
### Why `epoch` is removed
|
||||
|
||||
`epoch` exists today because timeline identity is not fully owned by durable storage. It resets when the in-memory window is invalidated or rebuilt, and clients carry that reset logic in public protocol state.
|
||||
|
||||
That is the wrong long-term contract for a DB-backed append-only history. Once committed history is storage-owned and no longer trimmed out from under clients:
|
||||
|
||||
- catch-up can be `after seq`
|
||||
- published sequence numbers do not need to be rewritten
|
||||
- clients do not need `epoch`, `reset`, or projection-derived recovery metadata
|
||||
|
||||
Removing `epoch` is not a cosmetic cleanup. It is the public sign that committed history is finally durable and stable.
|
||||
|
||||
### Why refresh/load must not rehydrate provider history
|
||||
|
||||
Provider history hydration made sense as a stopgap while Paseo did not own durable timeline history. It is now an obstacle.
|
||||
|
||||
Refresh/load must stop rehydrating provider history because it:
|
||||
|
||||
- makes the public history contract depend on provider-specific retention and replay behavior
|
||||
- forces user-message dedup rules and other replay suppression into server logic
|
||||
- creates ambiguity over whether a timeline row is provider-imported history or Paseo-owned committed history
|
||||
- makes reconnect behavior depend on re-importing old provider state instead of reading committed rows from storage
|
||||
|
||||
The replacement contract is simpler:
|
||||
|
||||
- refresh/load restores agent snapshot state from Paseo storage
|
||||
- committed history comes from `AgentTimelineStore`
|
||||
- provisional in-flight state, if any, is subscription-scoped and may be seeded once per subscription according to the chosen policy
|
||||
|
||||
## Timeline consumption contract
|
||||
|
||||
The client model is intentionally two-layered:
|
||||
|
||||
- committed history is the durable tail, keyed by monotonically increasing `seq`
|
||||
- provisional live state is the transient head, carried by unsequenced `timeline` events
|
||||
|
||||
The client keeps a committed cursor, not a provisional cursor. Provisional streaming never advances that committed cursor.
|
||||
|
||||
### Client mental model
|
||||
|
||||
The client stores:
|
||||
|
||||
- the last committed `seq` it has accepted for a given agent
|
||||
- the committed history rows needed for the current view
|
||||
- any current provisional UI state for in-progress assistant, reasoning, or tool activity
|
||||
|
||||
It does not store or reason about a second durable sequence space for provisional data.
|
||||
|
||||
### `fetch-after-seq`
|
||||
|
||||
`fetch-after-seq(agentId, seq)` means:
|
||||
|
||||
- return committed canonical history rows with `seq > cursor`
|
||||
- never return provisional live state
|
||||
- never return raw chunk rows
|
||||
- preserve committed order
|
||||
|
||||
If there are no committed rows after that cursor yet, the result is empty even if an in-progress message is still streaming live.
|
||||
|
||||
### Subscribe-time provisional seed
|
||||
|
||||
A subscribe-time provisional seed, if used by the chosen policy, is a one-time delivery of the current full provisional items for that subscription.
|
||||
|
||||
It is not:
|
||||
|
||||
- a historical replay of provider deltas
|
||||
- a second committed history fetch
|
||||
- a second sequence space
|
||||
|
||||
If emitted, it must arrive before later provisional patch events for that subscription.
|
||||
|
||||
Phase 0 chooses the simpler policy: no subscribe-time provisional seed for now.
|
||||
|
||||
### Reconnect scenarios
|
||||
|
||||
Concrete reconnect example:
|
||||
|
||||
- the client has accepted committed history through `seq = 120`
|
||||
- the committed tail includes rows through `120`
|
||||
- the client UI also shows an in-progress assistant message in the provisional head
|
||||
- that provisional assistant message has no committed seq yet
|
||||
|
||||
Case A: the assistant message finishes while the client is disconnected
|
||||
|
||||
1. The client reconnects with last committed `seq = 120`.
|
||||
2. The client calls `fetch-after-seq(agentId, 120)`.
|
||||
3. The server returns committed row `121`, containing the full finalized assistant message.
|
||||
4. The client accepts row `121` into the durable tail and advances its committed cursor to `121`.
|
||||
5. The stale provisional assistant UI from before disconnect is dropped because the committed row has now superseded it.
|
||||
|
||||
Case B: the assistant message has not finished by the time the client reconnects
|
||||
|
||||
1. The client reconnects with last committed `seq = 120`.
|
||||
2. The client calls `fetch-after-seq(agentId, 120)`.
|
||||
3. The server may legitimately return nothing new yet, because committed row `121` does not exist yet.
|
||||
4. Live streaming resumes.
|
||||
5. In Phase 0, the client continues from fresh live updates only.
|
||||
6. When the assistant message finally completes, it becomes one committed row `121`.
|
||||
|
||||
In both cases:
|
||||
|
||||
- partial streaming before disconnect never advanced the committed cursor
|
||||
- `fetch-after-seq` is about committed history only
|
||||
- provisional streaming is transient UI state, not committed history
|
||||
|
||||
If the daemon restarted or the agent is unloaded:
|
||||
|
||||
- committed history still comes from `AgentTimelineStore`
|
||||
- the client can fetch committed history by seq without the provider session being active
|
||||
- provisional state is absent unless a new live subscription later seeds it
|
||||
|
||||
### Replacement rule without a second sequence space
|
||||
|
||||
The replacement rule is intentionally simple:
|
||||
|
||||
- committed rows are authoritative durable history
|
||||
- provisional UI is best-effort and disposable
|
||||
- when a committed row arrives that corresponds to previously shown provisional state, the committed row becomes the durable representation and the provisional UI is cleared
|
||||
|
||||
This simplicity depends on the agreed invariant that there is at most one active provisional assistant stream and one active provisional reasoning stream per turn.
|
||||
|
||||
This avoids introducing `epoch`, a second durable sequence space, or provider-specific replay semantics.
|
||||
|
||||
## Authority and ownership
|
||||
|
||||
The storage revamp needs explicit authority boundaries so the model cannot drift during implementation.
|
||||
|
||||
- `AgentTimelineStore` owns committed timeline authority
|
||||
- only the committed-history authority path may assign durable `seq`
|
||||
- provisional/live buffering is owned by the runtime/session path that is already handling live provider events; it is not owned by the DB
|
||||
- `AgentSnapshotStore` owns durable agent snapshot persistence
|
||||
- `WorkspaceReconciliationService` owns filesystem-vs-stored-workspace reconciliation
|
||||
- `ProjectRegistry` and `WorkspaceRegistry` own durable structured records for those domains
|
||||
- legacy structured-record import is a bootstrap path owned by the structured-record storage layer, not by ad hoc session code
|
||||
|
||||
Practical implications:
|
||||
|
||||
- providers do not assign committed seqs
|
||||
- clients do not infer committed seq advancement from provisional events
|
||||
- session code may read committed history, but it should not become an alternate authority for committed storage semantics
|
||||
|
||||
## Interfaces and module boundaries
|
||||
|
||||
These are design-level contracts, not implementation-level signatures.
|
||||
|
||||
### `AgentSnapshotStore`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- read and write durable agent snapshot metadata
|
||||
- archive and unarchive snapshot records
|
||||
- list and fetch stored agent snapshots without requiring an active provider session
|
||||
|
||||
Useful method shape:
|
||||
|
||||
- `get(agentId)`
|
||||
- `list(filter?)`
|
||||
- `upsert(snapshotRecord)`
|
||||
- `archive(agentId, archivedAt)`
|
||||
- `unarchive(agentId)`
|
||||
|
||||
### `AgentTimelineStore`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- append finalized committed history rows
|
||||
- fetch committed history by tail, before-seq, or after-seq queries
|
||||
- serve committed history for unloaded or restarted agents
|
||||
- guarantee ordered committed seq allocation per agent
|
||||
|
||||
Useful method shape:
|
||||
|
||||
- `appendCommitted(agentId, item, metadata?) -> committedRow`
|
||||
- `fetchTail(agentId, limit)`
|
||||
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
|
||||
- `fetchAfterSeq(agentId, seq, limit?)`
|
||||
- `getLatestCommittedSeq(agentId)`
|
||||
|
||||
Constraints:
|
||||
|
||||
- no raw chunk rows in durable history
|
||||
- no provisional rows in durable history
|
||||
- seq assignment happens only here or in the narrow authority path directly behind it
|
||||
|
||||
### `ProjectRegistry`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- store and fetch durable project records
|
||||
- upsert, archive, and list project records
|
||||
|
||||
Useful method shape:
|
||||
|
||||
- `get(projectId)`
|
||||
- `list(filter?)`
|
||||
- `upsert(projectRecord)`
|
||||
- `archive(projectId, archivedAt)`
|
||||
|
||||
### `WorkspaceRegistry`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- store and fetch durable workspace records
|
||||
- upsert, archive, and list workspace records
|
||||
|
||||
Useful method shape:
|
||||
|
||||
- `get(workspaceId)`
|
||||
- `list(filter?)`
|
||||
- `upsert(workspaceRecord)`
|
||||
- `archive(workspaceId, archivedAt)`
|
||||
|
||||
### `WorkspaceReconciliationService`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- own both targeted reconcile-on-register or placement-update checks and broader stale-workspace sweeps
|
||||
- compare durable workspace records against filesystem reality
|
||||
- mark stale or missing workspaces according to the chosen policy
|
||||
- keep reconciliation logic out of session orchestration code
|
||||
|
||||
Useful method shape:
|
||||
|
||||
- `reconcileTarget(workspaceId | cwd) -> reconcileResult`
|
||||
- `reconcileSweep() -> reconciliationResult`
|
||||
|
||||
### Timeline-facing session/service seam
|
||||
|
||||
One session-facing timeline contract is useful so the app/CLI path does not need to know storage internals.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- fetch committed history for initial view, older-history pagination, or reconnect
|
||||
- expose live subscription semantics
|
||||
- apply the committed-vs-provisional contract consistently for app and CLI consumers
|
||||
|
||||
Useful method shape:
|
||||
|
||||
- `fetchTail(agentId, limit)`
|
||||
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
|
||||
- `fetchAfterSeq(agentId, seq, limit?)`
|
||||
- `subscribe(agentId, options?)`
|
||||
|
||||
This seam is a consumer-facing contract exposed by the session layer so app and CLI callers do not need to know storage internals. It is not a second storage/service authority for committed timeline semantics.
|
||||
|
||||
## DB design and query shape
|
||||
|
||||
The plan does not need to freeze the final schema, but it should lock the query-driving shape.
|
||||
|
||||
### Main tables expected
|
||||
|
||||
- `projects`
|
||||
- `workspaces`
|
||||
- `agent_snapshots`
|
||||
- `agent_timeline_rows`
|
||||
|
||||
### Fields that matter for core queries
|
||||
|
||||
For `agent_timeline_rows`:
|
||||
|
||||
- `agent_id`
|
||||
- committed `seq`
|
||||
- committed timestamp
|
||||
- canonical item payload
|
||||
- optional item kind or type discriminator only if it helps storage ergonomics without expanding the hot-path query surface
|
||||
|
||||
For `agent_snapshots`:
|
||||
|
||||
- `agent_id`
|
||||
- provider
|
||||
- cwd / workspace linkage
|
||||
- lifecycle/status metadata
|
||||
- timestamps needed for listing and resume decisions
|
||||
- archive state
|
||||
|
||||
For `projects` and `workspaces`:
|
||||
|
||||
- durable IDs
|
||||
- linkage fields
|
||||
- display fields
|
||||
- archive state
|
||||
- timestamps needed for reconciliation and listing
|
||||
|
||||
### Main queries to support efficiently
|
||||
|
||||
Hot-path:
|
||||
|
||||
- fetch committed tail for an agent
|
||||
- fetch older committed history before a known seq for scrollback / pagination
|
||||
- fetch committed rows after a known seq for reconnect/catch-up
|
||||
- fetch committed history for an unloaded or restarted agent through the same per-agent tail/after-seq path
|
||||
- append one finalized committed row
|
||||
- list/fetch agent snapshots for UI and session bootstrap
|
||||
- list/fetch projects and workspaces for the app shell
|
||||
|
||||
Cold-path:
|
||||
|
||||
- legacy structured-record import
|
||||
- archive/unarchive and reconciliation sweeps
|
||||
|
||||
### Likely indexes and why
|
||||
|
||||
- unique ordered `(agent_id, seq)` constraint/index on timeline rows for per-agent seq uniqueness, ordered append, and after-seq fetch
|
||||
- index on agent snapshot status/update time for list views
|
||||
- index on workspace `project_id` for grouped workspace listing
|
||||
- index on archive state where it materially reduces active-record scans
|
||||
|
||||
This is enough to guide implementation without prematurely optimizing secondary filters or analytics-style queries. Per-type filtering is not part of the required v1 hot path.
|
||||
|
||||
### Not optimizing yet
|
||||
|
||||
The v1 design is intentionally not optimizing for:
|
||||
|
||||
- cross-agent global timeline queries
|
||||
- full-text search over timeline history
|
||||
- reasoning-history analytics
|
||||
- provider-specific chunk replay
|
||||
- multi-tenant or remote-hosted query patterns
|
||||
|
||||
## Timeline query scenarios and invariants
|
||||
|
||||
Primary timeline queries:
|
||||
|
||||
### Fetch tail for initial view
|
||||
|
||||
- input: `agentId`, `limit`
|
||||
- result: latest committed canonical rows only
|
||||
- invariant: tail fetch does not depend on an active provider session
|
||||
|
||||
### Fetch after seq for reconnect/catch-up
|
||||
|
||||
- input: `agentId`, last committed `seq`
|
||||
- result: committed canonical rows with greater seq
|
||||
- invariant: no provisional rows are returned
|
||||
- invariant: no gaps or duplicates appear when combined with resumed live streaming
|
||||
|
||||
### Fetch before seq for older-history pagination
|
||||
|
||||
- input: `agentId`, `beforeSeq`, `limit`
|
||||
- result: older committed canonical rows with `seq < beforeSeq`
|
||||
- invariant: older-history pagination uses the same committed-row contract as tail and after-seq reads
|
||||
- invariant: no provisional rows or raw chunk rows are returned
|
||||
|
||||
### Fetch committed history for unloaded or restarted agent
|
||||
|
||||
- input: `agentId`
|
||||
- result: committed history from storage
|
||||
- invariant: provider-history rehydrate is not required
|
||||
|
||||
### Append finalized committed row
|
||||
|
||||
- input: finalized logical history item
|
||||
- result: one committed row with one durable seq
|
||||
- invariant: no raw chunk rows are written as durable history
|
||||
|
||||
Core invariants:
|
||||
|
||||
- provisional streaming does not advance the committed seq cursor
|
||||
- durable history contains finalized canonical rows only
|
||||
- clients recover from disconnect by fetching committed rows after the last committed seq
|
||||
- if nothing finalized while disconnected, `fetch-after-seq` may return nothing and live streaming resumes
|
||||
- if something finalized while disconnected, the returned committed row supersedes stale provisional client UI
|
||||
- no gaps or duplicates are allowed across catch-up plus live resume
|
||||
|
||||
## v1 design non-goals
|
||||
|
||||
The design deliberately does not support these in v1:
|
||||
|
||||
- durable reasoning history
|
||||
- historical backfill of timeline data that never existed in Paseo storage
|
||||
- provider-history rehydrate as a normal refresh/load mechanism
|
||||
- raw chunk durability
|
||||
- multiple simultaneous provisional assistant or reasoning streams beyond the agreed single-stream invariant
|
||||
|
||||
## Phase plan
|
||||
|
||||
Each phase below includes the required goals, sequencing, tests, exit gate, and suggested commit boundary. The phases are intentionally practical rather than over-detailed.
|
||||
|
||||
Suggested commit boundaries are approximate. They are there to keep reviewable slices and clear gates, not to force an artificial commit count if two adjacent steps clearly belong together.
|
||||
|
||||
### Phase 0: Contract and model cleanup before DB wiring
|
||||
|
||||
Goal:
|
||||
Lock the public and internal timeline contract before introducing a new storage backend.
|
||||
|
||||
What changes:
|
||||
|
||||
- remove `epoch` from shared timeline cursor/message shapes
|
||||
- define committed-vs-provisional semantics around `timeline + optional seq`
|
||||
- remove projection-specific public response baggage such as `reset`, `projection`, seq-range collapse metadata, and related epoch gates
|
||||
- update server/session/app catch-up logic to use seq-backed committed history plus provisional head seeding
|
||||
- make reasoning explicitly live-only for v1
|
||||
- document and enforce the single-provisional-stream invariant per kind
|
||||
|
||||
Dependencies:
|
||||
|
||||
- none beyond the contract decisions already converged in the room
|
||||
|
||||
Tests required:
|
||||
|
||||
- shared message parsing tests for the simplified timeline payloads
|
||||
- app reducer tests for committed append, provisional merge, and reconnect without provisional seeding
|
||||
- session/WebSocket tests for `live -> disconnect -> fetch-after-seq -> subscribe -> resume`
|
||||
- explicit no-provisional-seed reconnect test proving that reconnect resumes from committed catch-up plus future live events only
|
||||
|
||||
Exit gate:
|
||||
The client and server can exchange committed and provisional timeline updates without `epoch`, `reset`, or projection metadata, and all timeline catch-up tests pass with the new contract.
|
||||
|
||||
Can happen in parallel:
|
||||
|
||||
- shared message/schema cleanup
|
||||
- app reducer and bootstrap policy cleanup
|
||||
- server session fetch/subscribe cleanup
|
||||
|
||||
Must stay sequential:
|
||||
|
||||
- final protocol shape must settle before DB schema and timeline store work begin
|
||||
|
||||
Suggested commit boundaries:
|
||||
|
||||
- protocol and message schema cleanup
|
||||
- app/session reducer and catch-up logic cleanup
|
||||
- tests locking the new contract
|
||||
|
||||
### Phase 1: Storage boundary refactor with current backends
|
||||
|
||||
Goal:
|
||||
Separate domain ownership and persistence seams while still using the current JSON and in-memory backends.
|
||||
|
||||
What changes:
|
||||
|
||||
- introduce explicit `AgentSnapshotStore` and `AgentTimelineStore` seams
|
||||
- keep `ProjectRegistry` and `WorkspaceRegistry` as concrete domain seams rather than wrapping them in a generic repository abstraction
|
||||
- extract workspace reconciliation into a dedicated `WorkspaceReconciliationService`
|
||||
- collapse duplicate snapshot-writer paths so snapshot persistence ownership is explicit
|
||||
- isolate current in-memory timeline logic behind the timeline store interface
|
||||
- isolate refresh/load dependence on provider history rehydrate behind explicit seams so Phase 4 can remove it cleanly without another structural refactor
|
||||
|
||||
Dependencies:
|
||||
|
||||
- Phase 0 contract and message-shape cleanup should be settled enough that the extracted seams do not preserve `epoch`-era behavior by accident
|
||||
|
||||
Tests required:
|
||||
|
||||
- store contract tests for snapshot store and timeline store
|
||||
- registry contract tests for projects and workspaces
|
||||
- reconciliation tests using real temp directories and real filesystem mutations
|
||||
- refresh/load tests proving any remaining provider-history dependency is isolated behind explicit storage/session seams rather than scattered through the runtime
|
||||
|
||||
Exit gate:
|
||||
The server can run unchanged behavior through explicit storage interfaces, duplicate snapshot-writer ownership is collapsed, and any remaining provider-history rehydrate dependency is isolated to a narrow compatibility path that Phase 4 can delete.
|
||||
|
||||
Can happen in parallel:
|
||||
|
||||
- snapshot-store extraction
|
||||
- reconciliation extraction
|
||||
- timeline-store interface extraction
|
||||
|
||||
Must stay sequential:
|
||||
|
||||
- duplicate snapshot-writer cleanup should land before DB-backed snapshot cutover
|
||||
- timeline store contract should settle before DB timeline work begins
|
||||
|
||||
Suggested commit boundaries:
|
||||
|
||||
- snapshot-store ownership cleanup
|
||||
- reconciliation-service extraction
|
||||
- timeline-store extraction with in-memory adapter
|
||||
|
||||
### Phase 2: DB foundation
|
||||
|
||||
Goal:
|
||||
Land the database runtime, schema, and migration tooling without yet making the DB the source of truth for every domain.
|
||||
|
||||
What changes:
|
||||
|
||||
- add Drizzle schema and migration workflow
|
||||
- add PGlite database bootstrap and lifecycle management
|
||||
- define tables for projects, workspaces, agent snapshots, and committed timeline history
|
||||
- choose indexes based on the first real query set, especially `agent_id + seq`
|
||||
- implement DB-backed adapters for the storage seams introduced in Phase 1
|
||||
|
||||
Dependencies:
|
||||
|
||||
- Phase 1 storage seams should exist before DB adapters become the main implementation path
|
||||
|
||||
Tests required:
|
||||
|
||||
- migration tests on fresh databases
|
||||
- migration tests on incremental schema upgrades
|
||||
- DB adapter tests against a real PGlite database
|
||||
- startup/shutdown tests for DB bootstrap in the daemon environment
|
||||
|
||||
Exit gate:
|
||||
The repo can create, migrate, and query a real PGlite-backed Drizzle database in tests, and the new adapters satisfy the same contract tests as the current backends.
|
||||
|
||||
Suggested commit boundaries:
|
||||
|
||||
- Drizzle and PGlite foundation
|
||||
- schema plus migrations
|
||||
- DB adapters wired behind existing interfaces
|
||||
|
||||
### Phase 3: Structured-record cutover and legacy JSON import
|
||||
|
||||
Goal:
|
||||
Move projects, workspaces, and agent snapshot metadata onto the DB, with explicit import rules from existing Paseo homes.
|
||||
|
||||
What changes:
|
||||
|
||||
- make the DB-backed project/workspace stores authoritative
|
||||
- make the DB-backed agent snapshot store authoritative
|
||||
- import legacy `projects.json`, `workspaces.json`, and agent snapshot JSON into the DB
|
||||
- run workspace reconciliation against stored records and filesystem state
|
||||
- keep archive/unarchive and related metadata flows working through the new store ownership model
|
||||
|
||||
Dependencies:
|
||||
|
||||
- Phase 2 DB bootstrap, migrations, and DB-backed structured-record adapters must be stable first
|
||||
|
||||
Tests required:
|
||||
|
||||
- import tests from existing JSON files into empty DBs
|
||||
- idempotent re-import tests where the DB already contains matching rows
|
||||
- reconciliation tests covering deleted directories and archived-agent edge cases
|
||||
- agent list/fetch/workspace list session tests against DB-backed stores
|
||||
|
||||
Exit gate:
|
||||
A daemon can start from a legacy Paseo home, import structured records into the DB, restart from the same home again without duplication or state drift, and serve project/workspace/agent metadata without depending on JSON files as the source of truth.
|
||||
|
||||
Ordered rollout note:
|
||||
|
||||
- first, project/workspace authority plus reconciliation and import harness
|
||||
- second, restart/idempotence verification on the structured-record path
|
||||
- third, agent snapshot metadata authority
|
||||
- structured records should cut over before timeline storage so the timeline phase can rely on stable DB-backed agent identity and metadata
|
||||
|
||||
Suggested commit boundaries:
|
||||
|
||||
- project/workspace DB cutover plus import harness and reconciliation
|
||||
- agent snapshot DB cutover and restart/idempotence tests
|
||||
|
||||
### Phase 4: Timeline storage and catch-up cutover
|
||||
|
||||
Goal:
|
||||
Move committed timeline history to storage-owned append-only rows and remove provider-history rehydrate from refresh/load.
|
||||
|
||||
What changes:
|
||||
|
||||
- make `AgentTimelineStore` authoritative for committed history
|
||||
- write one committed row per finalized history item
|
||||
- stop storing raw chunk rows as committed history
|
||||
- keep provisional live updates in memory/subscription scope only
|
||||
- make fetch/catch-up query committed rows by `agent_id + seq`
|
||||
- remove provider-history rehydrate from normal refresh/load flows
|
||||
- remove retention-window timeline trimming and epoch-reset behavior for committed history
|
||||
|
||||
Dependencies:
|
||||
|
||||
- Phase 0 contract cleanup
|
||||
- Phase 2 DB foundation
|
||||
- Phase 3 structured-record cutover, so timeline rows attach to stable DB-backed agent metadata and identity
|
||||
|
||||
Tests required:
|
||||
|
||||
- timeline store contract tests against real PGlite
|
||||
- live-to-commit tests for assistant, tool, and reasoning-live-only behavior
|
||||
- reconnect tests covering committed catch-up plus provisional seed ordering
|
||||
- migration/cutover tests proving refresh/load do not import provider history
|
||||
- scenario tests for:
|
||||
- live stream while connected
|
||||
- disconnect after committed seq `N`, reconnect, `fetch-after-seq(N)`, then resume live without gaps or duplicates
|
||||
- daemon restart, then fetch committed history without provider rehydrate
|
||||
- `attach`/`logs` against an agent with committed stored history but no active provider session
|
||||
|
||||
Exit gate:
|
||||
Committed timeline history survives daemon restart, reconnect from a known committed seq yields ordered history with no gaps or duplicates across catch-up plus live resume, including the provisional-seed case, and refresh/load no longer depend on provider history replay.
|
||||
|
||||
Internal sub-gates:
|
||||
|
||||
- write-path parity is established before the DB-backed timeline writer becomes authoritative
|
||||
- `fetch-after-seq` parity is established before legacy committed-history reads are removed
|
||||
- reconnect no-gap/no-duplicate behavior is proven before timeline cutover is considered complete
|
||||
- refresh/load de-rehydrate removal lands only after committed-history write and read authority are stable
|
||||
|
||||
Can happen in parallel:
|
||||
|
||||
- timeline write path
|
||||
- fetch/catch-up query path
|
||||
- CLI/app verification against storage-backed committed history behavior
|
||||
|
||||
Must stay sequential:
|
||||
|
||||
- this phase depends on Phase 0 contract cleanup and Phase 2 DB foundation
|
||||
- it should land after Phase 3 so agent and workspace identity are already DB-backed
|
||||
|
||||
Suggested commit boundaries:
|
||||
|
||||
- gated committed timeline store plus write/read parity work, not yet authoritative until matching read-path coverage exists
|
||||
- authoritative fetch/catch-up cutover together with reconnect scenario coverage
|
||||
- refresh/load de-rehydrate cleanup and related tests
|
||||
|
||||
### Phase 5: Cleanup and removal
|
||||
|
||||
Goal:
|
||||
Delete legacy paths once DB-backed storage and the simplified contract are proven.
|
||||
|
||||
What changes:
|
||||
|
||||
- remove legacy JSON source-of-truth paths for projects, workspaces, and agent snapshots
|
||||
- remove in-memory committed timeline ownership and retention trimming
|
||||
- remove leftover compatibility code and dead reducers from the pre-Phase-0 timeline model
|
||||
- remove provider-history hydration paths that are no longer part of refresh/load
|
||||
- update architecture and developer docs to reflect the new ownership model
|
||||
|
||||
Dependencies:
|
||||
|
||||
- Phase 3 and Phase 4 must both have passed their exit gates
|
||||
|
||||
Tests required:
|
||||
|
||||
- full server typecheck and existing relevant test suites
|
||||
- targeted regression tests for deleted legacy branches
|
||||
- migration tests proving old homes still import correctly into the new model
|
||||
|
||||
Exit gate:
|
||||
There is one durable storage path for structured records and committed timeline history, normal runtime no longer writes legacy JSON or in-memory committed-history branches, and no runtime behavior depends on the removed JSON/in-memory history model.
|
||||
|
||||
Suggested commit boundaries:
|
||||
|
||||
- legacy path removal
|
||||
- doc updates and final contract cleanup
|
||||
|
||||
## Legacy data and import constraints
|
||||
|
||||
The migration story is intentionally narrower than a full historical backfill.
|
||||
|
||||
What can be imported from existing Paseo homes:
|
||||
|
||||
- projects from `projects/projects.json`
|
||||
- workspaces from `projects/workspaces.json`
|
||||
- agent snapshot metadata from agent JSON files
|
||||
|
||||
What cannot be broadly backfilled:
|
||||
|
||||
- committed timeline history that never existed in Paseo storage
|
||||
- raw provider history that would need to be rehydrated on refresh/load
|
||||
- provisional live state from prior daemon lifetimes
|
||||
|
||||
That means the honest timeline migration story is:
|
||||
|
||||
- existing committed history only exists for whatever Paseo has already written under the new timeline store
|
||||
- after cutover, timeline history is durable and append-only
|
||||
- before cutover, old homes do not gain full historical timelines unless a separate import source is defined later
|
||||
|
||||
This is a feature, not a gap. It keeps the new contract storage-owned instead of reintroducing provider-specific replay semantics.
|
||||
|
||||
## Orchestrator audit plan
|
||||
|
||||
Every implementation slice requires an independent audit pass before it is accepted. Audit agents verify; they do not edit files. A phase is not complete until its audit gate passes.
|
||||
|
||||
Audit prompts should be checklist-based and evidence-driven. The point of these passes is to catch regressions, hidden behavior changes, missing tests, two-authority drift, and unnecessary complexity before the next phase begins.
|
||||
|
||||
### Phase 0 audit
|
||||
|
||||
The audit pass must verify:
|
||||
|
||||
- the public contract actually removed `epoch`, `reset`, and projection-heavy response baggage where intended
|
||||
- `timeline` with `seq` vs without `seq` behaves as specified
|
||||
- the explicit `seq = 120 -> 121` reconnect scenario is covered by tests
|
||||
- older-history pagination still works under the committed-history contract
|
||||
- app reducer and session/WebSocket behavior match the agreed contract
|
||||
|
||||
### Phase 1 audit
|
||||
|
||||
The audit pass must verify:
|
||||
|
||||
- no functional behavior changed beyond the intended seam refactor
|
||||
- duplicate snapshot-writer ownership is actually collapsed
|
||||
- reconciliation ownership is actually extracted from `Session`
|
||||
- provider-history dependency is isolated and not still scattered through runtime code
|
||||
- seam contract tests exist and pass
|
||||
|
||||
### Phase 2 audit
|
||||
|
||||
The audit pass must verify:
|
||||
|
||||
- fresh migrations succeed
|
||||
- incremental migrations succeed
|
||||
- DB adapters pass the same contract tests as current backends
|
||||
- bootstrap and shutdown are stable
|
||||
- no DB path is prematurely authoritative unless explicitly intended
|
||||
|
||||
### Phase 3 audit
|
||||
|
||||
The audit pass must verify:
|
||||
|
||||
- import from a legacy Paseo home works
|
||||
- restarting from the same legacy home does not duplicate or drift structured records
|
||||
- project, workspace, and agent metadata behavior matches prior behavior
|
||||
- reconciliation still surfaces stale or missing workspaces correctly
|
||||
- archive, unarchive, list, and fetch flows still work
|
||||
|
||||
### Phase 4 audit
|
||||
|
||||
The audit pass must verify:
|
||||
|
||||
- committed history survives daemon restart
|
||||
- `fetch-after-seq` and `fetch-before-seq` behave correctly
|
||||
- no raw chunk rows are persisted
|
||||
- reconnect satisfies the no-gap/no-duplicate invariant
|
||||
- the `120 -> 121` reconnect scenario is explicitly verified
|
||||
- unloaded or restarted agent history reads work without provider rehydrate
|
||||
- CLI and app history behaviors such as `logs`, `attach`, and timeline fetch still work against committed stored history
|
||||
|
||||
### Phase 5 audit
|
||||
|
||||
The audit pass must verify:
|
||||
|
||||
- normal runtime no longer writes legacy JSON or in-memory committed-history branches
|
||||
- dead compatibility branches are actually gone
|
||||
- relevant test suites and typecheck pass
|
||||
- legacy-home import still works under the final model
|
||||
|
||||
## Open questions and risks
|
||||
|
||||
Open product or implementation questions:
|
||||
|
||||
- should subscribe-time provisional seeding always happen when a turn is active, or only when the client asks for streaming state
|
||||
- do we want a durable system/error timeline row for failed or canceled turns, or should those remain transport-only
|
||||
- do any providers require an extra adapter constraint to uphold the single-provisional-stream invariant
|
||||
|
||||
Main risks and mitigations:
|
||||
|
||||
- risk: the plan quietly recreates projection complexity inside the DB schema
|
||||
mitigation: store canonical committed rows only, and keep raw/provisional data out of durable history
|
||||
|
||||
- risk: refresh/load still leaks provider-history import back into the committed-history path
|
||||
mitigation: make de-rehydrate behavior a named Phase 4 gate with dedicated tests
|
||||
|
||||
- risk: legacy import over-promises timeline backfill
|
||||
mitigation: keep timeline migration constraints explicit and separate from structured-record import
|
||||
|
||||
- risk: the storage refactor turns into a large abstraction exercise
|
||||
mitigation: keep the seam list concrete and domain-shaped; avoid generic repository frameworks
|
||||
|
||||
- risk: sequencing gets inverted and DB work begins before the protocol and ownership model settle
|
||||
mitigation: keep Phase 0 and Phase 1 as hard prerequisites for DB timeline cutover
|
||||
@@ -0,0 +1,170 @@
|
||||
# Agent Event Stream Redesign
|
||||
|
||||
Status: **Implemented** (2026-03-24)
|
||||
|
||||
## Problem
|
||||
|
||||
The Claude provider had three event paths delivering the same events to the agent-manager:
|
||||
|
||||
1. **Foreground stream** (`stream()` → `activeForegroundTurn.queue`)
|
||||
2. **Live event pump** (`streamLiveEvents()` → `liveEventQueue`) fed by the query pump
|
||||
3. **JSONL history poller** (`startLiveHistoryPolling()` → `routeSdkMessageFromPump()`)
|
||||
|
||||
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
|
||||
|
||||
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
|
||||
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
|
||||
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
|
||||
|
||||
Codex and OpenCode were stable because they had ONE event path with no routing decision.
|
||||
|
||||
## Design
|
||||
|
||||
### Core principle
|
||||
|
||||
One event source per provider session. Identity-based turn ownership, not timing-based routing.
|
||||
|
||||
### Provider contract (`AgentSession`)
|
||||
|
||||
```typescript
|
||||
interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
|
||||
// Turn lifecycle
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
interrupt(): Promise<void>;
|
||||
|
||||
// Event delivery (push-based)
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
|
||||
// History (hydration only — never live dispatch)
|
||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||
|
||||
// Run (uses startTurn + subscribe internally)
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
|
||||
// Session metadata (unchanged)
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Method contracts
|
||||
|
||||
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
|
||||
|
||||
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
|
||||
|
||||
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
|
||||
|
||||
#### `subscribe(callback): () => void`
|
||||
|
||||
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
|
||||
|
||||
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
|
||||
|
||||
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
|
||||
|
||||
#### `interrupt(): Promise<void>`
|
||||
|
||||
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
|
||||
|
||||
### Provider-side guarantees
|
||||
|
||||
1. **Per-session ordering**: callbacks invoked in provider event order
|
||||
2. **No concurrent callback execution**: serialized delivery per session
|
||||
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
|
||||
4. **Callback error isolation**: subscriber throws → provider logs and continues
|
||||
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
|
||||
|
||||
### Event tagging
|
||||
|
||||
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
|
||||
|
||||
### User message dedup
|
||||
|
||||
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
|
||||
|
||||
## Manager
|
||||
|
||||
### Single subscription per session
|
||||
|
||||
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
|
||||
|
||||
### Lifecycle projection from turn identity
|
||||
|
||||
- After `startTurn()` resolves: foreground turn is active
|
||||
- On `turn_started` for active foreground turnId: lifecycle = `running`
|
||||
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
|
||||
- On autonomous `turn_started`: lifecycle = `running`
|
||||
- On autonomous terminal: lifecycle = `idle` or `error`
|
||||
|
||||
### `streamAgent()` as filtered view
|
||||
|
||||
```typescript
|
||||
async *streamAgent(agentId, prompt, options) {
|
||||
const { turnId } = await session.startTurn(prompt, options);
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
|
||||
// Foreground turn waiter yields events matching this turnId
|
||||
// Ends when terminal event for turnId arrives
|
||||
}
|
||||
```
|
||||
|
||||
### State model
|
||||
|
||||
| Concept | Implementation |
|
||||
|---------|---------------|
|
||||
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
|
||||
| Lifecycle projection | From turn events via turnId matching |
|
||||
| Cancellation | `session.interrupt()` + await waiter settlement |
|
||||
|
||||
## What was deleted
|
||||
|
||||
- `stream()` from `AgentSession` interface and all providers
|
||||
- `Pushable<T>` async queue from all providers
|
||||
- `streamLiveEvents()` capability
|
||||
- `activeForegroundTurn` + foreground queue in Claude provider
|
||||
- `liveEventQueue` in Claude provider
|
||||
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
|
||||
- `startLiveEventPump()` in manager
|
||||
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
|
||||
- `shouldSuppressLiveUserMessageEcho()` in manager
|
||||
- `startLiveHistoryPolling()` for live dispatch
|
||||
- `snapHistoryOffsetToEnd()`
|
||||
- `pendingRun` as iterator reference
|
||||
|
||||
## Integration tests
|
||||
|
||||
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
|
||||
|
||||
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
|
||||
|
||||
| Test | What it verifies |
|
||||
|------|-----------------|
|
||||
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
|
||||
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
|
||||
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
|
||||
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
|
||||
| Interruption | Start long task → interrupt → turn_canceled arrives |
|
||||
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
|
||||
| Fast-fail | Quick error produces clean terminal, no stale events |
|
||||
| User message dedup | Exactly one user_message with matching text in event log |
|
||||
|
||||
### Invariants (asserted on every test)
|
||||
|
||||
1. For each foreground turnId, exactly ONE `user_message` event
|
||||
2. Every `turn_started` has exactly one matching terminal
|
||||
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
|
||||
4. Autonomous turns between foreground turns are visible with distinct turnIds
|
||||
Generated
+1096
-19
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -37,6 +37,7 @@
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
@@ -44,9 +45,9 @@
|
||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.32",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@getpaseo/server": "0.1.32",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -1,55 +1,23 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function LegacySettingsRoute() {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId]);
|
||||
}, [daemons]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, router, targetServerId]);
|
||||
|
||||
if (preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}, [router, targetServerId]);
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
|
||||
@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
|
||||
function getModeColor(modeId?: string): string {
|
||||
if (!modeId) return "#9ca3af"; // gray
|
||||
|
||||
// Color based on common mode types
|
||||
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
|
||||
if (modeId.includes("code")) return "#22c55e"; // green - writes code
|
||||
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
|
||||
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
|
||||
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
|
||||
return "#3b82f6"; // blue - build/auto
|
||||
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
|
||||
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
|
||||
return "#22c55e"; // green - safest
|
||||
|
||||
return "#9ca3af"; // gray - unknown
|
||||
}
|
||||
|
||||
@@ -537,14 +537,10 @@ export function AgentConfigRow({
|
||||
}, [modeOptions]);
|
||||
|
||||
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
|
||||
for (const model of models) {
|
||||
opts.push({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
return models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
}));
|
||||
}, [models]);
|
||||
|
||||
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
||||
@@ -586,7 +582,7 @@ export function AgentConfigRow({
|
||||
title="Select model"
|
||||
value={selectedModel}
|
||||
options={modelSelectOptions}
|
||||
placeholder="Auto"
|
||||
placeholder={isModelLoading ? "Loading..." : "Select model"}
|
||||
disabled={disabled}
|
||||
isLoading={isModelLoading}
|
||||
onSelect={onSelectModel}
|
||||
@@ -777,10 +773,8 @@ export function ModelDropdown({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
const selectedLabel = selectedModel
|
||||
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
|
||||
: "Automatic";
|
||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
||||
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||
const helperText = error
|
||||
? undefined
|
||||
: isLoading
|
||||
@@ -790,34 +784,20 @@ export function ModelDropdown({
|
||||
: undefined;
|
||||
|
||||
const options = useMemo(() => {
|
||||
const opts: ComboSelectOption[] = [
|
||||
{
|
||||
id: "",
|
||||
label: "Automatic (provider default)",
|
||||
description: "Let the assistant pick the recommended model.",
|
||||
},
|
||||
];
|
||||
for (const model of models) {
|
||||
opts.push({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
return models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
}));
|
||||
}, [models]);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (id === "") {
|
||||
onClear();
|
||||
} else {
|
||||
onSelect(id);
|
||||
}
|
||||
onSelect(id);
|
||||
},
|
||||
[onClear, onSelect],
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
@@ -59,6 +59,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
||||
interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isInputActive: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
@@ -91,6 +92,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
export function AgentInputArea({
|
||||
agentId,
|
||||
serverId,
|
||||
isInputActive,
|
||||
onSubmitMessage,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
@@ -112,8 +114,6 @@ export function AgentInputArea({
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const insets = useSafeAreaInsets();
|
||||
const isScreenFocused = useIsFocused();
|
||||
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||
@@ -127,7 +127,14 @@ export function AgentInputArea({
|
||||
agentDirectoryStatus === "revalidating" ||
|
||||
agentDirectoryStatus === "error_after_ready");
|
||||
|
||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
return {
|
||||
status: agent?.status ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const queuedMessagesRaw = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
||||
@@ -260,7 +267,6 @@ export function AgentInputArea({
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
await client.sendAgentMessage(agentId, text, {
|
||||
messageId: clientMessageId,
|
||||
@@ -274,41 +280,8 @@ export function AgentInputArea({
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
}, [onSubmitMessage]);
|
||||
|
||||
const isAgentRunning = agent?.status === "running";
|
||||
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
|
||||
|
||||
const prevIsAgentRunningRef = useRef(isAgentRunning);
|
||||
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
|
||||
useEffect(() => {
|
||||
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
|
||||
if (agentUpdatedAtMs < previousUpdatedAt) {
|
||||
if (isProcessing && !isAgentRunning) {
|
||||
prevIsAgentRunningRef.current = false;
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wasRunning = prevIsAgentRunningRef.current;
|
||||
let shouldClearProcessing = false;
|
||||
|
||||
if (isProcessing) {
|
||||
const hasEnteredRunning = !wasRunning && isAgentRunning;
|
||||
const hasFreshRunningUpdateWhileRunning =
|
||||
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
|
||||
const hasStoppedRunning = wasRunning && !isAgentRunning;
|
||||
|
||||
shouldClearProcessing =
|
||||
hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning;
|
||||
}
|
||||
|
||||
prevIsAgentRunningRef.current = isAgentRunning;
|
||||
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
|
||||
|
||||
if (shouldClearProcessing) {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
|
||||
const isAgentRunning = agentState.status === "running";
|
||||
const hasAgent = agentState.status !== null;
|
||||
|
||||
const updateQueue = useCallback(
|
||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||
@@ -350,7 +323,7 @@ export function AgentInputArea({
|
||||
message,
|
||||
imageAttachments,
|
||||
forceSend,
|
||||
isAgentRunning: agent?.status === "running",
|
||||
isAgentRunning: agentState.status === "running",
|
||||
// Parent-managed submits are still valid submit paths even when the
|
||||
// transport is disconnected, because the parent decides the failure mode.
|
||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||
@@ -424,7 +397,7 @@ export function AgentInputArea({
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isScreenFocused) {
|
||||
if (!isInputActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -460,7 +433,7 @@ export function AgentInputArea({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[isScreenFocused],
|
||||
[isInputActive],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -472,9 +445,9 @@ export function AgentInputArea({
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
enabled: isScreenFocused,
|
||||
enabled: isInputActive,
|
||||
priority: isMessageInputFocused ? 200 : 100,
|
||||
isActive: () => isScreenFocused,
|
||||
isActive: () => isInputActive,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
@@ -483,7 +456,7 @@ export function AgentInputArea({
|
||||
});
|
||||
|
||||
function handleCancelAgent() {
|
||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
||||
if (!isAgentRunning || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !client) {
|
||||
@@ -497,7 +470,7 @@ export function AgentInputArea({
|
||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||
|
||||
const handleToggleRealtimeVoice = useCallback(() => {
|
||||
if (!voice || !isConnected || !agent) {
|
||||
if (!voice || !isConnected || !hasAgent) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceSwitching) {
|
||||
@@ -514,7 +487,7 @@ export function AgentInputArea({
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}, [agent, agentId, isConnected, serverId, toast, voice]);
|
||||
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
@@ -606,7 +579,7 @@ export function AgentInputArea({
|
||||
|
||||
const rightContent = (
|
||||
<View style={styles.rightControls}>
|
||||
{!isVoiceModeForAgent && agent ? (
|
||||
{!isVoiceModeForAgent && hasAgent ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
@@ -716,7 +689,7 @@ export function AgentInputArea({
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isScreenFocused={isScreenFocused}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function submitAgentInput<TImage>(
|
||||
try {
|
||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
||||
input.clearDraft("sent");
|
||||
input.setIsProcessing(false);
|
||||
return "submitted";
|
||||
} catch (error) {
|
||||
input.onSubmitError?.(error);
|
||||
|
||||
@@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => {
|
||||
});
|
||||
|
||||
describe("normalizeModelId", () => {
|
||||
it("treats empty and default values as unset", () => {
|
||||
it("treats empty values as unset", () => {
|
||||
expect(normalizeModelId("")).toBeNull();
|
||||
expect(normalizeModelId(" default ")).toBeNull();
|
||||
expect(normalizeModelId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns trimmed model ids", () => {
|
||||
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||
expect(normalizeModelId(" default ")).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,4 +69,50 @@ describe("resolveAgentModelSelection", () => {
|
||||
expect(selection.selectedThinkingId).toBe("high");
|
||||
expect(selection.displayThinking).toBe("High");
|
||||
});
|
||||
|
||||
it("falls back to the provider default model label instead of Auto", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: "a",
|
||||
provider: "codex",
|
||||
label: "Model A",
|
||||
isDefault: true,
|
||||
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||
defaultThinkingOptionId: "low",
|
||||
},
|
||||
],
|
||||
runtimeModelId: null,
|
||||
configuredModelId: null,
|
||||
explicitThinkingOptionId: null,
|
||||
});
|
||||
|
||||
expect(selection.displayModel).toBe("Model A");
|
||||
expect(selection.displayThinking).toBe("Low");
|
||||
});
|
||||
|
||||
it("prefers the configured model when runtime model is not in the model list", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: "default",
|
||||
provider: "claude",
|
||||
label: "Default (Sonnet 4.6)",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
],
|
||||
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||
configuredModelId: "default",
|
||||
explicitThinkingOptionId: null,
|
||||
});
|
||||
|
||||
expect(selection.activeModelId).toBe("default");
|
||||
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
|
||||
expect(selection.selectedThinkingId).toBe("low");
|
||||
expect(selection.displayThinking).toBe("Low");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
@@ -104,21 +106,18 @@ function getModeIconColor(
|
||||
palette: {
|
||||
blue: { 500: string };
|
||||
green: { 500: string };
|
||||
amber: { 500: string };
|
||||
red: { 500: string };
|
||||
purple: { 500: string };
|
||||
},
|
||||
): string {
|
||||
switch (colorTier) {
|
||||
case "default":
|
||||
return palette.blue[500];
|
||||
case "safe":
|
||||
return palette.green[500];
|
||||
case "moderate":
|
||||
return palette.amber[500];
|
||||
return palette.blue[500];
|
||||
case "dangerous":
|
||||
return palette.red[500];
|
||||
case "readonly":
|
||||
case "planning":
|
||||
return palette.purple[500];
|
||||
default:
|
||||
return palette.blue[500];
|
||||
@@ -166,8 +165,12 @@ function ControlledStatusBar({
|
||||
const displayModel =
|
||||
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
||||
? "Loading models..."
|
||||
: findOptionLabel(modelOptions, selectedModelId, "Auto");
|
||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
|
||||
: findOptionLabel(modelOptions, selectedModelId, "Select model");
|
||||
const displayThinking = findOptionLabel(
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||
);
|
||||
|
||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||
@@ -603,8 +606,29 @@ function ControlledStatusBar({
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_MODES: AgentMode[] = [];
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
return currentAgent
|
||||
? {
|
||||
provider: currentAgent.provider,
|
||||
cwd: currentAgent.cwd,
|
||||
currentModeId: currentAgent.currentModeId,
|
||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||
model: currentAgent.model,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
const availableModes = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
@@ -631,23 +655,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const models = modelsQuery.data ?? null;
|
||||
|
||||
const displayMode =
|
||||
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
agent?.currentModeId ||
|
||||
"default";
|
||||
|
||||
const modelSelection = resolveAgentModelSelection({
|
||||
models,
|
||||
runtimeModelId: agent?.runtimeInfo?.model,
|
||||
runtimeModelId: agent?.runtimeModelId,
|
||||
configuredModelId: agent?.model,
|
||||
explicitThinkingOptionId: agent?.thinkingOptionId,
|
||||
});
|
||||
|
||||
const modeOptions = useMemo<StatusOption[]>(() => {
|
||||
return (agent?.availableModes ?? []).map((mode) => ({
|
||||
return availableModes.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
}));
|
||||
}, [agent?.availableModes]);
|
||||
}, [availableModes]);
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||
@@ -668,9 +692,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
<ControlledStatusBar
|
||||
provider={agent.provider}
|
||||
modeOptions={
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
}
|
||||
selectedModeId={agent.currentModeId ?? undefined}
|
||||
onSelectMode={(modeId) => {
|
||||
@@ -777,7 +799,7 @@ export function DraftAgentStatusBar({
|
||||
label: definition.label,
|
||||
}));
|
||||
|
||||
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
|
||||
const modelOptions: StatusOption[] = [];
|
||||
for (const model of models) {
|
||||
modelOptions.push({ id: model.id, label: model.label });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
|
||||
|
||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
@@ -30,25 +30,33 @@ export function resolveAgentModelSelection(input: {
|
||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
||||
const runtimeSelectedModel =
|
||||
models && normalizedRuntimeModelId
|
||||
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
|
||||
: null;
|
||||
const preferredModelId =
|
||||
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||
const fallbackModel =
|
||||
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||
const selectedModel =
|
||||
models && preferredModelId
|
||||
? (models.find((model) => model.id === preferredModelId) ?? null)
|
||||
: null;
|
||||
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||
: fallbackModel;
|
||||
|
||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
|
||||
const displayModel =
|
||||
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const selectedThinkingId =
|
||||
const resolvedThinkingId =
|
||||
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
||||
? explicitThinkingOptionId
|
||||
: (selectedModel?.defaultThinkingOptionId ?? null);
|
||||
const selectedThinking =
|
||||
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
||||
const displayThinking =
|
||||
selectedThinking?.label ??
|
||||
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
||||
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
|
||||
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
|
||||
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
@@ -82,7 +82,7 @@ export interface AgentStreamViewHandle {
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
agent: Agent;
|
||||
agent: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
|
||||
@@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8;
|
||||
|
||||
type DrillDownView = { provider: string };
|
||||
|
||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||
if (!models || models.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||
}
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
@@ -66,9 +73,9 @@ export function CombinedModelSelector({
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
if (!models) return isLoading ? "Loading..." : "Auto";
|
||||
if (!models) return isLoading ? "Loading..." : "Select model";
|
||||
const model = models.find((m) => m.id === selectedModel);
|
||||
return model?.label ?? "Auto";
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,8 +66,8 @@ export interface MessageInputProps {
|
||||
autoFocus?: boolean;
|
||||
autoFocusKey?: string;
|
||||
disabled?: boolean;
|
||||
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
|
||||
isScreenFocused?: boolean;
|
||||
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isInputActive?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||
@@ -190,7 +190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
disabled = false,
|
||||
isScreenFocused = true,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
rightContent,
|
||||
voiceServerId,
|
||||
@@ -387,7 +387,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
||||
autoStopWhenHidden: { isVisible: isInputActive },
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -420,6 +420,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
userSelect: Platform.OS === "web" ? "text" : "auto",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -821,7 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||
selectable={false}
|
||||
selectable={Platform.OS === "web" ? undefined : false}
|
||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -150,70 +150,136 @@ export function Autocomplete({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedOption = options[selectedIndex];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={(event) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const optionDetail = removeBoltGlyphs(option.detail);
|
||||
const optionDescription = removeBoltGlyphs(option.description);
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onLayout={(event) => handleRowLayout(index, event)}
|
||||
onPress={() => onSelect(option)}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.item,
|
||||
(hovered || pressed || isSelected) && styles.itemActive,
|
||||
]}
|
||||
>
|
||||
{option.kind === "directory" || option.kind === "file" ? (
|
||||
<View style={styles.itemLeading}>
|
||||
{option.kind === "directory" ? (
|
||||
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.itemMain}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null}
|
||||
</View>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<View style={styles.outerWrapper}>
|
||||
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||
<View style={styles.detailCard}>
|
||||
<Text style={styles.detailLabel}>
|
||||
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
||||
</Text>
|
||||
<Text style={styles.detailDescription}>
|
||||
{removeBoltGlyphs(selectedOption.description)}
|
||||
</Text>
|
||||
{selectedOption.detail ? (
|
||||
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={(event) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const optionDescription = removeBoltGlyphs(option.description);
|
||||
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onLayout={(event) => handleRowLayout(index, event)}
|
||||
onPress={() => onSelect(option)}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.item,
|
||||
(hovered || pressed || isSelected) && styles.itemActive,
|
||||
]}
|
||||
>
|
||||
{isFileOrDir ? (
|
||||
<>
|
||||
<View style={styles.itemLeading}>
|
||||
{option.kind === "directory" ? (
|
||||
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.itemMain}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{removeBoltGlyphs(option.detail) ? (
|
||||
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.itemMainRow}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
outerWrapper: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailCard: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
detailLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
detailDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
detailHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
container: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
@@ -236,12 +302,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
itemActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemMainRow: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -261,6 +334,11 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
itemDescriptionInline: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
emptyItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
|
||||
@@ -525,9 +525,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
cursor: { seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||
@@ -753,13 +752,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||
(agentId: string, cursor: { endSeq: number }) => {
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
cursor: { seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||
@@ -862,7 +860,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === result.cursor.epoch &&
|
||||
current.startSeq === result.cursor.startSeq &&
|
||||
current.endSeq === result.cursor.endSeq
|
||||
) {
|
||||
@@ -967,7 +964,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const { agentId, event, timestamp, seq } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
if (
|
||||
@@ -1009,7 +1006,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const result = processAgentStreamEvent({
|
||||
event: streamEvent,
|
||||
seq,
|
||||
epoch,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
@@ -1033,8 +1029,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (
|
||||
current &&
|
||||
typeof seq === "number" &&
|
||||
typeof epoch === "string" &&
|
||||
current.epoch === epoch &&
|
||||
seq >= current.startSeq &&
|
||||
seq <= current.endSeq
|
||||
) {
|
||||
@@ -1043,7 +1037,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === nextCursor.epoch &&
|
||||
current.startSeq === nextCursor.startSeq &&
|
||||
current.endSeq === nextCursor.endSeq
|
||||
) {
|
||||
|
||||
@@ -9,13 +9,9 @@ import {
|
||||
type TimelineCursor,
|
||||
} from "./session-stream-reducers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||
return {
|
||||
seqStart: seq,
|
||||
seq,
|
||||
provider: "claude",
|
||||
item: { type, text },
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
@@ -33,22 +29,30 @@ function makeTimelineEvent(
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
||||
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text },
|
||||
} as AgentStreamEventPayload;
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "shell",
|
||||
status,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "pwd",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
direction: "after",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
event: makeTimelineEvent("hello"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
timestamp: new Date(2000),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processTimelineResponse", () => {
|
||||
it("returns error path when payload.error is set", () => {
|
||||
const result = processTimelineResponse({
|
||||
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.head).toBe(baseTimelineInput.currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns error with no init resolution when no deferred exists", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
error: "timeout",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBe("timeout");
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces tail and clears head when reset=true", () => {
|
||||
const existingTail: StreamItem[] = [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "old",
|
||||
text: "old message",
|
||||
timestamp: new Date(500),
|
||||
},
|
||||
];
|
||||
const existingHead: StreamItem[] = [
|
||||
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
|
||||
const provisionalHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-1",
|
||||
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: existingTail,
|
||||
currentHead: existingHead,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeTimelineEntry(1, "first"),
|
||||
makeTimelineEntry(2, "second"),
|
||||
makeTimelineEntry(3, "third"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).not.toBe(existingTail);
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: provisionalHead,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "tail",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 5 },
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
||||
},
|
||||
});
|
||||
|
||||
// Bootstrap tail replaces
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
|
||||
// Should have catch-up side effect
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 5,
|
||||
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
|
||||
expect(catchUp).toEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("appends incrementally for contiguous seqs", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
|
||||
it("prepends older committed history for before pagination", () => {
|
||||
const currentTail: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "tail-3",
|
||||
text: "newer",
|
||||
timestamp: new Date(3000),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
});
|
||||
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(10, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
// Gap should trigger catch-up
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale entries silently", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
|
||||
},
|
||||
});
|
||||
|
||||
// No new items appended (all dropped as stale)
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("drops entries with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-2",
|
||||
entries: [makeTimelineEntry(6, "different epoch")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves init when deferred matches direction", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.initResolution).toBe("resolve");
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resolve init when directions differ (before vs after)", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
currentTail,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
entries: [],
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
entries: [
|
||||
makeTimelineEntry(1, "hello", "user_message"),
|
||||
makeTimelineEntry(2, "older"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// "before" direction doesn't match "after" initRequestDirection,
|
||||
// and "before" is not a bootstrap tail path, so init should NOT resolve
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(false);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
});
|
||||
expect(result.tail).toHaveLength(3);
|
||||
expect(result.tail[0]?.kind).toBe("user_message");
|
||||
expect(result.tail[1]?.kind).toBe("assistant_message");
|
||||
expect(result.tail[2]).toBe(currentTail[0]);
|
||||
});
|
||||
|
||||
it("clears initializing even without deferred", () => {
|
||||
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
expect(result.initResolution).toBe(null);
|
||||
});
|
||||
|
||||
it("always includes flush_pending_updates side effect on success", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes cursor when no existing cursor on first entries", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
|
||||
startSeq: 121,
|
||||
endSeq: 121,
|
||||
entries: [makeTimelineEntry(121, "finalized reply")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "still streaming",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toBe(currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
});
|
||||
|
||||
it("requests catch-up when committed rows arrive with a forward gap", () => {
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: 125,
|
||||
endSeq: 125,
|
||||
entries: [makeTimelineEntry(125, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processAgentStreamEvent", () => {
|
||||
it("passes through non-timeline events without cursor changes", () => {
|
||||
const turnEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
it("treats seq-less timeline events as provisional head updates", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnEvent,
|
||||
event: makeTimelineEvent("partial"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "partial",
|
||||
});
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts timeline event with cursor advance", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("new chunk"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
event: makeTimelineEvent("finalized reply"),
|
||||
seq: 121,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
it("replaces provisional tool progress when the committed tool row arrives", () => {
|
||||
const provisional = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("running"),
|
||||
seq: undefined,
|
||||
});
|
||||
|
||||
const committed = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("completed"),
|
||||
seq: 8,
|
||||
currentHead: provisional.head,
|
||||
currentTail: provisional.tail,
|
||||
currentCursor: { startSeq: 1, endSeq: 7 },
|
||||
});
|
||||
|
||||
expect(committed.head).toEqual([]);
|
||||
expect(committed.tail).toHaveLength(1);
|
||||
expect(committed.tail[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
callId: "call-1",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requests catch-up when a committed live row skips ahead", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("far ahead"),
|
||||
seq: 10,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
seq: 125,
|
||||
currentCursor: { startSeq: 1, endSeq: 120 },
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale timeline event", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("old"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears provisional head on terminal turn events without committing it to tail", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
},
|
||||
currentHead: [
|
||||
{
|
||||
kind: "thought",
|
||||
id: "reasoning-1",
|
||||
text: "thinking",
|
||||
timestamp: new Date(1000),
|
||||
status: "loading",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops timeline event with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("wrong epoch"),
|
||||
seq: 6,
|
||||
epoch: "epoch-2",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializes cursor when none exists", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("first"),
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: undefined,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives optimistic idle status on turn_completed for running agent", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent).not.toBe(null);
|
||||
expect(result.agent!.status).toBe("idle");
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(2000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("derives optimistic error status on turn_failed for running agent", () => {
|
||||
const turnFailedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: "something broke",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnFailedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("does not change agent when status is not running", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "idle",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("does not change agent when no agent is provided", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: null,
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves updatedAt when agent timestamp is newer than event", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(5000),
|
||||
lastActivityAt: new Date(5000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(5000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
|
||||
});
|
||||
|
||||
it("does not produce agent patch for non-terminal events", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("just text"),
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.tail).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -12,38 +12,25 @@ import {
|
||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared cursor type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineCursor = {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-effect discriminated unions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineReducerSideEffect =
|
||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
||||
| { type: "catch_up"; cursor: { endSeq: number } }
|
||||
| { type: "flush_pending_updates" };
|
||||
|
||||
export type AgentStreamReducerSideEffect = {
|
||||
type: "catch_up";
|
||||
cursor: { epoch: string; endSeq: number };
|
||||
cursor: { endSeq: number };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type TimelineResponseEntry = {
|
||||
seqStart: number;
|
||||
seq: number;
|
||||
provider: string;
|
||||
item: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
|
||||
payload: {
|
||||
agentId: string;
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
startCursor: { seq: number } | null;
|
||||
endCursor: { seq: number } | null;
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
entries: TimelineResponseEntry[];
|
||||
error: string | null;
|
||||
};
|
||||
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error path: reject init and leave stream state unchanged
|
||||
// ------------------------------------------------------------------
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Convert entries to timeline units
|
||||
// ------------------------------------------------------------------
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seqStart,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: typeof timelineUnits,
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derive bootstrap policy (replace vs incremental)
|
||||
// ------------------------------------------------------------------
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
reset: payload.reset,
|
||||
epoch: payload.epoch,
|
||||
endCursor: payload.endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
const replace = bootstrapPolicy.replace;
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (replace) {
|
||||
// ----------------------------------------------------------------
|
||||
// Replace path: full hydration from scratch
|
||||
// ----------------------------------------------------------------
|
||||
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
|
||||
source: "canonical",
|
||||
});
|
||||
nextHead = [];
|
||||
|
||||
if (payload.startCursor && payload.endCursor) {
|
||||
nextCursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: payload.startCursor.seq,
|
||||
endSeq: payload.endCursor.seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else {
|
||||
nextCursor = null;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (timelineUnits.length > 0) {
|
||||
// ----------------------------------------------------------------
|
||||
// Incremental append path
|
||||
// ----------------------------------------------------------------
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { epoch: string; endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
|
||||
epoch: payload.epoch,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale" || decision === "drop_epoch") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
if (decision === "init") {
|
||||
cursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: unit.seq,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!cursor) {
|
||||
continue;
|
||||
}
|
||||
cursor = {
|
||||
...cursor,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
cursor &&
|
||||
(!currentCursor ||
|
||||
currentCursor.epoch !== cursor.epoch ||
|
||||
currentCursor.startSeq !== cursor.startSeq ||
|
||||
currentCursor.endSeq !== cursor.endSeq)
|
||||
) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flush pending agent updates side effect
|
||||
// ------------------------------------------------------------------
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Init resolution
|
||||
// ------------------------------------------------------------------
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
reset: payload.reset,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessAgentStreamEventInput {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
function cursorsEqual(
|
||||
left: TimelineCursor | null | undefined,
|
||||
right: TimelineCursor | null | undefined,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
|
||||
}
|
||||
|
||||
function removeSupersededProvisionalItems(
|
||||
head: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
): StreamItem[] {
|
||||
if (head.length === 0 || event.type !== "timeline") {
|
||||
return head;
|
||||
}
|
||||
|
||||
let nextHead = head;
|
||||
if (event.item.type === "assistant_message") {
|
||||
nextHead = head.filter((item) => item.kind !== "assistant_message");
|
||||
} else if (event.item.type === "tool_call") {
|
||||
const committedToolCall = event.item;
|
||||
nextHead = head.filter(
|
||||
(item) =>
|
||||
item.kind !== "tool_call" ||
|
||||
item.payload.source !== "agent" ||
|
||||
item.payload.data.callId !== committedToolCall.callId,
|
||||
);
|
||||
}
|
||||
|
||||
return nextHead.length === head.length ? head : nextHead;
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seq,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
endSeq: payload.endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (bootstrapPolicy.replace) {
|
||||
nextTail = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextHead = [];
|
||||
nextCursor =
|
||||
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: payload.endSeq,
|
||||
}
|
||||
: null;
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (payload.direction === "before") {
|
||||
const prepended = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
|
||||
const derivedCursor =
|
||||
typeof payload.startSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
|
||||
}
|
||||
: currentCursor;
|
||||
nextCursor = derivedCursor;
|
||||
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
|
||||
} else if (timelineUnits.length > 0) {
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { endSeq: cursor.endSeq } : null,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
cursor =
|
||||
decision === "init"
|
||||
? { startSeq: unit.seq, endSeq: unit.seq }
|
||||
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
|
||||
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (cursor && !cursorsEqual(currentCursor, cursor)) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution: shouldResolveDeferredInit ? "resolve" : null,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
|
||||
|
||||
let shouldApplyStreamEvent = true;
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let changedTail = false;
|
||||
let changedHead = false;
|
||||
let nextTimelineCursor: TimelineCursor | null = null;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Timeline sequencing gate
|
||||
// ------------------------------------------------------------------
|
||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
||||
if (event.type === "timeline" && typeof seq === "number") {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "init") {
|
||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
cursorChanged = true;
|
||||
} else if (decision === "accept") {
|
||||
nextTimelineCursor = {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else if (decision === "gap") {
|
||||
shouldApplyStreamEvent = false;
|
||||
if (decision === "gap") {
|
||||
if (currentCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: {
|
||||
epoch: currentCursor.epoch,
|
||||
endSeq: currentCursor.endSeq,
|
||||
},
|
||||
cursor: { endSeq: currentCursor.endSeq },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// drop_stale or drop_epoch
|
||||
shouldApplyStreamEvent = false;
|
||||
} else if (decision !== "drop_stale") {
|
||||
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
|
||||
source: "canonical",
|
||||
});
|
||||
changedTail = nextTail !== currentTail;
|
||||
|
||||
nextHead = removeSupersededProvisionalItems(currentHead, event);
|
||||
changedHead = nextHead !== currentHead;
|
||||
|
||||
nextTimelineCursor =
|
||||
decision === "init"
|
||||
? { startSeq: seq, endSeq: seq }
|
||||
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
|
||||
}
|
||||
} else if (event.type === "timeline") {
|
||||
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
|
||||
source: "live",
|
||||
});
|
||||
changedHead = nextHead !== currentHead;
|
||||
} else if (
|
||||
(event.type === "turn_completed" ||
|
||||
event.type === "turn_canceled" ||
|
||||
event.type === "turn_failed") &&
|
||||
currentHead.length > 0
|
||||
) {
|
||||
nextHead = [];
|
||||
changedHead = true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
})
|
||||
: {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
changedTail: false,
|
||||
changedHead: false,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Optimistic lifecycle status
|
||||
// ------------------------------------------------------------------
|
||||
let agentPatch: AgentPatch | null = null;
|
||||
let agentChanged = false;
|
||||
|
||||
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
|
||||
}
|
||||
|
||||
return {
|
||||
tail,
|
||||
head,
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
changedTail,
|
||||
changedHead,
|
||||
cursor: nextTimelineCursor,
|
||||
|
||||
@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
deriveInitialTimelineRequest,
|
||||
shouldResolveTimelineInit,
|
||||
} from "./session-timeline-bootstrap-policy";
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "after",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
describe("deriveInitialTimelineRequest", () => {
|
||||
it("uses tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("uses catch-up after the committed cursor once history is synced", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
||||
const advancedCursor = { endSeq: 205 };
|
||||
const tailSeqStart = 101;
|
||||
const tailSeqEnd = 200;
|
||||
|
||||
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: advancedCursor,
|
||||
epoch: "epoch-1",
|
||||
seq,
|
||||
});
|
||||
if (decision === "accept" || decision === "init") {
|
||||
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
endSeq: 200,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 200,
|
||||
});
|
||||
});
|
||||
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
endSeq: 200,
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "tail",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "after",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
|
||||
} | null;
|
||||
|
||||
type InitialTimelineCursor = {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
|
||||
initialTimelineLimit: number;
|
||||
}): {
|
||||
direction: "tail" | "after";
|
||||
cursor?: { epoch: string; seq: number };
|
||||
cursor?: { seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
} {
|
||||
if (!hasAuthoritativeHistory || !cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
||||
cursor: { seq: cursor.seq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: BootstrapTailCursor;
|
||||
endSeq: number | null;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
catchUpCursor: { endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("accepts contiguous forward seq", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 4 },
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("accept");
|
||||
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops stale seq older than the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 8 },
|
||||
seq: 7,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops duplicate replay seq equal to the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 8 },
|
||||
seq: 8,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
});
|
||||
|
||||
it("drops epoch mismatch", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-2",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("drop_epoch");
|
||||
});
|
||||
|
||||
it("initializes when cursor is null", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: null,
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
}),
|
||||
).toBe("init");
|
||||
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("classifies forward gaps", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 4 },
|
||||
seq: 9,
|
||||
}),
|
||||
).toBe("gap");
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
export type SessionTimelineSeqCursor =
|
||||
| {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
|
||||
|
||||
export function classifySessionTimelineSeq({
|
||||
cursor,
|
||||
epoch,
|
||||
seq,
|
||||
}: {
|
||||
cursor: SessionTimelineSeqCursor;
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}): SessionTimelineSeqDecision {
|
||||
if (!cursor) {
|
||||
return "init";
|
||||
}
|
||||
if (cursor.epoch !== epoch) {
|
||||
return "drop_epoch";
|
||||
}
|
||||
if (seq <= cursor.endSeq) {
|
||||
return "drop_stale";
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function DesktopPermissionsSection() {
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
testNotificationError,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
@@ -58,6 +59,11 @@ export function DesktopPermissionsSection() {
|
||||
void sendTestNotification();
|
||||
}}
|
||||
/>
|
||||
{testNotificationError ? (
|
||||
<Text style={[styles.errorText, { color: theme.colors.destructive }]}>
|
||||
{testNotificationError}
|
||||
</Text>
|
||||
) : null}
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
showBorder
|
||||
@@ -80,4 +86,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface UseDesktopPermissionsReturn {
|
||||
isRefreshing: boolean;
|
||||
requestingPermission: DesktopPermissionKind | null;
|
||||
isSendingTestNotification: boolean;
|
||||
testNotificationError: string | null;
|
||||
refreshPermissions: () => Promise<void>;
|
||||
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||
sendTestNotification: () => Promise<void>;
|
||||
@@ -112,22 +113,25 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
[isDesktop, refreshPermissions],
|
||||
);
|
||||
|
||||
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingTestNotification(true);
|
||||
setTestNotificationError(null);
|
||||
try {
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
});
|
||||
if (!sent) {
|
||||
console.warn("[Settings] Desktop test notification was not delivered");
|
||||
setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to send desktop test notification", error);
|
||||
setTestNotificationError("Failed to send notification.");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSendingTestNotification(false);
|
||||
@@ -149,6 +153,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
testNotificationError,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
|
||||
@@ -80,20 +80,14 @@ describe("useAgentFormState", () => {
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("keeps provider thinking preference when it is valid for the effective model", () => {
|
||||
it("prefers provider defaults on fresh drafts", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
thinkingOptionId: "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
@@ -114,20 +108,14 @@ describe("useAgentFormState", () => {
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
@@ -151,7 +139,7 @@ describe("useAgentFormState", () => {
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("normalizes legacy model id 'default' from initial values to auto", () => {
|
||||
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
{ model: "default" },
|
||||
{ provider: "codex" },
|
||||
@@ -175,20 +163,13 @@ describe("useAgentFormState", () => {
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
|
||||
it("normalizes legacy model id 'default' to the provider default model", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ model: "default" },
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
@@ -209,7 +190,76 @@ describe("useAgentFormState", () => {
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
{ thinkingOptionId: "low" },
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("leaves thinking unset when the model exposes options without a provider default", () => {
|
||||
const claudeModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "default",
|
||||
label: "Default (Sonnet 4.6)",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "claude" },
|
||||
claudeModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "claude",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("default");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("resolves provider only from allowed provider map", () => {
|
||||
|
||||
@@ -11,7 +11,11 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
||||
import {
|
||||
useFormPreferences,
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} from "./use-form-preferences";
|
||||
|
||||
// Explicit overrides from URL params or "New Agent" button
|
||||
export interface FormInitialValues {
|
||||
@@ -102,7 +106,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "
|
||||
|
||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
@@ -117,6 +121,10 @@ function resolveDefaultModel(
|
||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||
}
|
||||
|
||||
function resolveEffectiveModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
modelId: string,
|
||||
@@ -134,9 +142,31 @@ function resolveEffectiveModel(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveThinkingOptionId(args: {
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
modelId: string;
|
||||
requestedThinkingOptionId: string;
|
||||
}): string {
|
||||
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
if (thinkingOptions.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||
if (
|
||||
normalizedThinkingOptionId &&
|
||||
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||
) {
|
||||
return normalizedThinkingOptionId;
|
||||
}
|
||||
|
||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that resolves form state from multiple data sources.
|
||||
* Priority: explicit (URL params) > preferences > provider defaults > fallback
|
||||
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
||||
*
|
||||
* Only resolves fields that haven't been user-modified.
|
||||
*/
|
||||
@@ -195,25 +225,22 @@ function resolveFormState(
|
||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||
|
||||
if (initialModel) {
|
||||
// If models aren't loaded yet, trust the initial value
|
||||
// It will be validated once models load
|
||||
if (!availableModels || isValidModel(initialModel)) {
|
||||
result.model = initialModel;
|
||||
} else if (preferredModel && isValidModel(preferredModel)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
result.model = defaultModelId;
|
||||
}
|
||||
} else if (preferredModel) {
|
||||
// If models haven't loaded yet, optimistically apply the stored preference.
|
||||
// We'll validate once models load and clear it if it isn't available.
|
||||
if (!availableModels || isValidModel(preferredModel)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
result.model = defaultModelId;
|
||||
}
|
||||
} else if (defaultModelId) {
|
||||
result.model = defaultModelId;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
@@ -224,13 +251,17 @@ function resolveFormState(
|
||||
typeof initialValues?.thinkingOptionId === "string"
|
||||
? initialValues.thinkingOptionId.trim()
|
||||
: "";
|
||||
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
|
||||
|
||||
if (!userModified.thinkingOptionId) {
|
||||
const effectiveModelId = result.model.trim();
|
||||
const preferredThinking = effectiveModelId
|
||||
? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
|
||||
: "";
|
||||
|
||||
if (initialThinkingOptionId.length > 0) {
|
||||
result.thinkingOptionId = initialThinkingOptionId;
|
||||
} else if (preferredThinkingOptionId.length > 0) {
|
||||
result.thinkingOptionId = preferredThinkingOptionId;
|
||||
} else if (preferredThinking.length > 0) {
|
||||
result.thinkingOptionId = preferredThinking;
|
||||
} else {
|
||||
result.thinkingOptionId = "";
|
||||
}
|
||||
@@ -238,27 +269,17 @@ function resolveFormState(
|
||||
|
||||
// Validate thinking option once model metadata is available.
|
||||
if (availableModels) {
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
|
||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
if (thinkingOptions.length === 0) {
|
||||
result.thinkingOptionId = "";
|
||||
} else {
|
||||
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
|
||||
const defaultThinkingOptionId =
|
||||
effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
|
||||
result.thinkingOptionId = defaultThinkingOptionId;
|
||||
}
|
||||
}
|
||||
result.thinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels,
|
||||
modelId: result.model,
|
||||
requestedThinkingOptionId: result.thinkingOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Resolve serverId (independent)
|
||||
// Only use stored serverId if the host still exists in the registry
|
||||
if (!userModified.serverId) {
|
||||
if (initialValues?.serverId !== undefined) {
|
||||
result.serverId = initialValues.serverId;
|
||||
} else if (preferences?.serverId && validServerIds.has(preferences.serverId)) {
|
||||
result.serverId = preferences.serverId;
|
||||
}
|
||||
// else keep current
|
||||
}
|
||||
@@ -267,8 +288,6 @@ function resolveFormState(
|
||||
if (!userModified.workingDir) {
|
||||
if (initialValues?.workingDir !== undefined) {
|
||||
result.workingDir = initialValues.workingDir;
|
||||
} else if (preferences?.workingDir) {
|
||||
result.workingDir = preferences.workingDir;
|
||||
}
|
||||
// else keep current (empty string)
|
||||
}
|
||||
@@ -313,7 +332,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
preferences,
|
||||
isLoading: isPreferencesLoading,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
} = useFormPreferences();
|
||||
|
||||
const daemons = useHosts();
|
||||
@@ -537,107 +555,119 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
validServerIds,
|
||||
]);
|
||||
|
||||
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isCreateFlow) return;
|
||||
if (isPreferencesLoading) return;
|
||||
if (userModified.serverId) return;
|
||||
const serverId = formState.serverId;
|
||||
if (!serverId) return;
|
||||
if (preferences?.serverId === serverId) return;
|
||||
void updatePreferences({ serverId });
|
||||
}, [
|
||||
isVisible,
|
||||
isCreateFlow,
|
||||
isPreferencesLoading,
|
||||
userModified.serverId,
|
||||
formState.serverId,
|
||||
preferences?.serverId,
|
||||
updatePreferences,
|
||||
]);
|
||||
|
||||
// User setters - mark fields as modified and persist to preferences
|
||||
const setSelectedServerIdFromUser = useCallback(
|
||||
(value: string | null) => {
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
||||
void updatePreferences({ serverId: value ?? undefined });
|
||||
},
|
||||
[updatePreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setProviderFromUser = useCallback(
|
||||
(provider: AgentProvider) => {
|
||||
setFormState((prev) => ({ ...prev, provider }));
|
||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||
void updatePreferences({ provider });
|
||||
|
||||
// When provider changes, reset mode and model to provider defaults
|
||||
// (unless user has explicitly set them)
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerDef = providerDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
|
||||
const isValidModel = (m: string) =>
|
||||
providerModels?.some((am) => am.id === m) ?? false;
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||
const nextModelId =
|
||||
preferredModel && (!providerModels || isValidModel(preferredModel))
|
||||
? preferredModel
|
||||
: defaultModelId;
|
||||
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
const nextModeId =
|
||||
providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)
|
||||
? providerPrefs.mode
|
||||
: providerDef?.defaultModeId ?? "";
|
||||
|
||||
const preferredThinking = nextModelId
|
||||
? providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? ""
|
||||
: "";
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels: providerModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: preferredThinking,
|
||||
});
|
||||
|
||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||
void updatePreferences({ provider });
|
||||
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
model: normalizeSelectedModelId(providerPrefs?.model),
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
modeId: nextModeId,
|
||||
model: nextModelId,
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
},
|
||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
||||
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
||||
);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
const providerDef = providerDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels: providerModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: "",
|
||||
});
|
||||
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: modelId,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
model: nextModelId,
|
||||
modeId: providerDef?.defaultModeId ?? "",
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||
void updatePreferences({ provider });
|
||||
void updateProviderPreferences(provider, { model: modelId });
|
||||
},
|
||||
[
|
||||
preferences?.providerPreferences,
|
||||
providerDefinitionMap,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
],
|
||||
[allProviderModels, providerDefinitionMap, updatePreferences],
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
setFormState((prev) => ({ ...prev, modeId }));
|
||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||
void updateProviderPreferences(formState.provider, { mode: modeId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: userModified.thinkingOptionId
|
||||
? formStateRef.current.thinkingOptionId
|
||||
: "",
|
||||
});
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
model: nextModelId,
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
setUserModified((prev) => ({ ...prev, model: true }));
|
||||
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences],
|
||||
[availableModels, userModified.thinkingOptionId],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||
void updateProviderPreferences(formState.provider, { thinkingOptionId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
@@ -648,9 +678,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
(value: string) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
||||
void updatePreferences({ workingDir: value });
|
||||
},
|
||||
[updatePreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setSelectedServerId = useCallback((value: string | null) => {
|
||||
@@ -662,39 +691,42 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, [providerModelsQuery]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const providerPreferenceUpdates: {
|
||||
mode: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
} = {
|
||||
mode: formState.modeId,
|
||||
model: formState.model,
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const modelId = resolvedModel?.id ?? formState.model;
|
||||
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
|
||||
|
||||
const nextProviderPrefs: ProviderPreferences = {
|
||||
model: modelId || undefined,
|
||||
mode: formState.modeId || undefined,
|
||||
thinkingByModel: {
|
||||
...existingProviderPrefs?.thinkingByModel,
|
||||
...(modelId && formState.thinkingOptionId
|
||||
? { [modelId]: formState.thinkingOptionId }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
if (userModified.thinkingOptionId) {
|
||||
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
|
||||
}
|
||||
|
||||
await updatePreferences({
|
||||
workingDir: formState.workingDir,
|
||||
provider: formState.provider,
|
||||
serverId: formState.serverId ?? undefined,
|
||||
providerPreferences: {
|
||||
...preferences?.providerPreferences,
|
||||
[formState.provider]: nextProviderPrefs,
|
||||
},
|
||||
});
|
||||
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
|
||||
}, [
|
||||
formState.modeId,
|
||||
availableModels,
|
||||
formState.model,
|
||||
formState.modeId,
|
||||
formState.provider,
|
||||
formState.serverId,
|
||||
formState.thinkingOptionId,
|
||||
formState.workingDir,
|
||||
userModified.thinkingOptionId,
|
||||
preferences?.providerPreferences,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
||||
const modelError =
|
||||
@@ -711,7 +743,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
setProviderFromUser,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel: formState.model,
|
||||
selectedModel: resolvedModelId,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId: formState.thinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
@@ -737,7 +769,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
formState.serverId,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
resolvedModelId,
|
||||
formState.thinkingOptionId,
|
||||
formState.workingDir,
|
||||
setSelectedServerId,
|
||||
@@ -771,5 +803,7 @@ export type CreateAgentInitialValues = FormInitialValues;
|
||||
|
||||
export const __private__ = {
|
||||
combineInitialValues,
|
||||
resolveDefaultModel,
|
||||
resolveFormState,
|
||||
resolveThinkingOptionId,
|
||||
};
|
||||
|
||||
@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
||||
it("uses committed tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: false,
|
||||
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
it("uses committed tail bootstrap when cursor is missing", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
||||
it("uses committed catch-up after the current cursor once history is synced", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: true,
|
||||
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useAgentInitialization({
|
||||
const hasAuthoritativeHistory =
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
||||
cursor: cursor ? { seq: cursor.endSeq } : null,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
@@ -107,7 +107,6 @@ export function useAgentInitialization({
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { useRef } from "react";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
|
||||
export interface AgentScreenAgent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
projectPlacement?: {
|
||||
checkout?: {
|
||||
cwd?: string;
|
||||
isGit?: boolean;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AgentScreenMissingState =
|
||||
| { kind: "idle" }
|
||||
@@ -8,8 +20,8 @@ export type AgentScreenMissingState =
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export interface AgentScreenMachineInput {
|
||||
agent: Agent | null;
|
||||
placeholderAgent: Agent | null;
|
||||
agent: AgentScreenAgent | null;
|
||||
placeholderAgent: AgentScreenAgent | null;
|
||||
missingAgentState: AgentScreenMissingState;
|
||||
isConnected: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
@@ -31,7 +43,7 @@ export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
||||
|
||||
export interface AgentScreenMachineMemory {
|
||||
hasRenderedReady: boolean;
|
||||
lastReadyAgent: Agent | null;
|
||||
lastReadyAgent: AgentScreenAgent | null;
|
||||
activeToastLatch: AgentScreenToastLatch;
|
||||
hadInitialSyncFailure: boolean;
|
||||
}
|
||||
@@ -70,7 +82,7 @@ export type AgentScreenViewState =
|
||||
}
|
||||
| {
|
||||
tag: "ready";
|
||||
agent: Agent;
|
||||
agent: AgentScreenAgent;
|
||||
source: "authoritative" | "optimistic" | "stale";
|
||||
sync: AgentScreenReadySyncState;
|
||||
isArchiving: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||
@@ -10,13 +9,11 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||
const providerPreferencesSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
thinkingByModel: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
const formPreferencesSchema = z.object({
|
||||
workingDir: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -35,12 +32,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
||||
export interface UseFormPreferencesReturn {
|
||||
preferences: FormPreferences;
|
||||
isLoading: boolean;
|
||||
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
|
||||
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
||||
updateProviderPreferences: (
|
||||
provider: AgentProvider,
|
||||
updates: Partial<ProviderPreferences>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
@@ -54,13 +46,6 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
|
||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||
|
||||
const getProviderPreferences = useCallback(
|
||||
(provider: AgentProvider): ProviderPreferences | undefined => {
|
||||
return preferences.providerPreferences?.[provider];
|
||||
},
|
||||
[preferences.providerPreferences],
|
||||
);
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<FormPreferences>) => {
|
||||
const prev =
|
||||
@@ -73,32 +58,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const updateProviderPreferences = useCallback(
|
||||
async (provider: AgentProvider, updates: Partial<ProviderPreferences>) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
||||
DEFAULT_FORM_PREFERENCES;
|
||||
const next: FormPreferences = {
|
||||
...prev,
|
||||
providerPreferences: {
|
||||
...prev.providerPreferences,
|
||||
[provider]: {
|
||||
...prev.providerPreferences?.[provider],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
};
|
||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
isLoading: isPending,
|
||||
getProviderPreferences,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,43 @@ describe("keyboard-action-dispatcher", () => {
|
||||
expect(handle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dispatches to the active mounted tab when a newer hidden tab is inactive", () => {
|
||||
const calls: string[] = [];
|
||||
const action: KeyboardActionDefinition = {
|
||||
id: "message-input.dictation-toggle",
|
||||
scope: "message-input",
|
||||
};
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "visible-tab",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: () => {
|
||||
calls.push("visible-tab");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "hidden-tab",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
isActive: () => false,
|
||||
handle: () => {
|
||||
calls.push("hidden-tab");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const handled = dispatcher.dispatch(action);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(calls).toEqual(["visible-tab"]);
|
||||
});
|
||||
|
||||
it("tries lower-priority handlers when a higher one does not consume the action", () => {
|
||||
const calls: string[] = [];
|
||||
const action: KeyboardActionDefinition = {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Platform, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||
@@ -16,6 +18,7 @@ import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import {
|
||||
useAgentScreenStateMachine,
|
||||
type AgentScreenAgent,
|
||||
type AgentScreenMissingState,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
@@ -78,11 +81,21 @@ function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const agent = useSessionStore(
|
||||
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null,
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null;
|
||||
return {
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const provider = agent?.provider ?? "codex";
|
||||
const label = resolveWorkspaceAgentTabLabel(agent?.title);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
||||
|
||||
return {
|
||||
@@ -90,12 +103,12 @@ function useAgentPanelDescriptor(
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon,
|
||||
statusBucket: agent
|
||||
statusBucket: descriptorState.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
status: descriptorState.status,
|
||||
pendingPermissionCount: descriptorState.pendingPermissionCount,
|
||||
requiresAttention: descriptorState.requiresAttention,
|
||||
attentionReason: descriptorState.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
@@ -243,8 +256,25 @@ function AgentPanelBody({
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const agent = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined,
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
status: agent?.status ?? null,
|
||||
cwd: agent?.cwd ?? null,
|
||||
archivedAt: agent?.archivedAt ?? null,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
@@ -318,52 +348,14 @@ function AgentPanelBody({
|
||||
agentId,
|
||||
client,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
attentionReason: agent?.attentionReason,
|
||||
requiresAttention: agentState.requiresAttention,
|
||||
attentionReason: agentState.attentionReason,
|
||||
isScreenFocused: isPaneFocused,
|
||||
});
|
||||
useEffect(() => {
|
||||
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
|
||||
}, [attentionController.clearOnAgentBlur]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEBUG: track which selector values change between renders
|
||||
// ---------------------------------------------------------------------------
|
||||
const debugPrevRef = useRef<Record<string, unknown>>({});
|
||||
useEffect(() => {
|
||||
const prev = debugPrevRef.current;
|
||||
const curr: Record<string, unknown> = {
|
||||
agent,
|
||||
"agent?.status": agent?.status,
|
||||
"agent?.cwd": agent?.cwd,
|
||||
"agent?.updatedAt": agent?.updatedAt,
|
||||
"agent?.requiresAttention": agent?.requiresAttention,
|
||||
streamItemsRaw,
|
||||
"streamItems.length": streamItems.length,
|
||||
allPendingPermissions,
|
||||
isInitializingFromMap,
|
||||
historySyncGeneration,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
agentHistorySyncGeneration,
|
||||
hasSession,
|
||||
isPaneFocused,
|
||||
isConnected,
|
||||
};
|
||||
const changed: string[] = [];
|
||||
for (const key of Object.keys(curr)) {
|
||||
if (!Object.is(prev[key], curr[key])) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
if (changed.length > 0 && Object.keys(prev).length > 0) {
|
||||
console.log("[AgentPanelBody] values changed:", changed.join(", "), {
|
||||
changed: Object.fromEntries(changed.map((k) => [k, { prev: prev[k], curr: curr[k] }])),
|
||||
});
|
||||
}
|
||||
debugPrevRef.current = curr;
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
@@ -488,49 +480,34 @@ function AgentPanelBody({
|
||||
}, [optimisticStreamItems, streamItems]);
|
||||
|
||||
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
|
||||
const authoritativeStatus = agent?.status;
|
||||
const authoritativeStatus = agentState.status;
|
||||
const isAuthoritativeBootstrapping =
|
||||
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
||||
const showPendingCreateSubmitLoading =
|
||||
isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping);
|
||||
const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
|
||||
|
||||
const placeholderAgent: Agent | null = useMemo(() => {
|
||||
const agent: AgentScreenAgent | null =
|
||||
agentState.serverId && agentState.id && agentState.status && agentState.cwd
|
||||
? {
|
||||
serverId: agentState.serverId,
|
||||
id: agentState.id,
|
||||
status: agentState.status,
|
||||
cwd: agentState.cwd,
|
||||
projectPlacement,
|
||||
}
|
||||
: null;
|
||||
|
||||
const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
|
||||
if (!shouldUseOptimisticStream || !agentId) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
return {
|
||||
serverId,
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: now,
|
||||
lastActivityAt: now,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: "claude",
|
||||
sessionId: null,
|
||||
model: null,
|
||||
modeId: null,
|
||||
},
|
||||
title: "Agent",
|
||||
cwd: ".",
|
||||
model: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
}, [agentId, serverId, shouldUseOptimisticStream]);
|
||||
|
||||
@@ -642,7 +619,7 @@ function AgentPanelBody({
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agent || shouldUseOptimisticStream) {
|
||||
if (agentState.id || shouldUseOptimisticStream) {
|
||||
if (missingAgentState.kind !== "idle") {
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
}
|
||||
@@ -717,7 +694,7 @@ function AgentPanelBody({
|
||||
setMissingAgentState({ kind: "error", message });
|
||||
});
|
||||
}, [
|
||||
agent,
|
||||
agentState.id,
|
||||
agentId,
|
||||
client,
|
||||
ensureAgentIsInitialized,
|
||||
@@ -803,10 +780,11 @@ function AgentPanelBody({
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? (
|
||||
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
||||
<AgentInputArea
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
images={agentInputDraft.images}
|
||||
@@ -831,7 +809,7 @@ function AgentPanelBody({
|
||||
streamViewRef.current?.scrollToBottom("message-sent");
|
||||
}}
|
||||
/>
|
||||
) : agentId && agent?.archivedAt ? (
|
||||
) : agentId && agentState.archivedAt ? (
|
||||
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -17,8 +17,15 @@ function useDraftPanelDescriptor() {
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
|
||||
usePaneContext();
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
target,
|
||||
isPaneFocused,
|
||||
openFileInWorkspace,
|
||||
retargetCurrentTab,
|
||||
} = usePaneContext();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
|
||||
return (
|
||||
@@ -27,6 +34,7 @@ function DraftPanel() {
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onOpenWorkspaceFile={({ filePath }) => {
|
||||
openFileInWorkspace(filePath);
|
||||
}}
|
||||
|
||||
@@ -1069,7 +1069,8 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767";
|
||||
const DEFAULT_LOCALHOST_ENDPOINT =
|
||||
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||
|
||||
@@ -744,6 +744,20 @@ function DraftAgentScreenContent({
|
||||
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
||||
|
||||
const createAgentClient = sessionClient;
|
||||
const effectiveDraftModelId = useMemo(() => {
|
||||
if (selectedModel.trim()) {
|
||||
return selectedModel.trim();
|
||||
}
|
||||
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||
}, [availableModels, selectedModel]);
|
||||
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||
if (selectedThinkingOptionId.trim()) {
|
||||
return selectedThinkingOptionId.trim();
|
||||
}
|
||||
const selectedModelDefinition =
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||
const cwd = (
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||
@@ -756,18 +770,18 @@ function DraftAgentScreenContent({
|
||||
provider: selectedProvider,
|
||||
cwd,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
||||
...(selectedThinkingOptionId.trim()
|
||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
isAttachWorktree,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedThinkingOptionId,
|
||||
selectedWorktreePath,
|
||||
workingDir,
|
||||
]);
|
||||
@@ -801,6 +815,12 @@ function DraftAgentScreenContent({
|
||||
if (gitBlockingError) {
|
||||
return gitBlockingError;
|
||||
}
|
||||
if (isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!effectiveDraftModelId) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (isAttachWorktree && !selectedWorktreePath) {
|
||||
return "Select a worktree to attach";
|
||||
}
|
||||
@@ -832,8 +852,8 @@ function DraftAgentScreenContent({
|
||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||
".";
|
||||
const provider = selectedProvider;
|
||||
const model = selectedModel.trim() || null;
|
||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
|
||||
return {
|
||||
@@ -869,14 +889,14 @@ function DraftAgentScreenContent({
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const trimmedModel = selectedModel.trim();
|
||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const effectiveBaseBranch = baseBranch.trim();
|
||||
@@ -1199,6 +1219,7 @@ function DraftAgentScreenContent({
|
||||
<AgentInputArea
|
||||
agentId={draftAgentIdRef.current}
|
||||
serverId={selectedServerId ?? ""}
|
||||
isInputActive={isFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentSessionConfig,
|
||||
@@ -33,6 +34,7 @@ type WorkspaceDraftAgentTabProps = {
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
draftId: string;
|
||||
isPaneFocused: boolean;
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||
onOpenWorkspaceFile: (input: { filePath: string }) => void;
|
||||
};
|
||||
@@ -42,6 +44,7 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceId,
|
||||
tabId,
|
||||
draftId,
|
||||
isPaneFocused,
|
||||
onCreated,
|
||||
onOpenWorkspaceFile,
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
@@ -92,6 +95,22 @@ export function WorkspaceDraftAgentTab({
|
||||
setWorkingDir(workspaceId);
|
||||
}, [setWorkingDir, workingDir, workspaceId]);
|
||||
|
||||
const effectiveDraftModelId = useMemo(() => {
|
||||
if (selectedModel.trim()) {
|
||||
return selectedModel.trim();
|
||||
}
|
||||
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||
}, [availableModels, selectedModel]);
|
||||
|
||||
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||
if (selectedThinkingOptionId.trim()) {
|
||||
return selectedThinkingOptionId.trim();
|
||||
}
|
||||
const selectedModelDefinition =
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
@@ -108,6 +127,12 @@ export function WorkspaceDraftAgentTab({
|
||||
if (providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!effectiveDraftModelId) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!client) {
|
||||
return "Host is not connected";
|
||||
}
|
||||
@@ -122,8 +147,8 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
buildDraftAgent: (attempt) => {
|
||||
const now = attempt.timestamp;
|
||||
const model = selectedModel.trim() || null;
|
||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
return {
|
||||
serverId,
|
||||
@@ -153,14 +178,14 @@ export function WorkspaceDraftAgentTab({
|
||||
}
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const trimmedModel = selectedModel.trim();
|
||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
@@ -186,17 +211,17 @@ export function WorkspaceDraftAgentTab({
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
||||
...(selectedThinkingOptionId.trim()
|
||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedThinkingOptionId,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
@@ -243,6 +268,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<AgentInputArea
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
@@ -251,7 +277,7 @@ export function WorkspaceDraftAgentTab({
|
||||
images={draftInput.images}
|
||||
onChangeImages={draftInput.setImages}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={!isSubmitting}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
commandDraftConfig={draftCommandConfig}
|
||||
statusControls={{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "./workspace-draft-pane-focus";
|
||||
|
||||
describe("shouldAutoFocusWorkspaceDraftComposer", () => {
|
||||
it("focuses the draft composer when the pane is focused and idle", () => {
|
||||
expect(
|
||||
shouldAutoFocusWorkspaceDraftComposer({
|
||||
isPaneFocused: true,
|
||||
isSubmitting: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not focus the draft composer when the pane is unfocused", () => {
|
||||
expect(
|
||||
shouldAutoFocusWorkspaceDraftComposer({
|
||||
isPaneFocused: false,
|
||||
isSubmitting: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not focus the draft composer while the draft is submitting", () => {
|
||||
expect(
|
||||
shouldAutoFocusWorkspaceDraftComposer({
|
||||
isPaneFocused: true,
|
||||
isSubmitting: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function shouldAutoFocusWorkspaceDraftComposer(input: {
|
||||
isPaneFocused: boolean;
|
||||
isSubmitting: boolean;
|
||||
}): boolean {
|
||||
return input.isPaneFocused && !input.isSubmitting;
|
||||
}
|
||||
@@ -191,7 +191,6 @@ export type DaemonServerInfo = {
|
||||
};
|
||||
|
||||
export interface AgentTimelineCursorState {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
@@ -37,4 +37,36 @@ describe("createMarkdownStyles", () => {
|
||||
overflowWrap: "anywhere",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps assistant markdown text selectable on web", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
|
||||
expect(styles.body).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.text).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.heading1).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.link).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.code_inline).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.code_block).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.fence).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.bullet_list_icon).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.ordered_list_icon).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Platform } from "react-native";
|
||||
import type { Theme } from "./theme";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
|
||||
const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {};
|
||||
|
||||
/**
|
||||
* Creates comprehensive markdown styles for react-native-markdown-display.
|
||||
*
|
||||
@@ -15,6 +18,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
body: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
@@ -24,6 +28,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
text: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
@@ -47,6 +52,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
heading1: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize["3xl"],
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -59,6 +65,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading2: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize["2xl"],
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -71,6 +78,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading3: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -80,6 +88,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading4: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -89,6 +98,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading5: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -98,6 +108,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading6: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -113,19 +124,23 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
strong: {
|
||||
...webSelectableTextStyle,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
|
||||
em: {
|
||||
...webSelectableTextStyle,
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
|
||||
s: {
|
||||
...webSelectableTextStyle,
|
||||
textDecorationLine: "line-through" as const,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
link: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.accentBright,
|
||||
textDecorationLine: "none" as const,
|
||||
flexShrink: 1,
|
||||
@@ -134,6 +149,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
blocklink: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.accentBright,
|
||||
textDecorationLine: "none" as const,
|
||||
flexShrink: 1,
|
||||
@@ -146,6 +162,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
code_inline: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
@@ -157,6 +174,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
code_block: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
@@ -167,6 +185,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
fence: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
@@ -200,6 +219,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
tbody: {},
|
||||
|
||||
th: {
|
||||
...webSelectableTextStyle,
|
||||
padding: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
@@ -218,6 +238,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
td: {
|
||||
...webSelectableTextStyle,
|
||||
padding: theme.spacing[2],
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -260,6 +281,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
bullet_list_icon: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -267,6 +289,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
ordered_list_icon: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -20,12 +20,12 @@ describe("extractAgentModel", () => {
|
||||
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
|
||||
});
|
||||
|
||||
it("treats legacy 'default' model ids as unset", () => {
|
||||
it("preserves 'default' as a valid model id", () => {
|
||||
const agent = {
|
||||
model: "default",
|
||||
runtimeInfo: { model: "default" },
|
||||
} as Partial<Agent> as Agent;
|
||||
|
||||
expect(extractAgentModel(agent)).toBeNull();
|
||||
expect(extractAgentModel(agent)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,13 @@ export function extractAgentModel(agent?: Agent | null): string | null {
|
||||
const fallbackModel = agent.model;
|
||||
if (typeof runtimeModel === "string") {
|
||||
const normalized = runtimeModel.trim();
|
||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
if (typeof fallbackModel === "string") {
|
||||
const normalized = fallbackModel.trim();
|
||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { focusWithRetries } from "./web-focus";
|
||||
|
||||
describe("focusWithRetries", () => {
|
||||
let frameQueue: FrameRequestCallback[] = [];
|
||||
let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
frameQueue = [];
|
||||
originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
frameQueue.push(callback);
|
||||
return frameQueue.length;
|
||||
}) as typeof requestAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalRequestAnimationFrame) {
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
return;
|
||||
}
|
||||
|
||||
delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame })
|
||||
.requestAnimationFrame;
|
||||
});
|
||||
|
||||
function flushAnimationFrames(count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const callbacks = frameQueue;
|
||||
frameQueue = [];
|
||||
for (const callback of callbacks) {
|
||||
callback(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("tries to focus immediately before waiting for animation frames", () => {
|
||||
let focused = false;
|
||||
const focus = vi.fn(() => {
|
||||
focused = true;
|
||||
});
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
focusWithRetries({
|
||||
focus,
|
||||
isFocused: () => focused,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(frameQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps retrying on later animation frames until focus succeeds", () => {
|
||||
let focused = false;
|
||||
let attempts = 0;
|
||||
const focus = vi.fn(() => {
|
||||
attempts += 1;
|
||||
if (attempts >= 3) {
|
||||
focused = true;
|
||||
}
|
||||
});
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
focusWithRetries({
|
||||
focus,
|
||||
isFocused: () => focused,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
|
||||
flushAnimationFrames(2);
|
||||
expect(focus).toHaveBeenCalledTimes(2);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
|
||||
flushAnimationFrames(2);
|
||||
expect(focus).toHaveBeenCalledTimes(3);
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops retrying after cancellation", () => {
|
||||
const focus = vi.fn();
|
||||
|
||||
const cancel = focusWithRetries({
|
||||
focus,
|
||||
isFocused: () => false,
|
||||
});
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
|
||||
cancel();
|
||||
flushAnimationFrames(4);
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -40,9 +40,7 @@ export function focusWithRetries({
|
||||
});
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.32",
|
||||
"@getpaseo/server": "0.1.32",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { addStopOptions, runStopCommand } from "./commands/agent/stop.js";
|
||||
import { addSendOptions, runSendCommand } from "./commands/agent/send.js";
|
||||
import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.js";
|
||||
import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js";
|
||||
import { addArchiveOptions, runArchiveCommand } from "./commands/agent/archive.js";
|
||||
import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js";
|
||||
import { withOutput } from "./output/index.js";
|
||||
import { onboardCommand } from "./commands/onboard.js";
|
||||
@@ -93,6 +94,10 @@ export function createCli(): Command {
|
||||
addWaitOptions(program.command("wait")),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
addArchiveOptions(program.command("archive")),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(onboardCommand());
|
||||
program.addCommand(daemonStartCommand());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -24,6 +24,13 @@ export const archiveSchema: OutputSchema<AgentArchiveResult> = {
|
||||
],
|
||||
};
|
||||
|
||||
export function addArchiveOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description('Archive an agent (soft-delete)')
|
||||
.argument("<id>", "Agent ID, prefix, or name")
|
||||
.option("--force", "Force archive running agent (interrupts active run first)");
|
||||
}
|
||||
|
||||
export interface AgentArchiveOptions extends CommandOptions {
|
||||
force?: boolean;
|
||||
host?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { runModeCommand } from "./mode.js";
|
||||
import { runArchiveCommand } from "./archive.js";
|
||||
import { addArchiveOptions, runArchiveCommand } from "./archive.js";
|
||||
import { addDeleteOptions, runDeleteCommand } from "./delete.js";
|
||||
import { addLsOptions, runLsCommand } from "./ls.js";
|
||||
import { addRunOptions, runRunCommand } from "./run.js";
|
||||
@@ -69,11 +69,7 @@ export function createAgentCommand(): Command {
|
||||
).action(withOutput(runModeCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("archive")
|
||||
.description("Archive an agent (soft-delete)")
|
||||
.argument("<id>", "Agent ID, prefix, or name")
|
||||
.option("--force", "Force archive running agent (interrupts active run first)"),
|
||||
addArchiveOptions(agent.command("archive")),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
|
||||
@@ -193,7 +193,6 @@ export async function resolveStructuredResponseMessage(options: {
|
||||
try {
|
||||
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 200,
|
||||
});
|
||||
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||
};
|
||||
|
||||
export interface AgentSendOptions extends CommandOptions {
|
||||
noWait?: boolean;
|
||||
wait?: boolean;
|
||||
image?: string[];
|
||||
prompt?: string;
|
||||
promptFile?: string;
|
||||
@@ -193,7 +193,7 @@ export async function runSendCommand(
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, { images });
|
||||
|
||||
// If --no-wait, return immediately
|
||||
if (options.noWait) {
|
||||
if (options.wait === false) {
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,6 @@ export async function fetchProjectedTimelineItems(
|
||||
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.32",
|
||||
"@getpaseo/server": "0.1.32",
|
||||
"@getpaseo/cli": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
|
||||
@@ -7,8 +7,125 @@ const WRAPPER_SCRIPT = `#!/bin/bash
|
||||
exec "$(dirname "$(readlink -f "$0")")/${EXECUTABLE_NAME}.bin" --no-sandbox "$@"
|
||||
`;
|
||||
|
||||
// electron-builder arch enum → Node.js arch string
|
||||
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
||||
|
||||
const RIPGREP_PLATFORM_DIR = {
|
||||
darwin: { arm64: "arm64-darwin", x64: "x64-darwin" },
|
||||
linux: { arm64: "arm64-linux", x64: "x64-linux" },
|
||||
win32: { arm64: "arm64-win32", x64: "x64-win32" },
|
||||
};
|
||||
|
||||
function rmSafe(target) {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function pruneChildrenExcept(parent, keep) {
|
||||
if (!fs.existsSync(parent)) return;
|
||||
for (const entry of fs.readdirSync(parent)) {
|
||||
if (!keep.has(entry)) {
|
||||
rmSafe(path.join(parent, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneOnnxRuntime(nodeModules, platform, arch) {
|
||||
const onnxBin = path.join(nodeModules, "onnxruntime-node", "bin", "napi-v6");
|
||||
if (!fs.existsSync(onnxBin)) return;
|
||||
|
||||
const otherPlatforms = ["darwin", "linux", "win32"].filter((p) => p !== platform);
|
||||
for (const p of otherPlatforms) {
|
||||
rmSafe(path.join(onnxBin, p));
|
||||
}
|
||||
|
||||
pruneChildrenExcept(path.join(onnxBin, platform), new Set([arch]));
|
||||
|
||||
if (platform === "linux") {
|
||||
const archDir = path.join(onnxBin, "linux", arch);
|
||||
if (fs.existsSync(archDir)) {
|
||||
for (const name of fs.readdirSync(archDir)) {
|
||||
if (name.includes("cuda") || name.includes("tensorrt")) {
|
||||
fs.rmSync(path.join(archDir, name), { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
|
||||
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
|
||||
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
|
||||
if (!keepName) return;
|
||||
|
||||
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
|
||||
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
|
||||
}
|
||||
|
||||
function pruneNodePty(nodeModules, platform, arch) {
|
||||
const prebuilds = path.join(nodeModules, "node-pty", "prebuilds");
|
||||
pruneChildrenExcept(prebuilds, new Set([`${platform}-${arch}`]));
|
||||
|
||||
if (platform !== "win32") {
|
||||
rmSafe(path.join(nodeModules, "node-pty", "third_party"));
|
||||
}
|
||||
}
|
||||
|
||||
function pruneSharpLibvips(nodeModules, platform, arch) {
|
||||
const prefix = `sharp-libvips-${platform}-${arch}`;
|
||||
const imgDir = path.join(nodeModules, "@img");
|
||||
if (!fs.existsSync(imgDir)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(imgDir)) {
|
||||
if (entry.startsWith("sharp-") && entry !== prefix && !entry.startsWith(`sharp-${platform}-${arch}`)) {
|
||||
rmSafe(path.join(imgDir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneNativeModules(appOutDir, platform, arch) {
|
||||
const resourcesDir =
|
||||
platform === "darwin"
|
||||
? path.join(appOutDir, `${EXECUTABLE_NAME}.app`, "Contents", "Resources")
|
||||
: path.join(appOutDir, "resources");
|
||||
|
||||
const nodeModules = path.join(resourcesDir, "app.asar.unpacked", "node_modules");
|
||||
if (!fs.existsSync(nodeModules)) return;
|
||||
|
||||
const before = dirSizeSync(nodeModules);
|
||||
|
||||
pruneOnnxRuntime(nodeModules, platform, arch);
|
||||
pruneClaudeAgentSdk(nodeModules, platform, arch);
|
||||
pruneNodePty(nodeModules, platform, arch);
|
||||
pruneSharpLibvips(nodeModules, platform, arch);
|
||||
|
||||
const after = dirSizeSync(nodeModules);
|
||||
const savedMB = ((before - after) / 1024 / 1024).toFixed(1);
|
||||
console.log(`Pruned native modules: ${savedMB} MB removed (${fmtMB(before)} → ${fmtMB(after)})`);
|
||||
}
|
||||
|
||||
function dirSizeSync(dir) {
|
||||
let total = 0;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
|
||||
if (entry.isFile()) {
|
||||
try {
|
||||
total += fs.statSync(path.join(entry.parentPath || entry.path, entry.name)).size;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function fmtMB(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== "linux") return;
|
||||
const platform = context.electronPlatformName;
|
||||
const arch = ARCH_MAP[context.arch] || process.arch;
|
||||
|
||||
pruneNativeModules(context.appOutDir, platform, arch);
|
||||
|
||||
if (platform !== "linux") return;
|
||||
|
||||
const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox");
|
||||
if (fs.existsSync(chromeSandbox)) {
|
||||
|
||||
@@ -168,6 +168,30 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNodeExecPath(): string {
|
||||
if (app.isPackaged && process.platform === "darwin") {
|
||||
const marker = ".app/Contents/MacOS/";
|
||||
const markerIndex = process.execPath.indexOf(marker);
|
||||
if (markerIndex !== -1) {
|
||||
const bundleRoot = process.execPath.substring(0, markerIndex + ".app".length);
|
||||
const name = path.basename(process.execPath);
|
||||
const helperPath = path.join(
|
||||
bundleRoot,
|
||||
"Contents",
|
||||
"Frameworks",
|
||||
`${name} Helper.app`,
|
||||
"Contents",
|
||||
"MacOS",
|
||||
`${name} Helper`,
|
||||
);
|
||||
if (existsSync(helperPath)) {
|
||||
return helperPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
export function createNodeEntrypointInvocation(input: {
|
||||
entrypoint: NodeEntrypointSpec;
|
||||
argvMode: NodeEntrypointArgvMode;
|
||||
@@ -175,7 +199,7 @@ export function createNodeEntrypointInvocation(input: {
|
||||
baseEnv: NodeJS.ProcessEnv;
|
||||
}): NodeEntrypointInvocation {
|
||||
return createSharedNodeEntrypointInvocation({
|
||||
execPath: process.execPath,
|
||||
execPath: resolveNodeExecPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
packagedRunnerPath: app.isPackaged
|
||||
? assertPathExists({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.32",
|
||||
"private": true,
|
||||
"version": "0.1.33",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
||||
"prepack": "npm run build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./packages/server/src/server/db/schema.ts",
|
||||
out: "./packages/server/src/server/db/migrations",
|
||||
dialect: "postgresql",
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -46,6 +46,7 @@
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"db:query": "tsx scripts/db-query.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
|
||||
@@ -64,8 +65,9 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@getpaseo/relay": "0.1.32",
|
||||
"@electric-sql/pglite": "^0.4.1",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
@@ -73,6 +75,7 @@
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.17.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fast-uri": "^3.1.0",
|
||||
@@ -96,6 +99,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Run arbitrary SQL against the running Paseo daemon's PGlite database.
|
||||
*
|
||||
* Connects to the daemon's dev query endpoint (available in dev mode only).
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots"
|
||||
* npx tsx packages/server/scripts/db-query.ts --port 6767 "SELECT count(*) FROM agent_timeline_rows"
|
||||
*
|
||||
* Without args, shows table row counts.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
|
||||
function findDaemonPort(explicit?: string): number {
|
||||
if (explicit) return parseInt(explicit, 10);
|
||||
|
||||
// Check for running dev daemon config
|
||||
const tmpDir = os.tmpdir();
|
||||
for (const entry of fs.readdirSync(tmpDir)) {
|
||||
if (entry.startsWith("paseo-dev.")) {
|
||||
const configPath = path.join(tmpDir, entry, "config.json");
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const listen = config.daemon?.listen;
|
||||
if (typeof listen === "string") {
|
||||
const portMatch = listen.match(/:(\d+)$/);
|
||||
if (portMatch) return parseInt(portMatch[1]!, 10);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 6767;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let port: string | undefined;
|
||||
const queries: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--port" && args[i + 1]) {
|
||||
port = args[++i];
|
||||
} else {
|
||||
queries.push(args[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
if (queries.length === 0) {
|
||||
queries.push(
|
||||
"SELECT 'agent_snapshots' AS table_name, count(*)::int AS rows FROM agent_snapshots UNION ALL " +
|
||||
"SELECT 'agent_timeline_rows', count(*)::int FROM agent_timeline_rows UNION ALL " +
|
||||
"SELECT 'projects', count(*)::int FROM projects UNION ALL " +
|
||||
"SELECT 'workspaces', count(*)::int FROM workspaces " +
|
||||
"ORDER BY table_name",
|
||||
);
|
||||
}
|
||||
|
||||
const daemonPort = findDaemonPort(port);
|
||||
const url = `http://localhost:${daemonPort}/dev/db-query`;
|
||||
|
||||
for (const sql of queries) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`HTTP ${res.status}: ${text}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { rows } = await res.json();
|
||||
if (rows.length === 0) {
|
||||
console.log("(0 rows)\n");
|
||||
} else {
|
||||
console.table(rows);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.cause?.code === "ECONNREFUSED") {
|
||||
console.error(`Cannot connect to daemon at port ${daemonPort}. Is it running?`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`Error: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1704,24 +1704,15 @@ describe("DaemonClient", () => {
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
seq: 1,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_snapshot",
|
||||
@@ -1798,24 +1789,15 @@ describe("DaemonClient", () => {
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
seq: 1,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_invalid",
|
||||
|
||||
@@ -130,7 +130,6 @@ export type DaemonEvent =
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: string;
|
||||
seq?: number;
|
||||
epoch?: string;
|
||||
}
|
||||
| { type: "status"; payload: { status: string } & Record<string, unknown> }
|
||||
| { type: "agent_deleted"; agentId: string }
|
||||
@@ -244,13 +243,13 @@ type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
||||
|
||||
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
||||
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
|
||||
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
|
||||
export type FetchAgentTimelineCursor = NonNullable<
|
||||
Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>["cursor"]
|
||||
>;
|
||||
export type FetchAgentTimelineOptions = {
|
||||
direction?: FetchAgentTimelineDirection;
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
@@ -1396,7 +1395,6 @@ export class DaemonClient {
|
||||
...(options.direction ? { direction: options.direction } : {}),
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
|
||||
...(options.projection ? { projection: options.projection } : {}),
|
||||
});
|
||||
|
||||
const payload = await this.sendRequest({
|
||||
@@ -3100,7 +3098,6 @@ export class DaemonClient {
|
||||
event: msg.payload.event,
|
||||
timestamp: msg.payload.timestamp,
|
||||
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
|
||||
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
|
||||
};
|
||||
case "status":
|
||||
return { type: "status", payload: msg.payload };
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type pino from "pino";
|
||||
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
||||
import {
|
||||
buildConfigOverrides,
|
||||
buildSessionConfig,
|
||||
extractTimestamps,
|
||||
toAgentPersistenceHandle,
|
||||
} from "./persistence-hooks.js";
|
||||
|
||||
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
|
||||
|
||||
export type AgentLoadingServiceOptions = {
|
||||
agentManager: Pick<
|
||||
AgentManager,
|
||||
| "createAgent"
|
||||
| "getAgent"
|
||||
| "reloadAgentSession"
|
||||
| "resumeAgentFromPersistence"
|
||||
>;
|
||||
agentStorage: Pick<AgentSnapshotStore, "get">;
|
||||
logger: pino.Logger;
|
||||
};
|
||||
|
||||
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
|
||||
export class AgentLoadingService {
|
||||
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
|
||||
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
|
||||
private readonly logger: pino.Logger;
|
||||
|
||||
constructor(options: AgentLoadingServiceOptions) {
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.logger = options.logger.child({ component: "agent-loading" });
|
||||
}
|
||||
|
||||
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(options.agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const initPromise = this.loadStoredAgent(options);
|
||||
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
const current = pendingAgentBootstrapLoads.get(options.agentId);
|
||||
if (current === initPromise) {
|
||||
pendingAgentBootstrapLoads.delete(options.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async resumeAgent(options: {
|
||||
handle: AgentPersistenceHandle;
|
||||
overrides?: Partial<AgentSessionConfig>;
|
||||
}): Promise<ManagedAgent> {
|
||||
return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides);
|
||||
}
|
||||
|
||||
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(options.agentId);
|
||||
if (existing) {
|
||||
return existing.persistence
|
||||
? await this.agentManager.reloadAgentSession(options.agentId)
|
||||
: existing;
|
||||
}
|
||||
|
||||
const record = await this.agentStorage.get(options.agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${options.agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
||||
if (!handle) {
|
||||
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
|
||||
}
|
||||
|
||||
return this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
options.agentId,
|
||||
extractTimestamps(record),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const record = await this.agentStorage.get(options.agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${options.agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
||||
let snapshot: ManagedAgent;
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
options.agentId,
|
||||
extractTimestamps(record),
|
||||
);
|
||||
this.logger.info(
|
||||
{ agentId: options.agentId, provider: record.provider },
|
||||
"Agent resumed from persistence",
|
||||
);
|
||||
} else {
|
||||
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, {
|
||||
labels: record.labels,
|
||||
});
|
||||
this.logger.info(
|
||||
{ agentId: options.agentId, provider: record.provider },
|
||||
"Agent created from stored config",
|
||||
);
|
||||
}
|
||||
|
||||
return this.agentManager.getAgent(options.agentId) ?? snapshot;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,29 @@ describe("curateAgentActivity", () => {
|
||||
expect(result).toContain("[Shell] npm test");
|
||||
});
|
||||
|
||||
it("renders terminal tool calls as one-line command summaries", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: `skills/paseo-chat/bin/chat.sh post --room storage-revamp --body $'first line
|
||||
|
||||
second line'`,
|
||||
icon: "square_terminal",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = curateAgentActivity(timeline);
|
||||
|
||||
expect(result).toContain(
|
||||
"[Terminal] skills/paseo-chat/bin/chat.sh post --room storage-revamp --body $'first line second line'",
|
||||
);
|
||||
expect(result).not.toContain("[Interacted with terminal]");
|
||||
});
|
||||
|
||||
it("does not infer summary from raw input when detail is missing", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
toolCallItem({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 40;
|
||||
const MAX_TOOL_INPUT_CHARS = 400;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 200;
|
||||
|
||||
function appendText(buffer: string, text: string): string {
|
||||
const normalized = text.trim();
|
||||
@@ -45,6 +46,20 @@ function formatToolInputJson(input: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolSummary(summary: string | undefined): string | null {
|
||||
if (typeof summary !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = summary.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.length <= MAX_TOOL_SUMMARY_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_TOOL_SUMMARY_CHARS - 3)}...`;
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
@@ -215,7 +230,7 @@ export function curateAgentActivity(
|
||||
metadata: item.metadata,
|
||||
});
|
||||
const displayName = display.displayName;
|
||||
const summary = display.summary;
|
||||
const summary = formatToolSummary(display.summary);
|
||||
if (isLikelyExternalToolName(item.name) && inputJson) {
|
||||
lines.push(`[${displayName}] ${inputJson}`);
|
||||
break;
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -51,7 +51,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
@@ -137,10 +137,7 @@ function startAgentRun(
|
||||
logger: Logger,
|
||||
options?: { replaceRunning?: boolean },
|
||||
): void {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const shouldReplace =
|
||||
options?.replaceRunning &&
|
||||
Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun));
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt)
|
||||
: agentManager.streamAgent(agentId, prompt);
|
||||
@@ -181,7 +178,7 @@ function sanitizePermissionRequest(
|
||||
}
|
||||
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
@@ -195,7 +192,7 @@ async function resolveAgentTitle(
|
||||
}
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
@@ -527,7 +524,7 @@ export async function createAgentManagementMcpServer(
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
|
||||
if (agentManager.hasInFlightRun(agentId)) {
|
||||
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,8 +40,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
} = overrides;
|
||||
|
||||
const sessionValue = lifecycle === "closed" ? null : (restOverrides.session ?? ({} as any));
|
||||
const pendingRunValue =
|
||||
restOverrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
const activeForegroundTurnIdValue =
|
||||
restOverrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
||||
const lastErrorValue =
|
||||
restOverrides.lastError ?? (lifecycle === "error" ? "encountered error" : undefined);
|
||||
|
||||
@@ -69,7 +69,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
],
|
||||
currentModeId: "plan",
|
||||
pendingPermissions: pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
|
||||
activeForegroundTurnId: activeForegroundTurnIdValue,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
unsubscribeSession: null,
|
||||
timeline: [],
|
||||
runtimeInfo: {
|
||||
provider: "claude",
|
||||
|
||||
@@ -259,23 +259,25 @@ export type AgentTimelineItem =
|
||||
|
||||
export type AgentStreamEvent =
|
||||
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
|
||||
| { type: "turn_started"; provider: AgentProvider }
|
||||
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage }
|
||||
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
|
||||
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
|
||||
| {
|
||||
type: "turn_failed";
|
||||
provider: AgentProvider;
|
||||
error: string;
|
||||
code?: string;
|
||||
diagnostic?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
| { type: "turn_canceled"; provider: AgentProvider; reason: string }
|
||||
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider }
|
||||
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest }
|
||||
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
|
||||
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider; turnId?: string }
|
||||
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest; turnId?: string }
|
||||
| {
|
||||
type: "permission_resolved";
|
||||
provider: AgentProvider;
|
||||
requestId: string;
|
||||
resolution: AgentPermissionResponse;
|
||||
turnId?: string;
|
||||
}
|
||||
| {
|
||||
type: "attention_required";
|
||||
@@ -382,12 +384,17 @@ export type AgentSessionConfig = {
|
||||
internal?: boolean;
|
||||
};
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator<AgentStreamEvent>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
@@ -398,19 +405,8 @@ export interface AgentSession {
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
/**
|
||||
* List available slash commands for this session.
|
||||
* Commands are provider-specific - Claude supports skills and built-in commands.
|
||||
*/
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
/**
|
||||
* Update the model used for subsequent turns (if supported by provider).
|
||||
*/
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
/**
|
||||
* Update the thinking/effort setting used for subsequent turns (if supported).
|
||||
* Normalized to a string option id (provider-specific interpretation).
|
||||
*/
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -421,10 +417,14 @@ export interface ListModelsOptions {
|
||||
export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
createSession(config: AgentSessionConfig): Promise<AgentSession>;
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
export interface AgentSnapshotStore {
|
||||
list(): Promise<StoredAgentRecord[]>;
|
||||
get(agentId: string): Promise<StoredAgentRecord | null>;
|
||||
upsert(record: StoredAgentRecord): Promise<void>;
|
||||
remove(agentId: string): Promise<void>;
|
||||
applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void>;
|
||||
setTitle(agentId: string, title: string): Promise<void>;
|
||||
}
|
||||
@@ -15,12 +15,12 @@ import type {
|
||||
|
||||
type ManagedAgentOverrides = Omit<
|
||||
Partial<ManagedAgent>,
|
||||
"config" | "pendingPermissions" | "session" | "pendingRun"
|
||||
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
|
||||
> & {
|
||||
config?: Partial<AgentSessionConfig>;
|
||||
pendingPermissions?: Map<string, AgentPermissionRequest>;
|
||||
session?: AgentSession | null;
|
||||
pendingRun?: ManagedAgent["pendingRun"];
|
||||
activeForegroundTurnId?: string | null;
|
||||
runtimeInfo?: ManagedAgent["runtimeInfo"];
|
||||
attention?: ManagedAgent["attention"];
|
||||
};
|
||||
@@ -42,8 +42,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
mcpServers: configOverrides.mcpServers,
|
||||
};
|
||||
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
|
||||
const pendingRun =
|
||||
overrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
const activeForegroundTurnId =
|
||||
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
id: overrides.id ?? "agent-test",
|
||||
@@ -65,7 +65,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
availableModes: overrides.availableModes ?? [],
|
||||
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
|
||||
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun,
|
||||
activeForegroundTurnId,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
unsubscribeSession: null,
|
||||
timeline: overrides.timeline ?? [],
|
||||
attention: overrides.attention ?? { requiresAttention: false },
|
||||
runtimeInfo: overrides.runtimeInfo ?? {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Logger } from "pino";
|
||||
import { AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
@@ -69,8 +70,11 @@ export type SerializableAgentConfig = Pick<
|
||||
>;
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
||||
return STORED_AGENT_SCHEMA.parse(value);
|
||||
}
|
||||
|
||||
export class AgentStorage {
|
||||
export class AgentStorage implements AgentSnapshotStore {
|
||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||
private pathById: Map<string, string> = new Map();
|
||||
private pathsById: Map<string, Set<string>> = new Map();
|
||||
@@ -300,7 +304,7 @@ export class AgentStorage {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(content);
|
||||
return STORED_AGENT_SCHEMA.parse(parsed);
|
||||
return parseStoredAgentRecord(parsed);
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
export type AgentTimelineRow = {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
item: AgentTimelineItem;
|
||||
};
|
||||
|
||||
export type AgentTimelineCursor = {
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
|
||||
|
||||
export type AgentTimelineFetchOptions = {
|
||||
direction?: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
/**
|
||||
* Number of canonical rows to return.
|
||||
* - undefined: store default
|
||||
* - 0: all rows in the selected window
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type AgentTimelineWindow = {
|
||||
minSeq: number;
|
||||
maxSeq: number;
|
||||
nextSeq: number;
|
||||
};
|
||||
|
||||
export type AgentTimelineFetchResult = {
|
||||
direction: AgentTimelineFetchDirection;
|
||||
window: AgentTimelineWindow;
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
rows: AgentTimelineRow[];
|
||||
};
|
||||
|
||||
export interface AgentTimelineStore {
|
||||
appendCommitted(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
): Promise<AgentTimelineRow>;
|
||||
fetchCommitted(
|
||||
agentId: string,
|
||||
options?: AgentTimelineFetchOptions,
|
||||
): Promise<AgentTimelineFetchResult>;
|
||||
getLatestCommittedSeq(agentId: string): Promise<number>;
|
||||
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
|
||||
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
|
||||
getLastAssistantMessage(agentId: string): Promise<string | null>;
|
||||
hasCommittedUserMessage(
|
||||
agentId: string,
|
||||
options: { messageId: string; text: string },
|
||||
): Promise<boolean>;
|
||||
deleteAgent(agentId: string): Promise<void>;
|
||||
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
} from "./agent-timeline-store-types.js";
|
||||
|
||||
export type SeedAgentTimelineOptions = {
|
||||
items?: readonly AgentTimelineItem[];
|
||||
rows?: readonly AgentTimelineRow[];
|
||||
nextSeq?: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
type AgentTimelineState = {
|
||||
rows: AgentTimelineRow[];
|
||||
nextSeq: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
|
||||
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
|
||||
return { ...row };
|
||||
}
|
||||
|
||||
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
|
||||
if (typeof messageId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = messageId.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export class InMemoryAgentTimelineStore {
|
||||
private readonly states = new Map<string, AgentTimelineState>();
|
||||
|
||||
has(agentId: string): boolean {
|
||||
return this.states.has(agentId);
|
||||
}
|
||||
|
||||
initialize(agentId: string, options?: SeedAgentTimelineOptions): void {
|
||||
const timestamp = options?.timestamp ?? new Date().toISOString();
|
||||
const rows = options?.rows?.length
|
||||
? options.rows.map(cloneRow)
|
||||
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
|
||||
const nextSeq =
|
||||
options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
|
||||
this.states.set(agentId, {
|
||||
rows,
|
||||
nextSeq,
|
||||
});
|
||||
}
|
||||
|
||||
delete(agentId: string): void {
|
||||
this.states.delete(agentId);
|
||||
}
|
||||
|
||||
getItems(agentId: string): AgentTimelineItem[] {
|
||||
return this.requireState(agentId).rows.map((row) => row.item);
|
||||
}
|
||||
|
||||
getRows(agentId: string): AgentTimelineRow[] {
|
||||
return this.requireState(agentId).rows.map(cloneRow);
|
||||
}
|
||||
|
||||
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const requestedLimit = options?.limit;
|
||||
const limit =
|
||||
requestedLimit === undefined
|
||||
? DEFAULT_TIMELINE_FETCH_LIMIT
|
||||
: Math.max(0, Math.floor(requestedLimit));
|
||||
const cursor = options?.cursor;
|
||||
const minSeq = state.rows.length ? state.rows[0]!.seq : 0;
|
||||
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1]!.seq : 0;
|
||||
const selectAll = limit === 0;
|
||||
|
||||
const window = {
|
||||
minSeq,
|
||||
maxSeq,
|
||||
nextSeq: state.nextSeq,
|
||||
};
|
||||
|
||||
if (state.rows.length === 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "tail") {
|
||||
const selected =
|
||||
selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit);
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: false,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "after") {
|
||||
const baseSeq = cursor?.seq ?? 0;
|
||||
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
|
||||
if (startIdx < 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: baseSeq >= minSeq,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectAll
|
||||
? state.rows.slice(startIdx)
|
||||
: state.rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected[0]!.seq > minSeq,
|
||||
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
const beforeSeq = cursor?.seq ?? state.nextSeq;
|
||||
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
: boundedRows.slice(boundedRows.length - limit);
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: endExclusive >= 0,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
append(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
): AgentTimelineRow {
|
||||
const state = this.requireState(agentId);
|
||||
const row: AgentTimelineRow = {
|
||||
seq: state.nextSeq,
|
||||
timestamp: options?.timestamp ?? new Date().toISOString(),
|
||||
item,
|
||||
};
|
||||
state.nextSeq += 1;
|
||||
state.rows.push(row);
|
||||
return cloneRow(row);
|
||||
}
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.rows[state.rows.length - 1]?.item ?? null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
const rows = this.requireState(agentId).rows;
|
||||
const chunks: string[] = [];
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const item = rows[i]!.item;
|
||||
if (item.type !== "assistant_message") {
|
||||
if (chunks.length > 0) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunks.reverse().join("");
|
||||
}
|
||||
|
||||
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
|
||||
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return [];
|
||||
}
|
||||
const messageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
if (!messageId) {
|
||||
return [];
|
||||
}
|
||||
return [[messageId, row.item.text]];
|
||||
});
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
|
||||
const messageId = normalizeTimelineMessageId(options.messageId);
|
||||
if (!messageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.requireState(agentId).rows.some((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return false;
|
||||
}
|
||||
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
return rowMessageId === messageId && row.item.text === options.text;
|
||||
});
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
const state = this.states.get(agentId);
|
||||
if (!state) {
|
||||
throw new Error(`Unknown agent '${agentId}'`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private buildRowsFromItems(
|
||||
items: readonly AgentTimelineItem[],
|
||||
startSeq: number,
|
||||
timestamp: string,
|
||||
): AgentTimelineRow[] {
|
||||
let nextSeq = startSeq;
|
||||
return items.map((item) => {
|
||||
const row: AgentTimelineRow = {
|
||||
seq: nextSeq,
|
||||
timestamp,
|
||||
item,
|
||||
};
|
||||
nextSeq += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { tmpdir } from "node:os";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
|
||||
type TestDeps = {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
spies: {
|
||||
agentManager: Record<string, any>;
|
||||
agentStorage: Record<string, any>;
|
||||
@@ -41,7 +41,7 @@ function createTestDeps(): TestDeps {
|
||||
|
||||
return {
|
||||
agentManager: agentManagerSpies as unknown as AgentManager,
|
||||
agentStorage: agentStorageSpies as unknown as AgentStorage,
|
||||
agentStorage: agentStorageSpies as unknown as AgentSnapshotStore,
|
||||
spies: {
|
||||
agentManager: agentManagerSpies,
|
||||
agentStorage: agentStorageSpies,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -31,7 +31,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
@@ -199,10 +199,7 @@ function startAgentRun(
|
||||
logger: Logger,
|
||||
options?: { replaceRunning?: boolean },
|
||||
): void {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const shouldReplace =
|
||||
options?.replaceRunning &&
|
||||
Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun));
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt)
|
||||
: agentManager.streamAgent(agentId, prompt);
|
||||
@@ -243,7 +240,7 @@ function sanitizePermissionRequest(
|
||||
}
|
||||
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
@@ -257,7 +254,7 @@ async function resolveAgentTitle(
|
||||
}
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
@@ -696,7 +693,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
|
||||
if (agentManager.hasInFlightRun(agentId)) {
|
||||
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,26 @@ describe("applyProviderEnv", () => {
|
||||
|
||||
expect(env.PATH).toBe("/custom/path");
|
||||
});
|
||||
|
||||
test("strips parent Claude Code session env vars", () => {
|
||||
const base = {
|
||||
PATH: "/usr/bin",
|
||||
CLAUDECODE: "1",
|
||||
CLAUDE_CODE_ENTRYPOINT: "sdk-ts",
|
||||
CLAUDE_CODE_SSE_PORT: "11803",
|
||||
CLAUDE_AGENT_SDK_VERSION: "0.2.71",
|
||||
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true",
|
||||
};
|
||||
|
||||
const env = applyProviderEnv(base, undefined, {});
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.CLAUDECODE).toBeUndefined();
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
expect(env.CLAUDE_CODE_SSE_PORT).toBeUndefined();
|
||||
expect(env.CLAUDE_AGENT_SDK_VERSION).toBeUndefined();
|
||||
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findExecutable", () => {
|
||||
|
||||
@@ -125,16 +125,31 @@ export function resolveShellEnv(): Record<string, string> {
|
||||
return cachedShellEnv;
|
||||
}
|
||||
|
||||
// Env vars that indicate a running Claude Code session. If the daemon itself is
|
||||
// launched from inside Claude Code (e.g. by a Paseo agent), these leak into
|
||||
// child processes and cause "cannot be launched inside another session" errors.
|
||||
const PARENT_SESSION_ENV_VARS = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SSE_PORT",
|
||||
"CLAUDE_AGENT_SDK_VERSION",
|
||||
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
||||
];
|
||||
|
||||
export function applyProviderEnv(
|
||||
baseEnv: Record<string, string | undefined>,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
shellEnv?: Record<string, string>,
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...baseEnv,
|
||||
...(shellEnv ?? resolveShellEnv()),
|
||||
...(runtimeSettings?.env ?? {}),
|
||||
};
|
||||
for (const key of PARENT_SESSION_ENV_VARS) {
|
||||
delete merged[key];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentMode } from "./agent-sdk-types.js";
|
||||
|
||||
export type AgentModeColorTier = "default" | "safe" | "moderate" | "dangerous" | "readonly";
|
||||
export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning";
|
||||
export type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff";
|
||||
|
||||
export interface AgentModeVisuals {
|
||||
@@ -44,7 +44,7 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
|
||||
label: "Plan Mode",
|
||||
description: "Analyze the codebase without executing tools or edits",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "readonly",
|
||||
colorTier: "planning",
|
||||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
@@ -62,7 +62,7 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
|
||||
description:
|
||||
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "readonly",
|
||||
colorTier: "safe",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
@@ -86,14 +86,14 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
|
||||
label: "Build",
|
||||
description: "Allows edits and tool execution for implementation work",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "default",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
description: "Read-only planning mode that avoids file edits",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "readonly",
|
||||
colorTier: "planning",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Integration tests for the agent event stream redesign (Unit 3).
|
||||
*
|
||||
* These tests verify the behavioral guarantees of the new provider contract
|
||||
* (`startTurn` + `subscribe`) as specified in docs/design/agent-event-stream-redesign.md.
|
||||
*
|
||||
* All tests use REAL Claude SDK sessions — no mocks.
|
||||
*
|
||||
* CREDENTIALS: These tests require a running `claude` CLI and either
|
||||
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
|
||||
* They are skipped automatically when credentials are unavailable.
|
||||
*/
|
||||
import { 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 type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../../provider-launch-config.js";
|
||||
import { ClaudeAgentClient } from "../claude-agent.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
const canRun = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||
|
||||
function tmpCwd(prefix: string): string {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
);
|
||||
}
|
||||
|
||||
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
|
||||
type EventWithTurnId = AgentStreamEvent & { turnId: string };
|
||||
|
||||
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
|
||||
return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
|
||||
}
|
||||
|
||||
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
|
||||
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
|
||||
}
|
||||
|
||||
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
|
||||
return events.filter(
|
||||
(e) =>
|
||||
e.type === "timeline" &&
|
||||
e.item.type === "user_message" &&
|
||||
e.item.text === text,
|
||||
);
|
||||
}
|
||||
|
||||
async function createSession(params?: {
|
||||
cwdPrefix?: string;
|
||||
}): Promise<{ cwd: string; session: AgentSession }> {
|
||||
const cwd = tmpCwd(params?.cwdPrefix ?? "event-stream-integration-");
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "event-stream integration",
|
||||
modeId: "acceptEdits",
|
||||
model: "haiku",
|
||||
});
|
||||
return { cwd, session };
|
||||
}
|
||||
|
||||
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
||||
await handle.session.close().catch(() => undefined);
|
||||
rmSync(handle.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function startTurnAndCollectEvents(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
options?: { extraMs?: number; timeoutMs?: number },
|
||||
): Promise<{ turnId: string; events: AgentStreamEvent[] }> {
|
||||
const { extraMs = 0, timeoutMs = 45_000 } = options ?? {};
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
let turnId: string | null = null;
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out after ${timeoutMs}ms waiting for terminal event`));
|
||||
}, timeoutMs);
|
||||
|
||||
const finish = () => {
|
||||
if (settled || !turnId) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve({ turnId, events });
|
||||
};
|
||||
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
events.push(event);
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
if (!isTerminalEvent(event) || !hasTurnId(event) || event.turnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (extraMs > 0) {
|
||||
setTimeout(finish, extraMs);
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
});
|
||||
|
||||
void session
|
||||
.startTurn(prompt)
|
||||
.then((result) => {
|
||||
turnId = result.turnId;
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invariant assertions — run after every test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[]): void {
|
||||
// Invariant 1: For each foreground turnId, at most ONE user_message event.
|
||||
// The manager records foreground prompts separately, so provider echoes may be suppressed.
|
||||
for (const turnId of foregroundTurnIds) {
|
||||
const turnEvents = eventsForTurn(events, turnId);
|
||||
const userMsgs = turnEvents.filter(
|
||||
(e) => e.type === "timeline" && e.item.type === "user_message",
|
||||
);
|
||||
expect(
|
||||
userMsgs.length,
|
||||
`Expected at most 1 user_message for turnId ${turnId}, got ${userMsgs.length}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Invariant 2: Every turn_started has exactly one matching terminal
|
||||
const turnStartedIds = events
|
||||
.filter((e) => e.type === "turn_started" && hasTurnId(e))
|
||||
.map((e) => (e as EventWithTurnId).turnId);
|
||||
|
||||
for (const turnId of turnStartedIds) {
|
||||
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
|
||||
expect(
|
||||
terminals.length,
|
||||
`Expected exactly 1 terminal for turnId ${turnId}, got ${terminals.length}`,
|
||||
).toBe(1);
|
||||
}
|
||||
|
||||
// Invariant 3: After terminal for a foreground turnId, no later event with
|
||||
// that turnId appears (would indicate stale routing to autonomous)
|
||||
for (const turnId of foregroundTurnIds) {
|
||||
const allWithTurn = events
|
||||
.map((e, i) => ({ event: e, index: i }))
|
||||
.filter(({ event }) => hasTurnId(event) && event.turnId === turnId);
|
||||
|
||||
const terminalEntry = allWithTurn.find(({ event }) => isTerminalEvent(event));
|
||||
if (!terminalEntry) continue;
|
||||
|
||||
const afterTerminal = allWithTurn.filter(({ index }) => index > terminalEntry.index);
|
||||
expect(
|
||||
afterTerminal.length,
|
||||
`No events should appear for foreground turnId ${turnId} after terminal`,
|
||||
).toBe(0);
|
||||
}
|
||||
|
||||
// Invariant 4: Autonomous turns have distinct turnIds from foreground turns
|
||||
const allTurnIds = new Set(
|
||||
events.filter(hasTurnId).map((e) => e.turnId),
|
||||
);
|
||||
const autonomousTurnIds = [...allTurnIds].filter(
|
||||
(id) => !foregroundTurnIds.includes(id),
|
||||
);
|
||||
for (const autoId of autonomousTurnIds) {
|
||||
expect(foregroundTurnIds).not.toContain(autoId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Agent event stream redesign — integration", () => {
|
||||
test.skipIf(!canRun)("Test 1: Basic foreground turn", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"respond with just the word hello",
|
||||
);
|
||||
|
||||
const turnStarted = events.find(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(turnStarted).toBeDefined();
|
||||
|
||||
const terminal = events.find(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.skipIf(!canRun)("Test 2: No duplicate user_messages — THE BUG", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
|
||||
|
||||
// No turn_started after terminal for the same turnId
|
||||
const terminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
const staleTurnStarted = events.slice(terminalIdx + 1).filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(staleTurnStarted.length).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.skipIf(!canRun)("Test 3: Lifecycle doesn't get stuck in running", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
const terminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
const afterTerminal = events.slice(terminalIdx + 1);
|
||||
|
||||
// No subsequent turn_started for same turnId
|
||||
expect(
|
||||
afterTerminal.filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
|
||||
).length,
|
||||
).toBe(0);
|
||||
|
||||
// Any turn_started after terminal must have a different turnId
|
||||
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
|
||||
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
|
||||
}
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.skipIf(!canRun)("Test 4: Autonomous run", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
|
||||
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||
|
||||
try {
|
||||
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Task tool to start a background sub-agent.",
|
||||
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||
"Do not wait for task completion.",
|
||||
"Reply immediately with exactly: SPAWNED",
|
||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||
].join(" "),
|
||||
{
|
||||
extraMs: 10_000,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
const fgTerminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
|
||||
);
|
||||
const afterForeground = events.slice(fgTerminalIdx + 1);
|
||||
|
||||
// Autonomous turn_started with a different turnId
|
||||
const autoStarts = afterForeground.filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||
) as EventWithTurnId[];
|
||||
if (autoStarts.length === 0) {
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const autoTurnId = autoStarts[0]!.turnId;
|
||||
expect(fgTurnId).not.toBe(autoTurnId);
|
||||
|
||||
// Autonomous turn reaches terminal
|
||||
expect(
|
||||
afterForeground.find(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
|
||||
),
|
||||
).toBeDefined();
|
||||
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test.skipIf(!canRun)("Test 5: Interruption", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
|
||||
|
||||
try {
|
||||
const { turnId } = await handle.session.startTurn(
|
||||
"write a very long essay about the history of computing",
|
||||
);
|
||||
|
||||
// Use a single subscription to avoid missing events between unsubscribe/resubscribe
|
||||
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
|
||||
const collected: AgentStreamEvent[] = [];
|
||||
let interrupted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out after 45000ms waiting for terminal event"));
|
||||
}, 45_000);
|
||||
|
||||
const unsubscribe = handle.session.subscribe((event) => {
|
||||
collected.push(event);
|
||||
|
||||
// Once we see turn_started, fire the interrupt
|
||||
if (
|
||||
!interrupted &&
|
||||
event.type === "turn_started" &&
|
||||
hasTurnId(event) &&
|
||||
event.turnId === turnId
|
||||
) {
|
||||
interrupted = true;
|
||||
handle.session.interrupt().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Resolve when we get a terminal event for this turn
|
||||
if (isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(collected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// turn_canceled or turn_failed arrives for that turnId
|
||||
const terminal = events.find(
|
||||
(e) =>
|
||||
(e.type === "turn_canceled" || e.type === "turn_failed") &&
|
||||
hasTurnId(e) &&
|
||||
e.turnId === turnId,
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
// No further events for that turnId after terminal
|
||||
const terminalIdx = events.indexOf(terminal!);
|
||||
expect(
|
||||
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
|
||||
).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.skipIf(!canRun)("Test 6: Sequential foreground turns", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
|
||||
|
||||
try {
|
||||
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"say first",
|
||||
);
|
||||
|
||||
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"say second",
|
||||
);
|
||||
|
||||
const allEvents = [...events1, ...events2];
|
||||
|
||||
expect(turnId1).not.toBe(turnId2);
|
||||
|
||||
// No events from turn 1 after turn 2 starts
|
||||
const turn2StartIdx = allEvents.findIndex(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
|
||||
);
|
||||
expect(
|
||||
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
|
||||
.length,
|
||||
).toBe(0);
|
||||
|
||||
assertInvariants(allEvents, [turnId1, turnId2]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test.skipIf(!canRun)("Test 7: Fast-fail", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
// At most one turn_started
|
||||
expect(
|
||||
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
|
||||
.length,
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
// Terminal present
|
||||
const terminal = events.find(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
// No stale turn_started after terminal
|
||||
const terminalIdx = events.indexOf(terminal!);
|
||||
expect(
|
||||
events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length,
|
||||
).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.skipIf(!canRun)("Test 8: User message dedup by text", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentLaunchContext } from "../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
|
||||
function createQueryMock(events: unknown[]) {
|
||||
let index = 0;
|
||||
return {
|
||||
next: vi.fn(async () =>
|
||||
index < events.length
|
||||
? { done: false, value: events[index++] }
|
||||
: { done: true, value: undefined },
|
||||
),
|
||||
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
close: vi.fn(() => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Claude agent env", () => {
|
||||
test("forwards launch-context env through Claude process env", async () => {
|
||||
let capturedEnv: Record<string, string | undefined> | undefined;
|
||||
const launchContext: AgentLaunchContext = {
|
||||
env: {
|
||||
PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000201",
|
||||
PASEO_TEST_FLAG: "launch-value",
|
||||
},
|
||||
};
|
||||
const queryFactory = vi.fn(
|
||||
({ options }: { options: { env?: Record<string, string | undefined> } }) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "managed-agent-env-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: "done" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
const session = await client.createSession(
|
||||
{
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await session.run("env check");
|
||||
expect(result.sessionId).toBe("managed-agent-env-session");
|
||||
expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
|
||||
expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("forwards launch-context env through Claude resume env", async () => {
|
||||
let capturedEnv: Record<string, string | undefined> | undefined;
|
||||
const launchContext: AgentLaunchContext = {
|
||||
env: {
|
||||
PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000202",
|
||||
PASEO_TEST_FLAG: "resume-launch-value",
|
||||
},
|
||||
};
|
||||
const queryFactory = vi.fn(
|
||||
({ options }: { options: { env?: Record<string, string | undefined> } }) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "persisted-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: "done" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
const session = await client.resumeSession(
|
||||
{
|
||||
provider: "claude",
|
||||
sessionId: "persisted-session",
|
||||
metadata: {
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
},
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await session.run("resume env check");
|
||||
expect(result.sessionId).toBe("persisted-session");
|
||||
expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
|
||||
expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,26 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
function tmpCwd(prefix: string): string {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
return (async function* empty() {})();
|
||||
}
|
||||
|
||||
function compactText(value: string): string {
|
||||
return value.replace(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
@@ -90,6 +98,30 @@ async function collectUntil(
|
||||
}
|
||||
}
|
||||
|
||||
function collectSubscribedUntil(
|
||||
session: AgentSession,
|
||||
predicate: (event: AgentStreamEvent) => boolean,
|
||||
timeoutMs = 45_000,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`));
|
||||
}, timeoutMs);
|
||||
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
events.push(event);
|
||||
if (!predicate(event)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(events);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAssistantText(events: AgentStreamEvent[]): string {
|
||||
return events
|
||||
.flatMap((event) => {
|
||||
@@ -142,18 +174,22 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P
|
||||
}
|
||||
|
||||
describe("ClaudeAgentSession integration", () => {
|
||||
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||
|
||||
beforeAll(() => {
|
||||
expect(isCommandAvailable("claude")).toBe(true);
|
||||
if (canRunClaudeIntegration) {
|
||||
expect(isCommandAvailable("claude")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("streams a basic response turn end-to-end", async () => {
|
||||
test.runIf(canRunClaudeIntegration)("streams a basic response turn end-to-end", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-basic-response-",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream("Respond with exactly: HELLO_WORLD"),
|
||||
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
|
||||
);
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
@@ -177,14 +213,59 @@ describe("ClaudeAgentSession integration", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("runs a real Bash tool call and completes it", async () => {
|
||||
test.runIf(canRunClaudeIntegration)(
|
||||
"supportedModels returns the current abstract Claude SDK model shape",
|
||||
async () => {
|
||||
const claudeQuery = query({
|
||||
prompt: createEmptyPrompt(),
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: ["user", "project"],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const models = await claudeQuery.supportedModels();
|
||||
|
||||
expect(models.length).toBeGreaterThanOrEqual(3);
|
||||
expect(models).toContainEqual(
|
||||
expect.objectContaining({
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
}),
|
||||
);
|
||||
expect(models).toContainEqual(
|
||||
expect.objectContaining({
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: expect.stringContaining("Haiku 4.5"),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
models.some(
|
||||
(model) =>
|
||||
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await claudeQuery.return?.();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-basic-tool-",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
streamSession(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: echo TOOL_TEST_OUTPUT",
|
||||
@@ -213,13 +294,16 @@ describe("ClaudeAgentSession integration", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("interrupts a running Bash turn and continues on the same query", async () => {
|
||||
test.runIf(canRunClaudeIntegration)(
|
||||
"interrupts a running Bash turn and continues on the same query",
|
||||
async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-interrupt-continue-",
|
||||
});
|
||||
|
||||
try {
|
||||
const firstStream = handle.session.stream(
|
||||
const firstStream = streamSession(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: sleep 10",
|
||||
@@ -254,7 +338,7 @@ describe("ClaudeAgentSession integration", () => {
|
||||
).toBe(true);
|
||||
|
||||
const followUpEvents = await collectUntilTerminal(
|
||||
handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK"),
|
||||
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
|
||||
);
|
||||
const secondQuery = getInternalQuery(handle.session);
|
||||
|
||||
@@ -267,18 +351,22 @@ describe("ClaudeAgentSession integration", () => {
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("creates an autonomous live turn when a background task completes", async () => {
|
||||
test.runIf(canRunClaudeIntegration)(
|
||||
"creates an autonomous live turn when a background task completes",
|
||||
async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-autonomous-",
|
||||
});
|
||||
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||
|
||||
try {
|
||||
const liveEventsStream = handle.session.streamLiveEvents();
|
||||
const foregroundEvents = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
streamSession(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Task tool to start a background sub-agent.",
|
||||
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||
@@ -292,9 +380,11 @@ describe("ClaudeAgentSession integration", () => {
|
||||
|
||||
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
|
||||
|
||||
const liveEvents = await collectUntilTerminal(liveEventsStream, {
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
const liveEvents = await collectSubscribedUntil(
|
||||
handle.session,
|
||||
(event) => isTerminalEvent(event),
|
||||
45_000,
|
||||
);
|
||||
|
||||
expect(
|
||||
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
|
||||
@@ -309,9 +399,11 @@ describe("ClaudeAgentSession integration", () => {
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("surfaces permission requests and resumes after approval", async () => {
|
||||
test.runIf(canRunClaudeIntegration)("surfaces permission requests and resumes after approval", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-permission-",
|
||||
modeId: "default",
|
||||
@@ -320,7 +412,8 @@ describe("ClaudeAgentSession integration", () => {
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
streamSession(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
|
||||
"If approval is required, wait for approval.",
|
||||
|
||||
+109
-20
@@ -2,12 +2,14 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
@@ -137,6 +139,7 @@ function createScriptedQuery(params: {
|
||||
return: vi.fn(async () => {
|
||||
output.end();
|
||||
}),
|
||||
close: vi.fn(() => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
@@ -209,6 +212,21 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function subscribeToEvents(session: { subscribe: (callback: (event: AgentStreamEvent) => void) => () => void }) {
|
||||
const queue = createAsyncQueue<AgentStreamEvent>();
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
queue.push(event);
|
||||
});
|
||||
|
||||
return {
|
||||
next: () => queue.next(),
|
||||
close: () => {
|
||||
unsubscribe();
|
||||
queue.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
options?: { timeoutMs?: number; intervalMs?: number },
|
||||
@@ -248,7 +266,7 @@ describe("ClaudeAgentSession interrupt regression", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = session.stream("first prompt");
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
|
||||
@@ -268,7 +286,7 @@ describe("ClaudeAgentSession interrupt regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("pushes the next prompt into the existing query instead of rebuilding it", async () => {
|
||||
test("reuses the existing query after interrupt before starting the next prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
@@ -298,11 +316,14 @@ describe("ClaudeAgentSession interrupt regression", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = session.stream("first prompt");
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(session.stream("second prompt"));
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
@@ -313,7 +334,6 @@ describe("ClaudeAgentSession interrupt regression", () => {
|
||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
|
||||
await firstTurn.return?.();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -394,12 +414,12 @@ describe("ClaudeAgentSession interrupt regression", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = session.stream("first prompt");
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(session.stream("second prompt"));
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
|
||||
@@ -408,6 +428,73 @@ describe("ClaudeAgentSession interrupt regression", () => {
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("stale abort result after replacement start does not poison the new foreground turn", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-stale-result-session",
|
||||
});
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
const firstStarted = await firstTurn.next();
|
||||
await waitFor(() => queryRef?.prompts.length === 1);
|
||||
|
||||
await session.interrupt();
|
||||
const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))];
|
||||
expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true);
|
||||
|
||||
const observedSecondTurnEvents: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
observedSecondTurnEvents.push(event);
|
||||
});
|
||||
|
||||
const secondTurn = streamSession(session, "second prompt");
|
||||
const secondStarted = await secondTurn.next();
|
||||
await waitFor(() => queryRef?.prompts.length === 2);
|
||||
|
||||
queryRef?.emit({
|
||||
type: "result",
|
||||
subtype: "error_during_execution",
|
||||
errors: ["Request was aborted."],
|
||||
session_id: "interrupt-stale-result-session",
|
||||
});
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SECOND_PROMPT_RESPONSE" },
|
||||
session_id: "interrupt-stale-result-session",
|
||||
});
|
||||
queryRef?.emit(buildSuccessResult("interrupt-stale-result-session"));
|
||||
|
||||
const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))];
|
||||
unsubscribe();
|
||||
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
expect(
|
||||
observedSecondTurnEvents.filter((event) => event.type === "turn_started").length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
observedSecondTurnEvents.some(
|
||||
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeAgentSession autonomous turns", () => {
|
||||
@@ -440,9 +527,9 @@ describe("ClaudeAgentSession autonomous turns", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await collectUntilTerminal(session.stream("seed prompt"));
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const liveIterator = session.streamLiveEvents();
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
@@ -450,9 +537,9 @@ describe("ClaudeAgentSession autonomous turns", () => {
|
||||
});
|
||||
queryRef?.emit(buildSuccessResult("autonomous-live-session"));
|
||||
|
||||
const started = await liveIterator.next();
|
||||
const timeline = await liveIterator.next();
|
||||
const completed = await liveIterator.next();
|
||||
const started = await subscribedEvents.next();
|
||||
const timeline = await subscribedEvents.next();
|
||||
const completed = await subscribedEvents.next();
|
||||
|
||||
expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" });
|
||||
expect(timeline.value).toMatchObject({
|
||||
@@ -468,7 +555,7 @@ describe("ClaudeAgentSession autonomous turns", () => {
|
||||
provider: "claude",
|
||||
});
|
||||
|
||||
await liveIterator.return?.();
|
||||
subscribedEvents.close();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -510,19 +597,21 @@ describe("ClaudeAgentSession autonomous turns", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await collectUntilTerminal(session.stream("seed prompt"));
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const liveIterator = session.streamLiveEvents();
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "BACKGROUND_ONLY_RESPONSE" },
|
||||
session_id: "autonomous-handoff-session",
|
||||
});
|
||||
|
||||
const autonomousStart = await liveIterator.next();
|
||||
const autonomousTimeline = await liveIterator.next();
|
||||
const foregroundEvents = await collectUntilTerminal(session.stream("foreground prompt"));
|
||||
const autonomousComplete = await liveIterator.next();
|
||||
const autonomousStart = await subscribedEvents.next();
|
||||
const autonomousTimeline = await subscribedEvents.next();
|
||||
const foregroundEvents = await collectUntilTerminal(
|
||||
streamSession(session, "foreground prompt"),
|
||||
);
|
||||
const autonomousComplete = await subscribedEvents.next();
|
||||
|
||||
expect(autonomousStart.value).toMatchObject({
|
||||
type: "turn_started",
|
||||
@@ -553,7 +642,7 @@ describe("ClaudeAgentSession autonomous turns", () => {
|
||||
"foreground prompt",
|
||||
]);
|
||||
|
||||
await liveIterator.return?.();
|
||||
subscribedEvents.close();
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
);
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, "Respond with exactly: HELLO_MAX")) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) {
|
||||
return events;
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("Claude max effort availability (real)", () => {
|
||||
test.runIf(isCommandAvailable("claude") && hasClaudeCredentials)(
|
||||
"surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable",
|
||||
async () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "bypassPermissions",
|
||||
model: "claude-opus-4-6",
|
||||
thinkingOptionId: "max",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(session);
|
||||
const failure = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
|
||||
event.type === "turn_failed",
|
||||
);
|
||||
|
||||
expect(failure).toBeDefined();
|
||||
expect(failure?.error).toContain("Claude Code process exited with code 1");
|
||||
expect(failure?.code).toBe("1");
|
||||
expect(failure?.diagnostic).toContain('Effort level "max" is not available');
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { normalizeClaudeRuntimeModelId } from "./claude-agent.js";
|
||||
import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js";
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
function latestModelId(family: "sonnet" | "opus" | "haiku"): string {
|
||||
const latest = CLAUDE_MODEL_CATALOG.find(
|
||||
(model) => model.family === family && model.isLatestInFamily,
|
||||
);
|
||||
if (latest) {
|
||||
return latest.modelId;
|
||||
}
|
||||
const fallback = CLAUDE_MODEL_CATALOG.find((model) => model.family === family);
|
||||
if (!fallback) {
|
||||
throw new Error(`Missing Claude model family in catalog: ${family}`);
|
||||
}
|
||||
return fallback.modelId;
|
||||
}
|
||||
|
||||
const SONNET = latestModelId("sonnet");
|
||||
const OPUS = latestModelId("opus");
|
||||
const HAIKU = latestModelId("haiku");
|
||||
const supportedModelIds = new Set([SONNET, OPUS, HAIKU]);
|
||||
const supportedModelFamilyAliases = new Map([
|
||||
["sonnet", SONNET],
|
||||
["opus", OPUS],
|
||||
["haiku", HAIKU],
|
||||
] as const);
|
||||
|
||||
test("preserves runtime model when it already exists in the supported catalog", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: SONNET,
|
||||
supportedModelIds,
|
||||
});
|
||||
expect(normalized).toBe(SONNET);
|
||||
});
|
||||
|
||||
test("maps unknown runtime Sonnet versions to the catalog Sonnet model ID", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||
supportedModelIds,
|
||||
supportedModelFamilyAliases,
|
||||
});
|
||||
expect(normalized).toBe(SONNET);
|
||||
});
|
||||
|
||||
test("maps unknown runtime Opus versions to the catalog Opus model ID", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-opus-4-5-20251101",
|
||||
supportedModelIds,
|
||||
supportedModelFamilyAliases,
|
||||
});
|
||||
expect(normalized).toBe(OPUS);
|
||||
});
|
||||
|
||||
test("maps unknown runtime Haiku versions to the catalog Haiku model ID", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-haiku-4-6-20260101",
|
||||
supportedModelIds,
|
||||
supportedModelFamilyAliases,
|
||||
});
|
||||
expect(normalized).toBe(HAIKU);
|
||||
});
|
||||
|
||||
test("uses configured model when runtime ID is unknown", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-custom-unknown",
|
||||
supportedModelIds,
|
||||
configuredModelId: OPUS,
|
||||
});
|
||||
expect(normalized).toBe(OPUS);
|
||||
});
|
||||
|
||||
test("uses current model when runtime and configured IDs are unknown", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-custom-unknown",
|
||||
supportedModelIds,
|
||||
configuredModelId: "claude-unknown",
|
||||
currentModelId: HAIKU,
|
||||
});
|
||||
expect(normalized).toBe(HAIKU);
|
||||
});
|
||||
|
||||
test("preserves runtime model when mapping is not possible", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-custom-unknown",
|
||||
supportedModelIds: new Set(["x", "y"]),
|
||||
});
|
||||
expect(normalized).toBe("claude-custom-unknown");
|
||||
});
|
||||
|
||||
test("does not force family fallback for unknown runtime families", () => {
|
||||
const normalized = normalizeClaudeRuntimeModelId({
|
||||
runtimeModelId: "claude-custom-unknown",
|
||||
supportedModelIds,
|
||||
});
|
||||
expect(normalized).toBe("claude-custom-unknown");
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,20 @@ import type { Logger } from "pino";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
|
||||
type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
|
||||
};
|
||||
|
||||
function buildUsage() {
|
||||
@@ -46,11 +49,15 @@ function createBaseQueryMock(nextImpl: QueryMock["next"]): QueryMock {
|
||||
next: nextImpl,
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
close: vi.fn(() => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,13 +270,14 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("emits interrupt step diagnostics without info logs", async () => {
|
||||
test("interruptActiveTurn only interrupts the active query without info logs", async () => {
|
||||
const spy = createSpyLogger();
|
||||
const session = await createSessionWithLogger(spy.logger);
|
||||
const internal = session as unknown as {
|
||||
query: {
|
||||
interrupt: () => Promise<void>;
|
||||
return?: () => Promise<void>;
|
||||
close?: () => void;
|
||||
} | null;
|
||||
input: { end: () => void } | null;
|
||||
queryRestartNeeded: boolean;
|
||||
@@ -281,6 +289,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
internal.query = {
|
||||
interrupt,
|
||||
return: queryReturn,
|
||||
close: vi.fn(() => undefined),
|
||||
};
|
||||
internal.input = { end };
|
||||
internal.queryRestartNeeded = false;
|
||||
@@ -296,15 +305,12 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
);
|
||||
|
||||
expect(interruptInfoMessages).toEqual([]);
|
||||
expect(interruptDebugMessages).toEqual([
|
||||
"interruptActiveTurn: calling query.interrupt()...",
|
||||
"interruptActiveTurn: calling query.return()...",
|
||||
]);
|
||||
expect(interruptDebugMessages).toEqual([]);
|
||||
expect(interrupt).toHaveBeenCalledTimes(1);
|
||||
expect(queryReturn).toHaveBeenCalledTimes(1);
|
||||
expect(end).toHaveBeenCalledTimes(1);
|
||||
expect(internal.query).toBeNull();
|
||||
expect(internal.input).toBeNull();
|
||||
expect(queryReturn).not.toHaveBeenCalled();
|
||||
expect(end).not.toHaveBeenCalled();
|
||||
expect(internal.query).not.toBeNull();
|
||||
expect(internal.input).not.toBeNull();
|
||||
expect(internal.queryRestartNeeded).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -625,164 +631,6 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("routes by deterministic identifier priority: task_id > parent_message_id > message_id", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
createRun: (owner: "autonomous", queue: null) => { id: string };
|
||||
runTracker: {
|
||||
bindIdentifiers: (
|
||||
run: { id: string },
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
|
||||
) => void;
|
||||
};
|
||||
routeMessage: (input: {
|
||||
message: AgentStreamEvent | Record<string, unknown>;
|
||||
identifiers: {
|
||||
taskId: string | null;
|
||||
parentMessageId: string | null;
|
||||
messageId: string | null;
|
||||
};
|
||||
metadataOnly: boolean;
|
||||
}) => { run: { id: string } | null; reason: string };
|
||||
turnState: "autonomous";
|
||||
};
|
||||
|
||||
const taskRun = internal.createRun("autonomous", null);
|
||||
internal.runTracker.bindIdentifiers(taskRun, {
|
||||
taskId: "task-A",
|
||||
parentMessageId: null,
|
||||
messageId: "msg-A",
|
||||
});
|
||||
|
||||
const parentRun = internal.createRun("autonomous", null);
|
||||
internal.runTracker.bindIdentifiers(parentRun, {
|
||||
taskId: null,
|
||||
parentMessageId: "parent-B",
|
||||
messageId: "msg-B",
|
||||
});
|
||||
|
||||
const messageRun = internal.createRun("autonomous", null);
|
||||
internal.runTracker.bindIdentifiers(messageRun, {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: "msg-C",
|
||||
});
|
||||
|
||||
internal.turnState = "autonomous";
|
||||
|
||||
const taskPriorityRoute = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "task-priority" } },
|
||||
identifiers: {
|
||||
taskId: "task-A",
|
||||
parentMessageId: "parent-B",
|
||||
messageId: "msg-C",
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(taskPriorityRoute.reason).toBe("task_id");
|
||||
expect(taskPriorityRoute.run?.id).toBe(taskRun.id);
|
||||
|
||||
const parentPriorityRoute = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "parent-priority" } },
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: "parent-B",
|
||||
messageId: "msg-C",
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(parentPriorityRoute.reason).toBe("parent_message_id");
|
||||
expect(parentPriorityRoute.run?.id).toBe(parentRun.id);
|
||||
|
||||
const messagePriorityRoute = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "message-priority" } },
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: "msg-C",
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(messagePriorityRoute.reason).toBe("message_id");
|
||||
expect(messagePriorityRoute.run?.id).toBe(messageRun.id);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("does not route unbound events to foreground before prompt replay is observed", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
|
||||
runTracker: {
|
||||
bindIdentifiers: (
|
||||
run: { id: string },
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
|
||||
) => void;
|
||||
};
|
||||
activeForegroundTurn: { runId: string; queue: unknown } | null;
|
||||
turnState: "foreground";
|
||||
routeMessage: (input: {
|
||||
message: Record<string, unknown>;
|
||||
identifiers: {
|
||||
taskId: string | null;
|
||||
parentMessageId: string | null;
|
||||
messageId: string | null;
|
||||
};
|
||||
metadataOnly: boolean;
|
||||
}) => { run: { id: string } | null; reason: string };
|
||||
};
|
||||
|
||||
const foregroundQueueStub = {
|
||||
push: () => undefined,
|
||||
end: () => undefined,
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => ({ done: true as const, value: undefined }),
|
||||
}),
|
||||
};
|
||||
const foregroundRun = internal.createRun("foreground", foregroundQueueStub);
|
||||
internal.turnState = "foreground";
|
||||
internal.activeForegroundTurn = {
|
||||
runId: foregroundRun.id,
|
||||
queue: foregroundQueueStub,
|
||||
};
|
||||
|
||||
const unboundBeforeReplay = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "stale-before-replay" } },
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: null,
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(unboundBeforeReplay.reason).toBe("foreground");
|
||||
expect(unboundBeforeReplay.run?.id).toBe(foregroundRun.id);
|
||||
|
||||
const promptReplayId = "foreground-replay-id";
|
||||
internal.runTracker.bindIdentifiers(foregroundRun, {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: promptReplayId,
|
||||
});
|
||||
const replayRoute = internal.routeMessage({
|
||||
message: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "prompt replay" },
|
||||
uuid: promptReplayId,
|
||||
},
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: promptReplayId,
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(replayRoute.reason).toBe("message_id");
|
||||
expect(replayRoute.run?.id).toBe(foregroundRun.id);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("completes a foreground run when only system metadata arrives before the first assistant message", async () => {
|
||||
let step = 0;
|
||||
sdkQueryFactory.mockImplementation(() =>
|
||||
@@ -846,7 +694,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const session = await createSession();
|
||||
try {
|
||||
const events = await Promise.race([
|
||||
collectUntilTerminal(session.stream("metadata helper prompt")),
|
||||
collectUntilTerminal(streamSession(session, "metadata helper prompt")),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error("Timed out waiting for foreground terminal event")),
|
||||
@@ -870,15 +718,79 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("captures Claude stderr in the turn failure diagnostic when stderr arrives after process exit", async () => {
|
||||
const stderrMessage =
|
||||
'Error: Effort level "max" is not available for Claude.ai subscribers. Please use "low", "medium", or "high".';
|
||||
let capturedOptions:
|
||||
| {
|
||||
stderr?: (data: string) => void;
|
||||
effort?: string;
|
||||
permissionMode?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
sdkQueryFactory.mockImplementation(
|
||||
({ options }: { options: { stderr?: (data: string) => void; effort?: string } }) => {
|
||||
capturedOptions = options;
|
||||
let failed = false;
|
||||
return createBaseQueryMock(
|
||||
vi.fn(async () => {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
setTimeout(() => {
|
||||
options.stderr?.(`${stderrMessage}\n`);
|
||||
}, 0);
|
||||
throw new Error("Claude Code process exited with code 1");
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const loggerSpy = createSpyLogger();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: loggerSpy.logger,
|
||||
queryFactory: sdkQueryFactory,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "bypassPermissions",
|
||||
thinkingOptionId: "max",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(streamSession(session, "trigger max failure"));
|
||||
const failure = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
|
||||
event.type === "turn_failed",
|
||||
);
|
||||
|
||||
expect(capturedOptions?.permissionMode).toBe("bypassPermissions");
|
||||
expect(capturedOptions?.effort).toBe("max");
|
||||
expect(failure).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: "Claude Code process exited with code 1",
|
||||
code: "1",
|
||||
diagnostic: stderrMessage,
|
||||
});
|
||||
expect(loggerSpy.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stderr: stderrMessage }),
|
||||
"Claude Agent SDK stderr",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
turnState: "idle" | "foreground" | "autonomous";
|
||||
nextRunOrdinal: number;
|
||||
nextTurnOrdinal: number;
|
||||
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
|
||||
runTracker: {
|
||||
listActiveRuns: (owner?: "foreground" | "autonomous") => Array<{ id: string }>;
|
||||
};
|
||||
autonomousTurn: { id: string } | null;
|
||||
};
|
||||
|
||||
internal.turnState = "idle";
|
||||
@@ -890,9 +802,9 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const firstRun = internal.runTracker.listActiveRuns("autonomous");
|
||||
expect(firstRun).toHaveLength(1);
|
||||
expect(internal.nextRunOrdinal).toBe(2);
|
||||
const firstRunId = internal.autonomousTurn?.id ?? null;
|
||||
expect(firstRunId).toBe("autonomous-turn-1");
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
|
||||
internal.routeSdkMessageFromPump({
|
||||
type: "stream_event",
|
||||
@@ -901,10 +813,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
delta: { type: "text_delta", text: "WAKE" },
|
||||
},
|
||||
});
|
||||
const secondRun = internal.runTracker.listActiveRuns("autonomous");
|
||||
expect(secondRun).toHaveLength(1);
|
||||
expect(secondRun[0]?.id).toBe(firstRun[0]?.id);
|
||||
expect(internal.nextRunOrdinal).toBe(2);
|
||||
expect(internal.autonomousTurn?.id).toBe(firstRunId);
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
|
||||
internal.routeSdkMessageFromPump({
|
||||
type: "result",
|
||||
@@ -912,73 +822,11 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
});
|
||||
expect(internal.runTracker.listActiveRuns("autonomous")).toHaveLength(0);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("pushEvent does not route side-channel events into a stale foreground queue", async () => {
|
||||
const session = await createSession();
|
||||
const staleQueueEvents: AgentStreamEvent[] = [];
|
||||
const staleQueue = {
|
||||
push: (event: AgentStreamEvent) => staleQueueEvents.push(event),
|
||||
end: () => undefined,
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => ({ done: true as const, value: undefined }),
|
||||
}),
|
||||
};
|
||||
|
||||
const internal = session as unknown as {
|
||||
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
|
||||
activeForegroundTurn: { runId: string; queue: unknown } | null;
|
||||
runTracker: {
|
||||
getRun: (runId: string) => unknown;
|
||||
complete: (run: unknown, state: "completed") => void;
|
||||
};
|
||||
pushEvent: (event: AgentStreamEvent) => void;
|
||||
};
|
||||
|
||||
const staleForegroundRun = internal.createRun("foreground", staleQueue);
|
||||
const runRecord = internal.runTracker.getRun(staleForegroundRun.id);
|
||||
internal.runTracker.complete(runRecord, "completed");
|
||||
internal.activeForegroundTurn = {
|
||||
runId: staleForegroundRun.id,
|
||||
queue: staleQueue,
|
||||
};
|
||||
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const permissionEvent: AgentStreamEvent = {
|
||||
type: "permission_requested",
|
||||
provider: "claude",
|
||||
request: {
|
||||
id: "permission-1",
|
||||
provider: "claude",
|
||||
name: "Bash",
|
||||
kind: "tool",
|
||||
},
|
||||
};
|
||||
internal.pushEvent(permissionEvent);
|
||||
|
||||
const next = await Promise.race([
|
||||
liveIterator.next(),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Timed out waiting for live side-channel event")), 1_000);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(next.done).toBe(false);
|
||||
expect(next.value).toEqual(permissionEvent);
|
||||
expect(staleQueueEvents).toHaveLength(0);
|
||||
expect(internal.activeForegroundTurn).toBeNull();
|
||||
|
||||
await liveIterator.return?.();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("tracks run lifecycle transitions for success, error, and interrupt", async () => {
|
||||
const session = await createSession();
|
||||
let streamCase: "success" | "error" | "interrupt" = "success";
|
||||
@@ -1074,18 +922,18 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
});
|
||||
|
||||
streamCase = "success";
|
||||
const successEvents = await collectUntilTerminal(session.stream("success prompt"));
|
||||
const successEvents = await collectUntilTerminal(streamSession(session, "success prompt"));
|
||||
expect(successEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(successEvents.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(false);
|
||||
|
||||
streamCase = "error";
|
||||
const errorEvents = await collectUntilTerminal(session.stream("error prompt"));
|
||||
const errorEvents = await collectUntilTerminal(streamSession(session, "error prompt"));
|
||||
expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true);
|
||||
expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(false);
|
||||
|
||||
streamCase = "interrupt";
|
||||
const interruptStream = session.stream("interrupt prompt");
|
||||
const interruptStream = streamSession(session, "interrupt prompt");
|
||||
const interruptEvents: AgentStreamEvent[] = [];
|
||||
for await (const event of interruptStream) {
|
||||
interruptEvents.push(event);
|
||||
@@ -1204,7 +1052,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
});
|
||||
|
||||
const session = await createSession();
|
||||
const events = await collectUntilTerminal(session.stream("timeline prompt"));
|
||||
const events = await collectUntilTerminal(streamSession(session, "timeline prompt"));
|
||||
const assistantText = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
@@ -1323,7 +1171,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
});
|
||||
|
||||
const session = await createSession();
|
||||
const events = await collectUntilTerminal(session.stream("uuid fallback prompt"));
|
||||
const events = await collectUntilTerminal(streamSession(session, "uuid fallback prompt"));
|
||||
const assistantText = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
@@ -1337,7 +1185,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const assembler = session as unknown as {
|
||||
timelineAssembler: { messages: Map<string, unknown> };
|
||||
};
|
||||
expect(assembler.timelineAssembler.messages.size).toBe(1);
|
||||
expect(assembler.timelineAssembler.messages.size).toBe(0);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "../agent-manager.js";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
@@ -18,11 +19,13 @@ type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
|
||||
};
|
||||
|
||||
function buildQueryMock(events: unknown[]): QueryMock {
|
||||
@@ -38,11 +41,15 @@ function buildQueryMock(events: unknown[]): QueryMock {
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
close: vi.fn(() => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,7 +256,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("delegate work"));
|
||||
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
|
||||
await session.close();
|
||||
|
||||
const timelineToolCalls = events
|
||||
@@ -310,7 +317,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("delegate work"));
|
||||
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
|
||||
await session.close();
|
||||
|
||||
const timelineToolCalls = events
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
@@ -241,6 +242,13 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
describe("ClaudeAgentClient.listModels", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
function createSupportedModelsQueryMock(models: ModelInfo[]) {
|
||||
return {
|
||||
supportedModels: vi.fn(async () => models),
|
||||
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||
};
|
||||
}
|
||||
|
||||
test("returns models with required fields", async () => {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const models = await client.listModels();
|
||||
@@ -267,4 +275,134 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("prefers provider-discovered Claude defaults and effort levels", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const queryFactory = vi.fn(() => queryMock);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.supportedModels).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-sonnet-4-6",
|
||||
isDefault: true,
|
||||
label: "Sonnet 4.6",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves SDK ids even when descriptions are weak", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: vi.fn(() => queryMock) as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
}),
|
||||
]);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keeps the Claude control-plane query open until supportedModels resolves", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
let promptIterator: AsyncIterator<SDKUserMessage, void> | null = null;
|
||||
let promptNextPromise: Promise<IteratorResult<SDKUserMessage, void>> | null = null;
|
||||
let promptClosedBeforeModelsResolved = false;
|
||||
|
||||
queryMock.supportedModels = vi.fn(async () => {
|
||||
promptNextPromise = promptIterator?.next() ?? null;
|
||||
if (!promptNextPromise) {
|
||||
throw new Error("Prompt iterator not captured");
|
||||
}
|
||||
promptNextPromise.then(() => {
|
||||
promptClosedBeforeModelsResolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(promptClosedBeforeModelsResolved).toBe(false);
|
||||
return [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
] satisfies ModelInfo[];
|
||||
});
|
||||
|
||||
const queryFactory = vi.fn(({ prompt }) => {
|
||||
promptIterator = prompt[Symbol.asyncIterator]();
|
||||
return queryMock;
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expect(promptNextPromise).not.toBeNull();
|
||||
await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined });
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user