mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
77 Commits
android-v0
...
v0.1.35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2000ff789 | ||
|
|
759815cfac | ||
|
|
68df304867 | ||
|
|
a2aac3797f | ||
|
|
f13178a9ad | ||
|
|
c1d71dfedc | ||
|
|
2e7bc49c4c | ||
|
|
55dd3a9b1a | ||
|
|
5b3aea8bfe | ||
|
|
e47eb64de0 | ||
|
|
eada5eec53 | ||
|
|
1ee7cace36 | ||
|
|
612731a5b5 | ||
|
|
f50252c3b1 | ||
|
|
3cd1552ddc | ||
|
|
caab3f9686 | ||
|
|
bf8ba4da8f | ||
|
|
3fcc1ecedb | ||
|
|
46d7512cc4 | ||
|
|
00e172798b | ||
|
|
bb560809c7 | ||
|
|
765a11c7ab | ||
|
|
08e37540eb | ||
|
|
1768e2787f | ||
|
|
6d467d4659 | ||
|
|
701480c11c | ||
|
|
9ce6b45cf9 | ||
|
|
7eebee2de0 | ||
|
|
ff2fc72581 | ||
|
|
cdd7071885 | ||
|
|
30cba9550e | ||
|
|
1318372e81 | ||
|
|
185990f46b | ||
|
|
508cd524a8 | ||
|
|
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 |
3
.github/workflows/deploy-app.yml
vendored
3
.github/workflows/deploy-app.yml
vendored
@@ -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
|
||||
|
||||
|
||||
41
.github/workflows/fix-nix-hash.yml
vendored
Normal file
41
.github/workflows/fix-nix-hash.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Fix Nix hash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fix-nix-hash:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Fix lockfile and update hash
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
59
.github/workflows/nix-build.yml
vendored
Normal file
59
.github/workflows/nix-build.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/server/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/cli/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/server/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/cli/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Verify lockfile is complete
|
||||
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||
run: |
|
||||
node scripts/fix-lockfile.mjs package-lock.json
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Check npmDepsHash is up to date
|
||||
run: ./scripts/update-nix.sh --check
|
||||
3
.github/workflows/server-ci.yml
vendored
3
.github/workflows/server-ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.35 - 2026-03-26
|
||||
|
||||
### Improved
|
||||
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
||||
- Codex file deletions now display correctly as removed lines in diffs.
|
||||
- OpenCode questions are now surfaced in the permission UI.
|
||||
|
||||
### Fixed
|
||||
- Fixed queued prompt dispatch after idle transition.
|
||||
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
||||
|
||||
### Added
|
||||
- Added support for Nix and NixOS installation.
|
||||
|
||||
## 0.1.34 - 2026-03-25
|
||||
|
||||
### Added
|
||||
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
||||
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
||||
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
||||
- Linked Android download surfaces to the Google Play Store.
|
||||
|
||||
### Improved
|
||||
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
||||
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
||||
- Restored per-provider form preferences and removed the Auto model fallback.
|
||||
- Improved Codex activity logs with clearer tool-call summaries.
|
||||
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
|
||||
- Improved chat transcript readability.
|
||||
|
||||
### Fixed
|
||||
- Fixed `paseo send --no-wait` not taking effect.
|
||||
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
||||
- Fixed Claude interrupt handling and autonomous wake reliability.
|
||||
- Fixed nested Claude Code session detection and provider availability checks.
|
||||
- Fixed agent input focus scoping across panels.
|
||||
- Fixed terminal snapshot ordering when subscribing.
|
||||
- Fixed `chat read --since` to accept message IDs.
|
||||
- Fixed keyboard pane focus syncing with the active panel.
|
||||
- Fixed assistant text selection on web.
|
||||
- Fixed archived-agent notifications still appearing in chat rooms.
|
||||
- Fixed the attach-images button interaction in the message composer.
|
||||
- Pruned wrong-platform native binaries from Electron desktop builds.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -46,9 +46,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
|
||||
|
||||
70
README.md
70
README.md
@@ -4,40 +4,88 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">One interface for all your coding agents.</p>
|
||||
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
||||
### Desktop app
|
||||
|
||||
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
|
||||
|
||||
### Headless / server mode
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
Run the daemon on any machine:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then connect from the desktop app, mobile app, or CLI.
|
||||
Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
|
||||
|
||||
For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
Everything you can do in the app, you can do from the terminal.
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
paseo send abc123 "also add tests" # follow-up task
|
||||
|
||||
# run on a remote daemon
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
## Orchestration skills (Unstable)
|
||||
|
||||
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
```
|
||||
|
||||
Then use them in any agent conversation:
|
||||
|
||||
```bash
|
||||
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||
|
||||
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||
|
||||
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||
# Very opinionated and expects both Codex and Claude to work.
|
||||
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Quick monorepo package map:
|
||||
@@ -57,8 +105,12 @@ npm run dev
|
||||
# run individual surfaces
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# build the daemon
|
||||
npm run build:daemon
|
||||
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
|
||||
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||
|
||||
170
docs/design/agent-event-stream-redesign.md
Normal file
170
docs/design/agent-event-stream-redesign.md
Normal file
@@ -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
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772963539,
|
||||
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
59
flake.nix
Normal file
59
flake.nix
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
}:
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [ ./nix/module.nix ];
|
||||
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.python3
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
172
nix/module.nix
Normal file
172
nix/module.nix
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.paseo;
|
||||
in
|
||||
{
|
||||
options.services.paseo = {
|
||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||
|
||||
package = lib.mkPackageOption pkgs "paseo" { };
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "User account under which Paseo runs.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "Group under which Paseo runs.";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default =
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/${cfg.user}/.paseo";
|
||||
defaultText = lib.literalExpression ''
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/''${cfg.user}/.paseo"
|
||||
'';
|
||||
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 6767;
|
||||
description = "Port for the Paseo daemon to listen on.";
|
||||
};
|
||||
|
||||
listenAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address for the Paseo daemon to bind to.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||
|
||||
Set to `true` to allow any host (not recommended).
|
||||
'';
|
||||
};
|
||||
|
||||
relay = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
};
|
||||
};
|
||||
|
||||
inheritUserEnvironment = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.user != "paseo";
|
||||
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||
description = ''
|
||||
Whether to include the user's profile PATH in the service environment.
|
||||
|
||||
When Paseo runs as a real user (not the default system user), AI agents
|
||||
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||
NixOS profile and system paths so agents can use them without manually
|
||||
setting PATH.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
}
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.paseo = {
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||
# includes /run/current-system/sw/bin which is a superset of those.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
|
||||
ExecStart =
|
||||
"${cfg.package}/bin/paseo-server"
|
||||
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||
KillSignal = "SIGTERM";
|
||||
TimeoutStopSec = 15;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
144
nix/package.nix
Normal file
144
nix/package.nix
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter = path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||
# Exclude test fixtures and debug files
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-gOwvUBvem1SxDMypaexz6RaHRm2xFmUT9iwOW2ErEAM=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
# We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
dontNpmBuild = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all daemon packages in dependency order (defined in package.json)
|
||||
npm run build:daemon
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/paseo
|
||||
|
||||
# Copy root package metadata
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Copy node_modules (preserving workspace symlinks)
|
||||
cp -a node_modules $out/lib/paseo/
|
||||
|
||||
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||
name=$(basename "$link")
|
||||
if [ -d "packages/$name/dist" ]; then
|
||||
mkdir -p "$out/lib/paseo/packages/$name"
|
||||
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||
if [ -d "packages/$name/node_modules" ]; then
|
||||
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||
fi
|
||||
else
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy CLI bin entry
|
||||
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||
|
||||
# Copy extra server files referenced at runtime
|
||||
for f in agent-prompt.md .env.example; do
|
||||
if [ -f packages/server/$f ]; then
|
||||
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy server scripts (daemon-runner, supervisor) needed by CLI
|
||||
if [ -d packages/server/dist/scripts ]; then
|
||||
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||
fi
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
89
package-lock.json
generated
89
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34842,16 +34842,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@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.35",
|
||||
"@getpaseo/highlight": "0.1.35",
|
||||
"@getpaseo/server": "0.1.35",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -34937,6 +34937,8 @@
|
||||
},
|
||||
"packages/app/node_modules/expo-clipboard": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -34956,6 +34958,8 @@
|
||||
},
|
||||
"packages/app/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -34963,11 +34967,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.32",
|
||||
"@getpaseo/server": "0.1.32",
|
||||
"@getpaseo/relay": "0.1.35",
|
||||
"@getpaseo/server": "0.1.35",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -34987,6 +34991,8 @@
|
||||
},
|
||||
"packages/cli/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
@@ -34997,6 +35003,8 @@
|
||||
},
|
||||
"packages/cli/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -35004,10 +35012,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.32",
|
||||
"@getpaseo/server": "0.1.32",
|
||||
"@getpaseo/cli": "0.1.35",
|
||||
"@getpaseo/server": "0.1.35",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -35040,7 +35049,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35241,7 +35250,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35267,7 +35276,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35283,13 +35292,13 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@getpaseo/highlight": "0.1.35",
|
||||
"@getpaseo/relay": "0.1.35",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
@@ -35334,6 +35343,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
|
||||
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
@@ -35355,6 +35366,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -35369,6 +35382,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
@@ -35415,6 +35430,8 @@
|
||||
},
|
||||
"packages/server/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
@@ -35426,6 +35443,8 @@
|
||||
},
|
||||
"packages/server/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -35440,6 +35459,8 @@
|
||||
},
|
||||
"packages/server/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -35450,6 +35471,8 @@
|
||||
},
|
||||
"packages/server/node_modules/body-parser": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
@@ -35468,6 +35491,8 @@
|
||||
},
|
||||
"packages/server/node_modules/content-disposition": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
@@ -35478,6 +35503,8 @@
|
||||
},
|
||||
"packages/server/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
@@ -35485,6 +35512,8 @@
|
||||
},
|
||||
"packages/server/node_modules/finalhandler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
@@ -35500,6 +35529,8 @@
|
||||
},
|
||||
"packages/server/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -35507,6 +35538,8 @@
|
||||
},
|
||||
"packages/server/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -35514,6 +35547,8 @@
|
||||
},
|
||||
"packages/server/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -35524,6 +35559,8 @@
|
||||
},
|
||||
"packages/server/node_modules/mime-types": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
@@ -35534,6 +35571,8 @@
|
||||
},
|
||||
"packages/server/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -35572,6 +35611,8 @@
|
||||
},
|
||||
"packages/server/node_modules/send": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
@@ -35592,6 +35633,8 @@
|
||||
},
|
||||
"packages/server/node_modules/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -35605,6 +35648,8 @@
|
||||
},
|
||||
"packages/server/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@@ -35618,6 +35663,8 @@
|
||||
},
|
||||
"packages/server/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
@@ -35630,6 +35677,8 @@
|
||||
},
|
||||
"packages/server/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -35637,7 +35686,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
@@ -35663,6 +35712,8 @@
|
||||
},
|
||||
"packages/website/node_modules/@types/node": {
|
||||
"version": "22.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
|
||||
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -44,9 +44,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",
|
||||
@@ -68,6 +68,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"homepage": "https://paseo.sh",
|
||||
"keywords": [
|
||||
"openai",
|
||||
"realtime",
|
||||
@@ -76,7 +77,14 @@
|
||||
"development",
|
||||
"mcp"
|
||||
],
|
||||
"author": "moboudra",
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"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.35",
|
||||
"@getpaseo/highlight": "0.1.35",
|
||||
"@getpaseo/server": "0.1.35",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
export const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
export function shouldWaitOnStartupRace(input: {
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
pathname: string;
|
||||
}): boolean {
|
||||
if (input.onlineServerId) {
|
||||
return false;
|
||||
}
|
||||
if (input.pathname === WELCOME_ROUTE) {
|
||||
return false;
|
||||
}
|
||||
if (input.hasTimedOut) {
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
|
||||
export function shouldRedirectToWelcome(input: {
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
pathname: string;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
}): boolean {
|
||||
if (input.onlineServerId || !input.hasTimedOut) {
|
||||
return false;
|
||||
}
|
||||
if (input.pathname !== "/" && input.pathname !== "") {
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
let cancelled = false;
|
||||
const store = getHostRuntimeStore();
|
||||
|
||||
void store
|
||||
.loadFromStorage()
|
||||
const init = async () => {
|
||||
const settings = await loadSettingsFromStorage();
|
||||
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
|
||||
await store.loadFromStorage();
|
||||
if (isDesktopManaged) {
|
||||
await store.bootstrap({ manageBuiltInDaemon: true });
|
||||
} else {
|
||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||
}
|
||||
};
|
||||
|
||||
void init()
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
setReady(true);
|
||||
void store.bootstrap();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] Failed to initialize store", error);
|
||||
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}, [isLoading, settings.theme]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingView />;
|
||||
const isDesktopManaged =
|
||||
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
|
||||
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,113 +1,18 @@
|
||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from "@/app-support/index-startup";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
const runtime = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
);
|
||||
}
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||
const isDesktopStartupRace = shouldUseDesktopDaemon();
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setHasTimedOut(true);
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onlineServerId) {
|
||||
return;
|
||||
}
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(onlineServerId) as any);
|
||||
}, [onlineServerId, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldRedirectToWelcome({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
pathname,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
router.replace(WELCOME_ROUTE as any);
|
||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
||||
|
||||
if (
|
||||
shouldWaitOnStartupRace({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
pathname,
|
||||
})
|
||||
) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
if (!onlineServerId) {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -950,18 +950,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<View style={styles.leftButtonGroup}>
|
||||
{onPickImages && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
@@ -1203,7 +1205,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
leftButtonGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -31,11 +31,11 @@ import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
} from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -392,15 +392,15 @@ function NewWorktreeButton({
|
||||
}}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
loading ? (
|
||||
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Plus
|
||||
size={14}
|
||||
<FolderPlus
|
||||
size={15}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
@@ -411,7 +411,7 @@ function NewWorktreeButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostMutations,
|
||||
useHostRuntimeSnapshot,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
|
||||
actionTextPrimary: {
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
hostList: {
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
marginTop: theme.spacing[6],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
hostRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
hostLabel: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
hostStatus: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
hostStatusError: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
versionLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
const runtime = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const status = snapshot?.connectionStatus ?? "connecting";
|
||||
const lastError = snapshot?.lastError ?? null;
|
||||
|
||||
let dotColor: string;
|
||||
let statusText: string;
|
||||
let isError = false;
|
||||
|
||||
switch (status) {
|
||||
case "online":
|
||||
dotColor = theme.colors.success;
|
||||
statusText = "Online";
|
||||
break;
|
||||
case "connecting":
|
||||
case "idle":
|
||||
dotColor = theme.colors.foregroundMuted;
|
||||
statusText = "Connecting…";
|
||||
break;
|
||||
case "offline":
|
||||
dotColor = theme.colors.foregroundMuted;
|
||||
statusText = "Offline";
|
||||
break;
|
||||
case "error":
|
||||
dotColor = theme.colors.destructive;
|
||||
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
|
||||
isError = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.hostRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
|
||||
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
|
||||
{statusText}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WelcomeScreenProps {
|
||||
onHostAdded?: (profile: HostProfile) => void;
|
||||
}
|
||||
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
@@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyOnlineServerId) {
|
||||
return;
|
||||
}
|
||||
if (pendingNameHost) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Connect to your host to start</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{showHostList && (
|
||||
<View style={styles.hostList}>
|
||||
{hosts.map((host) => (
|
||||
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionAgents) {
|
||||
previousAgentStatusRef.current.clear();
|
||||
return;
|
||||
}
|
||||
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
for (const nextAgent of sessionAgents.values()) {
|
||||
nextStatuses.set(nextAgent.id, nextAgent.status);
|
||||
}
|
||||
previousAgentStatusRef.current = nextStatuses;
|
||||
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
|
||||
previousAgentStatusRef.current,
|
||||
sessionAgents,
|
||||
);
|
||||
}, [sessionAgents]);
|
||||
|
||||
const hydrateWorkspaces = useCallback(
|
||||
|
||||
72
packages/app/src/contexts/session-status-tracking.test.ts
Normal file
72
packages/app/src/contexts/session-status-tracking.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
|
||||
|
||||
function createAgent(status: Agent["status"]): Agent {
|
||||
return {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date(0),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Agent",
|
||||
cwd: "/tmp",
|
||||
model: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reconcilePreviousAgentStatuses", () => {
|
||||
it("preserves previously seen status for existing agents", () => {
|
||||
const previous = new Map([["agent-1", "running" as const]]);
|
||||
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||
|
||||
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||
});
|
||||
|
||||
it("seeds newly seen agents from the current snapshot", () => {
|
||||
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
|
||||
|
||||
expect(result).toEqual(new Map([["agent-1", "idle"]]));
|
||||
});
|
||||
|
||||
it("removes agents that are no longer present", () => {
|
||||
const previous = new Map([
|
||||
["agent-1", "running" as const],
|
||||
["agent-2", "idle" as const],
|
||||
]);
|
||||
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||
|
||||
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||
});
|
||||
|
||||
it("clears all tracked statuses when the session is unavailable", () => {
|
||||
const previous = new Map([["agent-1", "running" as const]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(previous, undefined);
|
||||
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
29
packages/app/src/contexts/session-status-tracking.ts
Normal file
29
packages/app/src/contexts/session-status-tracking.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
export function reconcilePreviousAgentStatuses(
|
||||
previousStatuses: Map<string, AgentLifecycleStatus>,
|
||||
sessionAgents: Map<string, Agent> | undefined,
|
||||
): Map<string, AgentLifecycleStatus> {
|
||||
if (!sessionAgents) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const nextStatuses = new Map(previousStatuses);
|
||||
const seenAgentIds = new Set<string>();
|
||||
|
||||
for (const agent of sessionAgents.values()) {
|
||||
seenAgentIds.add(agent.id);
|
||||
if (!nextStatuses.has(agent.id)) {
|
||||
nextStatuses.set(agent.id, agent.status);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of nextStatuses.keys()) {
|
||||
if (!seenAgentIds.has(agentId)) {
|
||||
nextStatuses.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextStatuses;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -1136,7 +1137,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<void> {
|
||||
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
|
||||
if (this.bootstrapAttempted) {
|
||||
return;
|
||||
}
|
||||
@@ -1152,7 +1153,9 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapDesktop();
|
||||
if (options?.manageBuiltInDaemon ?? true) {
|
||||
await this.bootstrapDesktop();
|
||||
}
|
||||
} else {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
@@ -1162,20 +1165,16 @@ export class HostRuntimeStore {
|
||||
try {
|
||||
const daemon = await startDesktopDaemon();
|
||||
const connection = connectionFromListen(daemon.listen);
|
||||
if (!connection) {
|
||||
if (!connection || !daemon.serverId) {
|
||||
return;
|
||||
}
|
||||
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||
});
|
||||
await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: hostname ?? daemon.hostname ?? undefined,
|
||||
serverId: daemon.serverId,
|
||||
label: daemon.hostname ?? undefined,
|
||||
connection,
|
||||
existingClient: client,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
99
packages/app/src/utils/web-focus.test.ts
Normal file
99
packages/app/src/utils/web-focus.test.ts
Normal file
@@ -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.35",
|
||||
"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.35",
|
||||
"@getpaseo/server": "0.1.35",
|
||||
"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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ publish:
|
||||
owner: getpaseo
|
||||
repo: paseo
|
||||
mac:
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
category: public.app-category.developer-tools
|
||||
icon: assets/icon.icns
|
||||
hardenedRuntime: true
|
||||
@@ -30,11 +31,15 @@ linux:
|
||||
category: Development
|
||||
icon: assets
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
maintainer: "Mohamed Boudra <hello@moboudra.com>"
|
||||
vendor: "Paseo"
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
- tar.gz
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"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.35",
|
||||
"@getpaseo/server": "0.1.35",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -25,5 +25,15 @@
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"wait-on": "8.0.5"
|
||||
}
|
||||
},
|
||||
"homepage": "https://paseo.sh",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later"
|
||||
}
|
||||
|
||||
@@ -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.35",
|
||||
"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.35",
|
||||
"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.35",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
# Voice Assistant System Prompt
|
||||
|
||||
## 1. Core Voice Rules (NON-NEGOTIABLE)
|
||||
|
||||
### Voice Context
|
||||
|
||||
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
|
||||
|
||||
### Voice Message Envelope
|
||||
|
||||
Some user utterances will be wrapped in an XML tag like:
|
||||
|
||||
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
|
||||
|
||||
- Treat the inner text as what the user said (STT output).
|
||||
- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”.
|
||||
|
||||
**Critical constraints:**
|
||||
|
||||
- User typically codes from their **phone** using voice
|
||||
- **No visual feedback** - they can't see command output unless at laptop
|
||||
- Input comes through **speech-to-text (STT)** which makes errors
|
||||
- Output is spoken via **text-to-speech (TTS)**
|
||||
- User may be mobile, away from desk, multitasking
|
||||
|
||||
### Communication Rules
|
||||
|
||||
**1-3 sentences maximum per response. Always.**
|
||||
|
||||
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
|
||||
- **Progressive disclosure** - answer what's asked, let user ask for more
|
||||
- **Start high-level** - give the gist, not every detail
|
||||
- **Natural pauses** - leave room for user to respond or redirect
|
||||
|
||||
**Good example:**
|
||||
|
||||
```
|
||||
User: "List my agents"
|
||||
You: "You have two agents. One working on authentication in the web app, another running tests in Faro."
|
||||
|
||||
User: "How's the auth agent doing?"
|
||||
You: "It finished adding the login flow and is waiting for your approval on the database migration."
|
||||
```
|
||||
|
||||
**Bad example:**
|
||||
|
||||
```
|
||||
User: "List my agents"
|
||||
You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..."
|
||||
```
|
||||
|
||||
### Handling STT Errors
|
||||
|
||||
Speech-to-text makes mistakes. Fix them silently using context.
|
||||
|
||||
**Common errors:**
|
||||
|
||||
- Homophones: "list" → "missed", "code" → "load"
|
||||
- Project names: "faro" → "pharaoh", "mcp" → "empty"
|
||||
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
|
||||
|
||||
**How to handle:**
|
||||
|
||||
1. Use context to fix obvious mistakes silently
|
||||
2. Ask for clarification only when truly ambiguous
|
||||
3. Never lecture about the error - just handle it
|
||||
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
|
||||
|
||||
**Examples:**
|
||||
|
||||
- User: "Run empty install" → Interpret as "Run npm install"
|
||||
- User: "Check the agent" → If only one agent, check that one; if multiple, ask which
|
||||
|
||||
### Immediate Silence Protocol
|
||||
|
||||
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
|
||||
|
||||
- "I'm not talking to you"
|
||||
- "Shut up" / "Be quiet" / "Stop talking"
|
||||
- "Not you"
|
||||
|
||||
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
|
||||
|
||||
## 2. Delegation Pattern
|
||||
|
||||
### Core Rule: Always Work Through Coding Agents
|
||||
|
||||
Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately.
|
||||
|
||||
### Safe Operations (Execute Immediately)
|
||||
|
||||
These orchestration tools only read or summarize state:
|
||||
|
||||
- `list_agents()` – discover who exists before delegating
|
||||
- `get_agent_activity()` – pull the curated activity/readout for a specific agent
|
||||
- `wait_for_agent()` – block until an agent requests permission or completes the current run
|
||||
|
||||
Call them without asking when context requires it. Never fabricate their output.
|
||||
|
||||
### Delegated Operations (Announce + Execute)
|
||||
|
||||
- `create_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately.
|
||||
- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt.
|
||||
- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions.
|
||||
|
||||
After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user.
|
||||
|
||||
### When to Ask vs Act
|
||||
|
||||
Ask only when the routing decision is truly ambiguous. Otherwise:
|
||||
|
||||
- Default to the most recently addressed agent.
|
||||
- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch.
|
||||
- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating).
|
||||
|
||||
### Tool Results Reporting
|
||||
|
||||
After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked.
|
||||
|
||||
## 4. Agent Integrations
|
||||
|
||||
### Your Role: Orchestrator
|
||||
|
||||
You orchestrate work. Agents execute.
|
||||
|
||||
**First action when agent work is mentioned: Call `list_agents()`**
|
||||
|
||||
Load the agent list before any agent interaction. Always.
|
||||
|
||||
#### Focus Management
|
||||
|
||||
- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent.
|
||||
- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop.
|
||||
- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?”
|
||||
- Always narrate hand-offs: “Okay, handing that to Planner.”
|
||||
- Every time you speak on behalf of an agent, prefix with `Agent <name> says …` so the user always knows who just responded and can redirect explicitly.
|
||||
|
||||
**Confirm before destructive agent operations:**
|
||||
- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative)
|
||||
- Killing agents: "Kill agent [id] working on [task]?"
|
||||
|
||||
**Delegate vs execute:**
|
||||
- Everything touching code, git, or shell runs through agents
|
||||
- Keep lightweight questions or summaries in-orchestrator when no action is needed
|
||||
- Active agent context → Send prompt to that agent (respect focus)
|
||||
|
||||
### Available Agents (Source of Truth)
|
||||
|
||||
We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations.
|
||||
|
||||
**Claude Code (`claude`)**
|
||||
- Default mode: `plan`
|
||||
- Alternate mode: `bypassPermissions`
|
||||
- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution.
|
||||
|
||||
**Codex (`codex`)**
|
||||
- Default mode: `auto`
|
||||
- Other modes: `read-only`, `full-access`
|
||||
- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access.
|
||||
|
||||
### Creating Agents
|
||||
|
||||
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
|
||||
|
||||
```javascript
|
||||
// Claude Code with planning
|
||||
create_agent({
|
||||
cwd: "~/dev/paseo",
|
||||
agentType: "claude",
|
||||
initialPrompt: "add dark mode toggle to settings page",
|
||||
initialMode: "plan"
|
||||
})
|
||||
|
||||
// Codex for quick edits
|
||||
create_agent({
|
||||
cwd: "~/dev/paseo",
|
||||
agentType: "codex",
|
||||
initialPrompt: "clean up the logging",
|
||||
initialMode: "auto"
|
||||
})
|
||||
```
|
||||
|
||||
If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
|
||||
|
||||
### Working with Agents
|
||||
|
||||
**Send prompts to agents:**
|
||||
|
||||
```javascript
|
||||
// Send task (non-blocking by default)
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "explain how authentication works"
|
||||
})
|
||||
// Returns immediately, agent processes in background
|
||||
|
||||
// Send task and wait for completion
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "fix the bug in auth.ts",
|
||||
maxWait: 60000 // Wait up to 60 seconds
|
||||
})
|
||||
|
||||
// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access)
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "implement user registration",
|
||||
sessionMode: "bypassPermissions"
|
||||
})
|
||||
```
|
||||
|
||||
**Check agent status:**
|
||||
|
||||
```javascript
|
||||
// Get current status
|
||||
get_agent_status({ agentId: "abc123" })
|
||||
// Returns: { status: "processing", info: {...} }
|
||||
|
||||
// Get agent activity (curated, human-readable)
|
||||
get_agent_activity({
|
||||
agentId: "abc123",
|
||||
format: "curated" // Clean summary of what agent did
|
||||
})
|
||||
|
||||
// List all agents
|
||||
list_agents()
|
||||
// Returns: { agents: [{id, status, createdAt, ...}, ...] }
|
||||
```
|
||||
|
||||
**Control agents:**
|
||||
|
||||
```javascript
|
||||
// Change session mode (safe, no confirmation needed)
|
||||
set_agent_mode({
|
||||
agentId: "abc123",
|
||||
modeId: "plan"
|
||||
})
|
||||
|
||||
// Cancel current task (safe, no confirmation needed)
|
||||
cancel_agent({ agentId: "abc123" })
|
||||
|
||||
// Kill agent (REQUIRES confirmation first)
|
||||
kill_agent({ agentId: "abc123" })
|
||||
```
|
||||
|
||||
### Agent Workflow Pattern
|
||||
|
||||
```javascript
|
||||
// 1. Load agents first
|
||||
list_agents()
|
||||
|
||||
// 2. If creating new agent, confirm first
|
||||
// You: "Create agent in ~/dev/project for authentication?"
|
||||
// User: "yes"
|
||||
|
||||
// 3. Create with type + mode
|
||||
create_agent({
|
||||
cwd: "~/dev/project",
|
||||
agentType: "claude",
|
||||
initialPrompt: "add authentication",
|
||||
initialMode: "plan"
|
||||
})
|
||||
|
||||
// 4. Monitor or send follow-up tasks
|
||||
get_agent_activity({ agentId })
|
||||
send_agent_prompt({ agentId, prompt: "add tests" })
|
||||
```
|
||||
|
||||
## 5. Git & GitHub
|
||||
|
||||
### Git Worktree Utilities
|
||||
|
||||
Custom utilities for safe worktree management:
|
||||
|
||||
**create-worktree:**
|
||||
- Creates new git worktree with new branch
|
||||
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
|
||||
- Outputs WORKTREE_PATH for you to parse
|
||||
|
||||
**delete-worktree:**
|
||||
- Preserves the branch, only deletes directory
|
||||
- Safe to use - won't lose work
|
||||
- Run from within worktree directory
|
||||
|
||||
### GitHub CLI (gh)
|
||||
|
||||
Already authenticated. Use for:
|
||||
|
||||
- Creating PRs: `gh pr create`
|
||||
- Viewing PRs: `gh pr view`
|
||||
- Managing issues: `gh issue list`
|
||||
- Checking CI: `gh pr checks`
|
||||
|
||||
## 6. Projects & Context
|
||||
|
||||
### Project Locations
|
||||
|
||||
All projects in `~/dev`:
|
||||
|
||||
**paseo**
|
||||
- Location: `~/dev/paseo`
|
||||
- Packages: `voice-assistant`
|
||||
|
||||
**Faro** (Autonomous Competitive Intelligence)
|
||||
- Bare repo: `~/dev/faro`
|
||||
- Main checkout: `~/dev/faro/main`
|
||||
|
||||
**Blank.page** (Minimal browser text editor)
|
||||
- Location: `~/dev/blank.page/editor`
|
||||
|
||||
### Decision Rules
|
||||
|
||||
**Agent work mentioned?**
|
||||
1. Call `list_agents()` first
|
||||
2. Reuse existing agent if task relates to its work
|
||||
3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately.
|
||||
|
||||
**Creating/killing agents?**
|
||||
- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit
|
||||
- Ask: "Kill agent [id]?"
|
||||
- Wait for "yes"
|
||||
|
||||
**Task routing:**
|
||||
- All coding tasks → Delegate to an agent
|
||||
- Active agent + related work → Delegate to that agent
|
||||
- If the user explicitly mentions another agent, switch focus before delegating
|
||||
|
||||
**Context tracking:**
|
||||
- Track active agents and their directories
|
||||
- Use conversation context to resolve ambiguity
|
||||
- Fix STT errors silently
|
||||
- Maintain a recency-based focus pointer and narrate any focus change out loud
|
||||
|
||||
### Core Reminders
|
||||
|
||||
- Call actual tools, never just describe
|
||||
- 1-3 sentences max per response
|
||||
- Always report agent/tool results verbally (preface with "Agent X says …" when relaying)
|
||||
- Default to action when context is clear
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.35",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -13,8 +13,7 @@
|
||||
"dist/scripts",
|
||||
"src/server/speech/providers/local/sherpa/assets",
|
||||
"README.md",
|
||||
".env.example",
|
||||
"agent-prompt.md"
|
||||
".env.example"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -64,8 +63,8 @@
|
||||
"@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",
|
||||
"@getpaseo/highlight": "0.1.35",
|
||||
"@getpaseo/relay": "0.1.35",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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 ?? {
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import type { AgentModelDefinition } from "../agent-sdk-types.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function isBinaryInstalled(binary: string): boolean {
|
||||
@@ -15,6 +16,16 @@ function isBinaryInstalled(binary: string): boolean {
|
||||
const hasCodex = isBinaryInstalled("codex");
|
||||
const hasOpenCode = isBinaryInstalled("opencode");
|
||||
|
||||
function modelMatchesFamily(
|
||||
model: AgentModelDefinition,
|
||||
family: "sonnet" | "haiku",
|
||||
): boolean {
|
||||
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
|
||||
value.toLowerCase(),
|
||||
);
|
||||
return haystacks.some((text) => text.includes(family));
|
||||
}
|
||||
|
||||
describe("provider model catalogs (e2e)", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
@@ -32,11 +43,8 @@ describe("provider model catalogs (e2e)", () => {
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.models.length).toBeGreaterThan(0);
|
||||
|
||||
const descriptions = result.models.map((model) =>
|
||||
`${model.label} ${model.description ?? ""}`.toLowerCase(),
|
||||
);
|
||||
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
|
||||
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
|
||||
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
|
||||
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
|
||||
}, 180_000);
|
||||
|
||||
test.runIf(hasCodex)(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 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 {
|
||||
let turnId: string | null = null;
|
||||
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);
|
||||
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
|
||||
turnId = event.turnId;
|
||||
}
|
||||
|
||||
// Once we see turn_started, fire the interrupt
|
||||
if (
|
||||
!interrupted &&
|
||||
turnId &&
|
||||
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 (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(collected);
|
||||
}
|
||||
});
|
||||
|
||||
void handle.session
|
||||
.startTurn("write a very long essay about the history of computing")
|
||||
.then((result) => {
|
||||
if (turnId && turnId !== result.turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(
|
||||
new Error(
|
||||
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
turnId = result.turnId;
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
expect(turnId).toBeDefined();
|
||||
|
||||
// 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.",
|
||||
|
||||
@@ -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();
|
||||
@@ -435,7 +441,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("routes input_json_delta through partial parsing before buffered JSON is complete", async () => {
|
||||
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
@@ -473,7 +479,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
partial_json: '{"command":"echo ',
|
||||
},
|
||||
},
|
||||
expectedCommand: "echo ",
|
||||
expectedCommand: "echo seed",
|
||||
},
|
||||
{
|
||||
event: {
|
||||
@@ -514,7 +520,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces canonical partial tool input from input_json_delta before JSON is complete", async () => {
|
||||
test("does not surface incomplete string values from input_json_delta", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
@@ -545,7 +551,6 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
|
||||
expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({
|
||||
file_path: "src/message.tsx",
|
||||
old_string: "before",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -625,164 +630,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 +693,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 +717,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 +801,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 +812,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 +821,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 +921,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 +1051,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 +1170,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 +1184,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 } 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,86 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import { appendFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, 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 { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
@@ -19,8 +20,6 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
||||
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
|
||||
const APPENDED_TASK_NOTIFICATION_MARKER = "Appended background task completed";
|
||||
const APPENDED_ASSISTANT_MARKER = "APPENDED_BACKGROUND_ASSISTANT_MARKER";
|
||||
|
||||
function buildSdkQueryMock() {
|
||||
const events = [
|
||||
@@ -61,24 +60,15 @@ function buildSdkQueryMock() {
|
||||
}),
|
||||
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 })),
|
||||
};
|
||||
}
|
||||
|
||||
function buildIdleSdkQueryMock() {
|
||||
return {
|
||||
next: vi.fn(async () => ({ done: true, value: undefined })),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,28 +88,6 @@ function collectTimelineText(events: AgentStreamEvent[]): string {
|
||||
return chunks.join("\n");
|
||||
}
|
||||
|
||||
async function readNextEvent(
|
||||
iterator: AsyncIterator<AgentStreamEvent>,
|
||||
timeoutMs: number,
|
||||
): Promise<AgentStreamEvent> {
|
||||
const outcome = await Promise.race([
|
||||
iterator.next().then((result) => ({ kind: "result" as const, result })),
|
||||
new Promise<{ kind: "timeout" }>((resolve) => {
|
||||
setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
if (outcome.kind === "timeout") {
|
||||
throw new Error("Timed out waiting for live event");
|
||||
}
|
||||
|
||||
if (outcome.result.done) {
|
||||
throw new Error("Live event stream ended before appended transcript arrived");
|
||||
}
|
||||
|
||||
return outcome.result.value;
|
||||
}
|
||||
|
||||
describe("ClaudeAgentSession history replay regression", () => {
|
||||
let tempRoot: string;
|
||||
let cwd: string;
|
||||
@@ -200,7 +168,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
try {
|
||||
for await (const event of session.stream("Say hello")) {
|
||||
for await (const event of streamSession(session, "Say hello")) {
|
||||
events.push(event);
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
@@ -249,112 +217,6 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
|
||||
});
|
||||
|
||||
test("emits appended transcript lines through streamLiveEvents after history was primed", async () => {
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
const mock = buildIdleSdkQueryMock();
|
||||
sdkMocks.lastQuery = mock;
|
||||
return mock;
|
||||
});
|
||||
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "history-session",
|
||||
nativeHandle: "history-session",
|
||||
metadata: {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
|
||||
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
|
||||
const historyPath = path.join(configDir, "projects", sanitized, "history-session.jsonl");
|
||||
|
||||
const session = await client.resumeSession(handle, { cwd });
|
||||
|
||||
try {
|
||||
for await (const _event of session.streamHistory()) {
|
||||
// Prime existing persisted history the same way agent-manager does.
|
||||
}
|
||||
|
||||
const liveEvents = session.streamLiveEvents();
|
||||
const iterator = liveEvents[Symbol.asyncIterator]();
|
||||
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`\n${JSON.stringify({
|
||||
type: "queue-operation",
|
||||
operation: "enqueue",
|
||||
uuid: "appended-task-note-1",
|
||||
content: [
|
||||
"<task-notification>",
|
||||
"<task-id>appended-bg-1</task-id>",
|
||||
"<status>completed</status>",
|
||||
`<summary>${APPENDED_TASK_NOTIFICATION_MARKER}</summary>`,
|
||||
"<output-file>/tmp/appended-bg-1.txt</output-file>",
|
||||
"</task-notification>",
|
||||
].join("\n"),
|
||||
})}\n${JSON.stringify({
|
||||
type: "assistant",
|
||||
sessionId: "history-session",
|
||||
cwd,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: APPENDED_ASSISTANT_MARKER,
|
||||
},
|
||||
})}`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const appendedEvents: AgentStreamEvent[] = [];
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
appendedEvents.push(await readNextEvent(iterator, 1_500));
|
||||
const sawTaskNotification = appendedEvents.some(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "task_notification",
|
||||
);
|
||||
const sawAssistant = appendedEvents.some(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes(APPENDED_ASSISTANT_MARKER),
|
||||
);
|
||||
if (sawTaskNotification && sawAssistant) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const timelineText = collectTimelineText(appendedEvents);
|
||||
const taskNotificationEvent = appendedEvents.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "task_notification",
|
||||
);
|
||||
const turnStartedEvent = appendedEvents.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_started" }> =>
|
||||
event.type === "turn_started",
|
||||
);
|
||||
|
||||
expect(taskNotificationEvent).toBeTruthy();
|
||||
expect(turnStartedEvent).toBeTruthy();
|
||||
expect(timelineText).toContain(APPENDED_ASSISTANT_MARKER);
|
||||
expect(taskNotificationEvent?.item.metadata).toMatchObject({
|
||||
taskId: "appended-bg-1",
|
||||
status: "completed",
|
||||
outputFile: "/tmp/appended-bg-1.txt",
|
||||
});
|
||||
expect(taskNotificationEvent?.item.detail).toMatchObject({
|
||||
type: "plain_text",
|
||||
label: APPENDED_TASK_NOTIFICATION_MARKER,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("listCommands includes rewind command", async () => {
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
@@ -394,7 +256,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
try {
|
||||
for await (const event of session.stream("/rewind")) {
|
||||
for await (const event of streamSession(session, "/rewind")) {
|
||||
events.push(event);
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||
|
||||
/**
|
||||
* Temporary hardcoded Claude model catalog.
|
||||
*
|
||||
* Why:
|
||||
* - Claude SDK model discovery currently returns abstract options like
|
||||
* "default", "opus", and "haiku".
|
||||
* - Runtime init messages report concrete model IDs like
|
||||
* "claude-opus-4-6".
|
||||
* - That mismatch breaks model selection + thinking reconciliation in UI.
|
||||
*
|
||||
* We keep a single flat list with all model data in one place.
|
||||
* If Claude SDK model discovery becomes consistent with runtime IDs, switch
|
||||
* listModels back to SDK discovery and remove this file.
|
||||
*/
|
||||
|
||||
export type ClaudeCatalogModel = {
|
||||
family: "sonnet" | "opus" | "haiku";
|
||||
modelId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isDefault?: boolean;
|
||||
isLatestInFamily?: boolean;
|
||||
};
|
||||
|
||||
export const CLAUDE_MODEL_CATALOG: readonly ClaudeCatalogModel[] = [
|
||||
{
|
||||
family: "opus",
|
||||
modelId: "claude-opus-4-6",
|
||||
name: "Opus 4.6",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
isLatestInFamily: true,
|
||||
},
|
||||
{
|
||||
family: "sonnet",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
name: "Sonnet 4.6",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
isLatestInFamily: true,
|
||||
},
|
||||
{
|
||||
family: "sonnet",
|
||||
modelId: "claude-sonnet-4-5-20250929",
|
||||
name: "Sonnet 4.5",
|
||||
description: "Sonnet 4.5 · Best for everyday tasks",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
family: "haiku",
|
||||
modelId: "claude-haiku-4-5-20251001",
|
||||
name: "Haiku 4.5",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
isLatestInFamily: true,
|
||||
},
|
||||
];
|
||||
|
||||
export type ClaudeModelFamily = ClaudeCatalogModel["family"];
|
||||
|
||||
function toClaudeModelDefinition(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
}): AgentModelDefinition {
|
||||
return {
|
||||
provider: "claude",
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
description: params.description,
|
||||
...(params.isDefault ? { isDefault: true } : {}),
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off", isDefault: true },
|
||||
{ id: "on", label: "On" },
|
||||
],
|
||||
defaultThinkingOptionId: "off",
|
||||
metadata: params.description
|
||||
? {
|
||||
description: params.description,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function listClaudeCatalogModels(): AgentModelDefinition[] {
|
||||
return CLAUDE_MODEL_CATALOG.map((model) =>
|
||||
toClaudeModelDefinition({
|
||||
id: model.modelId,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildClaudeSelectableModelIds(): Set<string> {
|
||||
return new Set(CLAUDE_MODEL_CATALOG.map((model) => model.modelId));
|
||||
}
|
||||
|
||||
export function buildClaudeModelFamilyAliases(): Map<ClaudeModelFamily, string> {
|
||||
const aliases = new Map<ClaudeModelFamily, string>();
|
||||
for (const model of CLAUDE_MODEL_CATALOG) {
|
||||
if (model.isLatestInFamily || !aliases.has(model.family)) {
|
||||
aliases.set(model.family, model.modelId);
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
@@ -13,33 +13,25 @@ describe("parsePartialJsonObject", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps partial string values without field-specific logic", () => {
|
||||
it("does not emit incomplete string values", () => {
|
||||
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
|
||||
value: {
|
||||
command: "echo ",
|
||||
},
|
||||
value: {},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns parsed prefix fields from incomplete objects", () => {
|
||||
it("returns only complete prefix fields from incomplete objects", () => {
|
||||
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
|
||||
value: {
|
||||
file_path: "src/message.tsx",
|
||||
old_string: "before",
|
||||
},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses nested partial values generically", () => {
|
||||
it("does not emit incomplete nested values", () => {
|
||||
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
|
||||
value: {
|
||||
payload: {
|
||||
path: "src/index.ts",
|
||||
content: "hello",
|
||||
},
|
||||
},
|
||||
value: {},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,17 +271,17 @@ function parsePartialObject(
|
||||
};
|
||||
}
|
||||
|
||||
value[parsedKey.value] = parsedMemberValue.value;
|
||||
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
|
||||
|
||||
if (!parsedMemberValue.complete) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex,
|
||||
nextIndex: skipWhitespace(input, parsedMemberValue.nextIndex),
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
value[parsedKey.value] = parsedMemberValue.value;
|
||||
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
|
||||
|
||||
const delimiter = input[currentIndex];
|
||||
if (delimiter === ",") {
|
||||
currentIndex += 1;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user