mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5631eb17ea | ||
|
|
49e09ee660 | ||
|
|
56b1def06e | ||
|
|
7512110e6f | ||
|
|
cb37d026ad | ||
|
|
50ec8955eb | ||
|
|
a3959dd99f | ||
|
|
27b6242128 | ||
|
|
51d1d007ce | ||
|
|
cfd72b815c | ||
|
|
b3f44981a8 | ||
|
|
e9f9759ba8 | ||
|
|
dcbbaa8ece | ||
|
|
edd5a99832 | ||
|
|
52a66cca70 | ||
|
|
e53d26699f | ||
|
|
c5bcec5c71 | ||
|
|
b83ee957d1 | ||
|
|
9f41904c6f | ||
|
|
eca0a5bf67 | ||
|
|
e72b0773e6 | ||
|
|
dcdb178468 | ||
|
|
bed8af7aa6 | ||
|
|
378f1986ac | ||
|
|
0967557846 | ||
|
|
6c3e2bd703 | ||
|
|
9ce6a38792 | ||
|
|
b832d49a78 | ||
|
|
02ec937399 | ||
|
|
0cbb8238c2 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -6,6 +6,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||
|
||||
Before opening, please:
|
||||
|
||||
- search existing issues for the same symptom
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/jz8T2uahpH
|
||||
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
|
||||
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.
|
||||
|
||||
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
@@ -20,6 +20,10 @@ env:
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# This job never executes Electron. Skipping the hosted binary avoids
|
||||
# unrelated npm ci failures when Electron's CDN returns 504.
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -36,6 +40,8 @@ jobs:
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -58,6 +64,8 @@ jobs:
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -88,6 +96,8 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: server-tests (${{ matrix.os }})
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -131,8 +141,35 @@ jobs:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install dependencies with Electron retry
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci; then
|
||||
exit 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install dependencies with Electron retry
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
npm ci
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
if ($attempt -eq 3) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
@@ -142,6 +179,8 @@ jobs:
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -164,6 +203,8 @@ jobs:
|
||||
|
||||
sdk-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -189,6 +230,8 @@ jobs:
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -229,6 +272,8 @@ jobs:
|
||||
|
||||
relay-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -253,6 +298,8 @@ jobs:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.93 - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Fable 5 is available in the Claude model picker** ([#1443](https://github.com/getpaseo/paseo/pull/1443) by [@0-Captain](https://github.com/0-Captain))
|
||||
|
||||
## 0.1.92 - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Skills autocomplete inside prompts**
|
||||
|
||||
### Improved
|
||||
|
||||
- Provider catalog is inline in Host settings ([#1423](https://github.com/getpaseo/paseo/pull/1423))
|
||||
- Manual update checks skip staged rollout delays
|
||||
- CodeWhale replaces DeepSeek TUI in the provider catalog
|
||||
- ACP provider catalog entries are updated for Cline, Codebuddy Code, DimCode, Factory Droid, Gemini, Nova, and Qoder
|
||||
- OMP has its own icon and website page
|
||||
- Model selector descriptions are clearer
|
||||
- ACP provider errors show the provider's real failure message
|
||||
|
||||
### Fixed
|
||||
|
||||
- New Paseo worktree branches can push their first commits
|
||||
- Imported sessions no longer open blank or in the wrong workspace
|
||||
- Windows Explorer opens the selected workspace instead of Documents ([#1412](https://github.com/getpaseo/paseo/pull/1412) by [@bjspi](https://github.com/bjspi))
|
||||
- Windows editor shortcuts installed as command shims launch correctly ([#1387](https://github.com/getpaseo/paseo/pull/1387) by [@Peter7896](https://github.com/Peter7896))
|
||||
- ACP providers that cannot use MCP servers can start correctly
|
||||
- Removed hosts no longer leave host pages stuck connecting
|
||||
- File preview links open in your external browser
|
||||
- Chat stays pinned to the latest message while output streams
|
||||
- The mobile composer send button no longer shifts while typing
|
||||
|
||||
## 0.1.91 - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
|
||||
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||
|
||||
---
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
@@ -409,7 +409,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
@@ -438,6 +438,25 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "acp"],
|
||||
"params": {
|
||||
"supportsMcpServers": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
@@ -574,18 +593,19 @@ When an `additionalModels` entry has the same `id` as a discovered model, it upd
|
||||
|
||||
Every entry under `agents.providers` accepts these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | ------------------------- | ----------------- | ------------------------------------------------------------------ |
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
|
||||
### Model definition
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ Anyone who builds software:
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
|
||||
@@ -36,6 +36,8 @@ Every provider adapter owns its canonical user-message timeline rows. When a for
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
@@ -315,7 +317,13 @@ interface AgentClient {
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
listImportableSessions?(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
importSession?(
|
||||
input: ImportProviderSessionInput,
|
||||
context: ImportProviderSessionContext,
|
||||
): Promise<ImportedProviderSession>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@ Use the beta path when you need to:
|
||||
|
||||
## Staged rollout (stable channel)
|
||||
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click **Check**. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
|
||||
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
||||
|
||||
@@ -173,7 +173,7 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
|
||||
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
||||
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
|
||||
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
|
||||
|
||||
## Mobile builds (EAS)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
|
||||
sha256-YZNXy7OyeGAmQqbNObKeuKOpM+8ova8djd5BDBUH2+A=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -36953,7 +36953,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -37178,12 +37178,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/server": "0.1.91",
|
||||
"@getpaseo/client": "0.1.93",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/server": "0.1.93",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37429,10 +37429,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/relay": "0.1.93",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37452,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37704,7 +37704,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37740,7 +37740,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37971,7 +37971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +37992,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38210,14 +38210,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/highlight": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/client": "0.1.93",
|
||||
"@getpaseo/highlight": "0.1.93",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/relay": "0.1.93",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38989,7 +38989,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "~> 2.234"
|
||||
gem "multi_json"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderModal,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
@@ -21,7 +21,7 @@ test.describe("ACP provider catalog", () => {
|
||||
await openSettingsHost(page, getServerId());
|
||||
// Providers moved to their own host section; add-provider lives there now.
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderModal(page);
|
||||
await openAddProviderArea(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
|
||||
@@ -43,6 +43,18 @@ export interface SeedDaemonClient {
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
}>;
|
||||
fetchRecentProviderSessions(options: {
|
||||
cwd: string;
|
||||
providers: string[];
|
||||
limit: number;
|
||||
}): Promise<{
|
||||
entries: Array<{
|
||||
providerId: string;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
firstPromptPreview?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
|
||||
@@ -180,6 +180,18 @@ export async function goBackInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function closeCompactSettings(page: Page): Promise<void> {
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
}
|
||||
|
||||
export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-remove-host-button").click();
|
||||
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
|
||||
await page.getByTestId("remove-host-confirm").click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
}
|
||||
|
||||
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
}
|
||||
@@ -319,8 +331,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProviderModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add provider", exact: true }).click();
|
||||
export async function openAddProviderArea(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -332,7 +344,6 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectProviderInstalledInSettings(
|
||||
|
||||
230
packages/app/e2e/import-session.opencode.real.spec.ts
Normal file
230
packages/app/e2e/import-session.opencode.real.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { connectSeedClient, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
const OPENCODE_REAL_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
const OPENCODE_SEED_TIMEOUT_MS = 45_000;
|
||||
const PASEO_REPO_PATH = path.resolve(__dirname, "../../..");
|
||||
|
||||
interface OpenCodeSeedResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
interface ImportableOpenCodeSession {
|
||||
providerHandleId: string;
|
||||
}
|
||||
|
||||
interface OpenCodeImportScenario {
|
||||
workspace: SeededWorkspace;
|
||||
prompt: string;
|
||||
promptPreview: string;
|
||||
response: string;
|
||||
}
|
||||
|
||||
let workspace: SeededWorkspace | null = null;
|
||||
|
||||
test.setTimeout(150_000);
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup().catch(() => undefined);
|
||||
workspace = null;
|
||||
});
|
||||
|
||||
test("imports a real OpenCode session from the workspace import sheet", async ({ page }) => {
|
||||
const scenario = await seedPaseoWorkspaceWithOpenCodeSession();
|
||||
workspace = scenario.workspace;
|
||||
const importableSession = await waitForImportableOpenCodeSession(scenario);
|
||||
await openWorkspace(page, scenario.workspace);
|
||||
|
||||
await importOpenCodeSession(page, importableSession);
|
||||
|
||||
await expectImportSheetClosed(page);
|
||||
await expectImportedSessionOpen(page, scenario);
|
||||
});
|
||||
|
||||
async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportScenario> {
|
||||
const response = `PASEO_OPENCODE_IMPORT_E2E_OK_${randomUUID().slice(0, 8)}`;
|
||||
const prompt = `Do not use tools. Reply with exactly: ${response}`;
|
||||
const promptPreview = JSON.stringify(prompt);
|
||||
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(PASEO_REPO_PATH);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
promptPreview,
|
||||
response,
|
||||
workspace: {
|
||||
client,
|
||||
repoPath: PASEO_REPO_PATH,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function launchOpenCodeSessionInWorkspace(repoPath: string, prompt: string): Promise<void> {
|
||||
const result = await runOpenCodeSeed(repoPath, prompt);
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(formatOpenCodeLaunchError(result, prompt));
|
||||
}
|
||||
}
|
||||
|
||||
function openCodeSeedArgs(repoPath: string, prompt: string): string[] {
|
||||
return [
|
||||
"run",
|
||||
"--print-logs",
|
||||
"--log-level",
|
||||
"INFO",
|
||||
"--dir",
|
||||
repoPath,
|
||||
"--model",
|
||||
OPENCODE_REAL_MODEL,
|
||||
"--format",
|
||||
"json",
|
||||
prompt,
|
||||
];
|
||||
}
|
||||
|
||||
function runOpenCodeSeed(repoPath: string, prompt: string): Promise<OpenCodeSeedResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("opencode", openCodeSeedArgs(repoPath, prompt), {
|
||||
cwd: repoPath,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, OPENCODE_SEED_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ stdout, stderr, code, signal, timedOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatOpenCodeLaunchError(result: OpenCodeSeedResult, prompt: string): string {
|
||||
return [
|
||||
"OpenCode launch failed",
|
||||
`command: ${["opencode", ...openCodeSeedArgs(PASEO_REPO_PATH, prompt)].join(" ")}`,
|
||||
`exit: ${result.code ?? "null"}`,
|
||||
result.signal ? `signal: ${result.signal}` : null,
|
||||
result.timedOut ? `timed out after ${OPENCODE_SEED_TIMEOUT_MS}ms` : null,
|
||||
result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : null,
|
||||
result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : null,
|
||||
]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, seed: SeededWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), seed.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
async function waitForImportableOpenCodeSession(
|
||||
scenario: OpenCodeImportScenario,
|
||||
): Promise<ImportableOpenCodeSession> {
|
||||
let importableSession: ImportableOpenCodeSession | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
importableSession = await findImportableOpenCodeSession(scenario);
|
||||
return importableSession?.providerHandleId ?? "";
|
||||
},
|
||||
{
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1_000],
|
||||
},
|
||||
)
|
||||
.not.toBe("");
|
||||
return importableSession!;
|
||||
}
|
||||
|
||||
async function findImportableOpenCodeSession(
|
||||
scenario: OpenCodeImportScenario,
|
||||
): Promise<ImportableOpenCodeSession | null> {
|
||||
const sessions = await scenario.workspace.client.fetchRecentProviderSessions({
|
||||
cwd: scenario.workspace.repoPath,
|
||||
providers: ["opencode"],
|
||||
limit: 5,
|
||||
});
|
||||
const entry = sessions.entries.find(
|
||||
(session) =>
|
||||
session.providerId === "opencode" && session.firstPromptPreview === scenario.promptPreview,
|
||||
);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return { providerHandleId: entry.providerHandleId };
|
||||
}
|
||||
|
||||
async function importOpenCodeSession(
|
||||
page: Page,
|
||||
session: ImportableOpenCodeSession,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Workspace actions" }).click();
|
||||
await page.getByTestId("workspace-header-import-agent").click();
|
||||
await expect(page.getByTestId("import-session-sheet")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const importSheet = page.getByTestId("import-session-sheet");
|
||||
const sessionRow = importSheet.getByTestId(
|
||||
`import-session-session-opencode-${session.providerHandleId}`,
|
||||
);
|
||||
await expect(sessionRow).toBeVisible({ timeout: 60_000 });
|
||||
await sessionRow.click();
|
||||
}
|
||||
|
||||
async function expectImportSheetClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("import-session-sheet")).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectImportedSessionOpen(
|
||||
page: Page,
|
||||
scenario: OpenCodeImportScenario,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid="user-message"]', { hasText: scenario.promptPreview }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.locator('[data-testid="assistant-message"]', { hasText: scenario.response }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
@@ -31,9 +33,18 @@ import {
|
||||
selectSettingsHost,
|
||||
expectSettingsHostPickerLabel,
|
||||
openSettingsHostSection,
|
||||
removeCurrentHostFromSettings,
|
||||
} from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
workspace: { workspaceId: string },
|
||||
) {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
@@ -187,4 +198,20 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
});
|
||||
|
||||
test("removing the last active host returns to welcome after settings closes", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
|
||||
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "host");
|
||||
await removeCurrentHostFromSettings(page);
|
||||
await closeCompactSettings(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/welcome$/);
|
||||
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -22,7 +22,7 @@
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:real": "playwright test --project=real-provider",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -560,6 +560,95 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
|
||||
it("keeps initial native content growth anchored before layout scroll events arrive", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "native-virtualized",
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
contentHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
harness.context.measurementState.contentHeight = 1348;
|
||||
harness.context.measurementState.contentMeasuredForKey = "native-virtualized";
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 0,
|
||||
contentHeight: 1348,
|
||||
});
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
requestId: null,
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.measurementState.viewportWidth = 390;
|
||||
harness.context.measurementState.viewportHeight = 546;
|
||||
harness.context.measurementState.viewportMeasuredForKey = "native-virtualized";
|
||||
harness.context.measurementState.offsetY = 50;
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 50,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
|
||||
it("keeps native sticky content changes anchored when measured height is unchanged", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "native-virtualized",
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 546,
|
||||
contentHeight: 546,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: "native-virtualized",
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
harness.context.nearBottom = true;
|
||||
});
|
||||
|
||||
harness.driver.prepareForStickyContentChange();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
requestId: null,
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.measurementState.offsetY = 50;
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 50,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller helper predicates", () => {
|
||||
|
||||
@@ -244,29 +244,6 @@ function createBottomAnchorControllerDriver(
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
|
||||
const _getLogContext = (extra?: Record<string, unknown>) => {
|
||||
const measurementState = input.getMeasurementState();
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight),
|
||||
);
|
||||
return {
|
||||
agentId: input.getAgentId(),
|
||||
requestReason: pendingRequest?.reason ?? null,
|
||||
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
contentHeight: measurementState.contentHeight,
|
||||
viewportHeight: measurementState.viewportHeight,
|
||||
offset: measurementState.offsetY,
|
||||
distanceFromBottom,
|
||||
renderStrategy: input.getRenderStrategy(),
|
||||
blockedReason,
|
||||
mode,
|
||||
containerKey: measurementState.containerKey,
|
||||
transportBehavior: input.getTransportBehavior(),
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
return;
|
||||
@@ -327,7 +304,6 @@ function createBottomAnchorControllerDriver(
|
||||
});
|
||||
|
||||
const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => {
|
||||
const _scheduledMeasurementState = input.getMeasurementState();
|
||||
if (verificationHandle) {
|
||||
input.cancelFrame(verificationHandle);
|
||||
}
|
||||
@@ -560,6 +536,12 @@ function createBottomAnchorControllerDriver(
|
||||
});
|
||||
if (shouldRestick && !pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
input.cancelFrame(attemptHandle);
|
||||
attemptHandle = null;
|
||||
}
|
||||
runAttempt(false);
|
||||
return;
|
||||
}
|
||||
if (shouldRestick || pendingRequest) {
|
||||
evaluate(false, "content_size_change");
|
||||
@@ -576,6 +558,16 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
if (!pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
input.cancelFrame(attemptHandle);
|
||||
attemptHandle = null;
|
||||
}
|
||||
runAttempt(false);
|
||||
return;
|
||||
}
|
||||
evaluate(false, "content_size_change");
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
@@ -656,11 +648,7 @@ export const __private__ = {
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
input.previousContentHeight > 0 &&
|
||||
input.contentHeight > input.previousContentHeight
|
||||
);
|
||||
return input.mode === "sticky-bottom" && input.contentHeight > input.previousContentHeight;
|
||||
},
|
||||
shouldDetachFromScrollAway(input: {
|
||||
mode: BottomAnchorMode;
|
||||
|
||||
@@ -84,7 +84,6 @@ import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
mapPathnameToServer,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
@@ -811,7 +810,6 @@ function OpenProjectListener() {
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
@@ -820,16 +818,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const shouldShowAppChrome =
|
||||
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId || hosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
|
||||
25
packages/app/src/app/h/[serverId]/_layout.tsx
Normal file
25
packages/app/src/app/h/[serverId]/_layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { resolveKnownHostRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<KnownHostRoute />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function KnownHostRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
||||
const resolution = resolveKnownHostRoute({ routeServerId, hosts });
|
||||
|
||||
if (resolution.kind === "redirect") {
|
||||
return <Redirect href={resolution.href} />;
|
||||
}
|
||||
|
||||
return <Slot />;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
|
||||
deepagents:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
|
||||
"deepseek-tui":
|
||||
codewhale:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
|
||||
dimcode:
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
|
||||
|
||||
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 397 B |
@@ -63,8 +63,6 @@ import {
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
||||
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||
@@ -198,12 +196,10 @@ function ModelRow({
|
||||
],
|
||||
);
|
||||
|
||||
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={row.modelLabel}
|
||||
description={showDescription ? row.description : undefined}
|
||||
description={row.description}
|
||||
selected={isSelected}
|
||||
elevated={elevated}
|
||||
onPress={onPress}
|
||||
|
||||
@@ -9,7 +9,6 @@ import Markdown, {
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
Linking,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
type TextProps,
|
||||
@@ -25,6 +24,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -208,7 +208,7 @@ function FilePreviewMarkdownLink({
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void Linking.openURL(href);
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
|
||||
14
packages/app/src/components/icons/omp-icon.tsx
Normal file
14
packages/app/src/components/icons/omp-icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OmpIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
|
||||
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" fill={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,9 @@ vi.mock("lucide-react-native", () => {
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
ChevronDown: icon("ChevronDown"),
|
||||
Inbox: icon("Inbox"),
|
||||
Layers: icon("Layers"),
|
||||
RotateCw: icon("RotateCw"),
|
||||
};
|
||||
});
|
||||
@@ -81,35 +83,38 @@ vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/segmented-control", () => ({
|
||||
SegmentedControl: ({
|
||||
vi.mock("@/components/ui/combobox", () => ({
|
||||
Combobox: ({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
testID,
|
||||
onSelect,
|
||||
open,
|
||||
}: {
|
||||
options: ReadonlyArray<{ value: string; label: string; testID?: string }>;
|
||||
options: ReadonlyArray<{ id: string; label: string }>;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
onSelect: (id: string) => void;
|
||||
open?: boolean;
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ "data-testid": testID },
|
||||
{ "data-testid": "import-session-combobox" },
|
||||
options.map((option) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
key: option.value,
|
||||
key: option.id,
|
||||
type: "button",
|
||||
"data-testid": option.testID,
|
||||
"data-selected": value === option.value,
|
||||
onClick: () => onValueChange(option.value),
|
||||
"data-testid": `import-session-filter-${option.id === "__all__" ? "all" : option.id}`,
|
||||
"data-selected": value === option.id,
|
||||
onClick: () => onSelect(option.id),
|
||||
},
|
||||
option.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
@@ -475,7 +480,13 @@ describe("ImportSessionSheet", () => {
|
||||
it("imports a selected session by provider handle and reports the imported agent", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||
const onClose = vi.fn();
|
||||
@@ -499,7 +510,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/repo/paseo",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
});
|
||||
});
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
@@ -678,11 +689,13 @@ describe("ImportSessionSheet", () => {
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
|
||||
|
||||
screen.getByText("Session codex");
|
||||
expect(screen.queryByText("Session claude")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-all"));
|
||||
|
||||
screen.getByText("Session claude");
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchRecentProviderSessionEntry,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { Inbox, RotateCw } from "lucide-react-native";
|
||||
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -88,6 +88,7 @@ interface SheetStatusMessagesProps {
|
||||
isSnapshotUnsupported: boolean;
|
||||
hasNoImportableProviders: boolean;
|
||||
isLoadingSessions: boolean;
|
||||
hasRows: boolean;
|
||||
allQueriesErrored: boolean;
|
||||
erroredProviderLabels: ReadonlyArray<string>;
|
||||
importErrored: boolean;
|
||||
@@ -98,6 +99,7 @@ function SheetStatusMessages({
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
hasRows,
|
||||
allQueriesErrored,
|
||||
erroredProviderLabels,
|
||||
importErrored,
|
||||
@@ -114,7 +116,7 @@ function SheetStatusMessages({
|
||||
{hasNoImportableProviders ? (
|
||||
<Text style={styles.statusText}>No importable providers are enabled.</Text>
|
||||
) : null}
|
||||
{isLoadingSessions ? (
|
||||
{isLoadingSessions && !hasRows ? (
|
||||
<View style={styles.statusRow}>
|
||||
<LoadingSpinner color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.statusText}>Loading recent sessions...</Text>
|
||||
@@ -176,25 +178,6 @@ function SheetEmptyState({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildProviderFilterOptions(
|
||||
providers: ReadonlyArray<string>,
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): SegmentedControlOption<string>[] {
|
||||
const options: SegmentedControlOption<string>[] = [
|
||||
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
|
||||
];
|
||||
for (const provider of providers) {
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
options.push({
|
||||
value: provider,
|
||||
label: providerLabelById.get(provider) ?? provider,
|
||||
testID: `import-session-filter-${provider}`,
|
||||
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function ImportSessionSheetRow({
|
||||
entry,
|
||||
disabled,
|
||||
@@ -271,6 +254,7 @@ export function ImportSessionSheet({
|
||||
onImported,
|
||||
}: ImportSessionSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||
cwd,
|
||||
@@ -315,6 +299,8 @@ export function ImportSessionSheet({
|
||||
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -330,24 +316,84 @@ export function ImportSessionSheet({
|
||||
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
|
||||
}, [aggregatedEntries, selectedProvider]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() => buildProviderFilterOptions(filterProviders, providerLabelById),
|
||||
const filterComboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => [
|
||||
{ id: ALL_FILTER_VALUE, label: "All providers" },
|
||||
...filterProviders.map((provider) => ({
|
||||
id: provider,
|
||||
label: providerLabelById.get(provider) ?? provider,
|
||||
})),
|
||||
],
|
||||
[filterProviders, providerLabelById],
|
||||
);
|
||||
|
||||
const selectedProviderLabel = useMemo(
|
||||
() =>
|
||||
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
|
||||
[filterComboboxOptions, selectedProvider],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFilterSelect = useCallback((id: string) => {
|
||||
setSelectedProvider(id);
|
||||
setIsFilterOpen(false);
|
||||
}, []);
|
||||
|
||||
const filterOptionIcons = useMemo(() => {
|
||||
const map = new Map<string, React.ReactNode>();
|
||||
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
|
||||
for (const provider of filterProviders) {
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
|
||||
}
|
||||
return map;
|
||||
}, [filterProviders, theme.colors.foregroundMuted]);
|
||||
|
||||
const renderFilterOption = useCallback(
|
||||
({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={filterOptionIcons.get(option.id)}
|
||||
/>
|
||||
),
|
||||
[filterOptionIcons],
|
||||
);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const effectiveCwd = cwd ?? entry.cwd;
|
||||
if (!effectiveCwd) {
|
||||
if (!entry.cwd) {
|
||||
throw new Error("Session is missing a working directory");
|
||||
}
|
||||
const agent = await client.importAgent({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: effectiveCwd,
|
||||
cwd: entry.cwd,
|
||||
});
|
||||
return agent;
|
||||
},
|
||||
@@ -423,25 +469,48 @@ export function ImportSessionSheet({
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{showFilter ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterRow}
|
||||
>
|
||||
<SegmentedControl
|
||||
testID="import-session-filters"
|
||||
size="sm"
|
||||
options={filterOptions}
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="import-session-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
||||
>
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
(() => {
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
|
||||
})()
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={filterComboboxOptions}
|
||||
value={selectedProvider}
|
||||
onValueChange={setSelectedProvider}
|
||||
onSelect={handleFilterSelect}
|
||||
renderOption={renderFilterOption}
|
||||
searchable={false}
|
||||
title="Filter by provider"
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
allQueriesErrored={allQueriesErrored}
|
||||
erroredProviderLabels={erroredProviderLabels}
|
||||
importErrored={importMutation.isError}
|
||||
@@ -466,10 +535,32 @@ export function ImportSessionSheet({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
filterRow: {
|
||||
flexDirection: "row",
|
||||
filterTriggerWrap: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
list: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
buildAcpProviderConfigPatch,
|
||||
useAcpProviderCatalog,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
interface AddProviderModalProps {
|
||||
interface ProviderCatalogListProps {
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
installingProviderId: string | null;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
|
||||
}
|
||||
|
||||
type InstallState = "installed" | "available";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ACTION_BUTTON_STYLE = { width: 92 } as const;
|
||||
const MODAL_SNAP_POINTS = ["78%", "92%"];
|
||||
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
|
||||
const SEARCH_ICON_SIZE = 16;
|
||||
const PROVIDER_FALLBACK_ICON_SIZE = 20;
|
||||
const PROVIDER_REMOTE_ICON_SIZE = 24;
|
||||
@@ -45,14 +33,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function getInstallState(
|
||||
entry: AcpProviderCatalogItem,
|
||||
installedProviderIds: Set<string>,
|
||||
): InstallState {
|
||||
if (installedProviderIds.has(entry.id)) return "installed";
|
||||
return "available";
|
||||
}
|
||||
|
||||
function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
@@ -61,22 +41,13 @@ function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderCatalogRowProps {
|
||||
interface CatalogRowProps {
|
||||
entry: AcpProviderCatalogItem;
|
||||
state: InstallState;
|
||||
installing: boolean;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => void;
|
||||
}
|
||||
|
||||
function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCatalogRowProps) {
|
||||
const isAvailable = state === "available";
|
||||
let actionLabel = "Add";
|
||||
if (installing) {
|
||||
actionLabel = "Adding";
|
||||
} else if (state === "installed") {
|
||||
actionLabel = "Installed";
|
||||
}
|
||||
|
||||
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
@@ -125,72 +96,43 @@ function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCat
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isAvailable ? "default" : "secondary"}
|
||||
disabled={!isAvailable || installing}
|
||||
variant="default"
|
||||
disabled={installing}
|
||||
loading={installing}
|
||||
onPress={handleInstall}
|
||||
style={ACTION_BUTTON_STYLE}
|
||||
style={styles.actionButton}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{actionLabel}
|
||||
{installing ? "Adding" : "Add"}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddProviderModal({ serverId, visible, onClose }: AddProviderModalProps) {
|
||||
const { entries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
export function ProviderCatalogList({
|
||||
serverId,
|
||||
installingProviderId,
|
||||
onInstall,
|
||||
}: ProviderCatalogListProps) {
|
||||
const { entries: catalogEntries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries } = useProvidersSnapshot(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearch("");
|
||||
bumpSearchResetKey();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const installedProviderIds = useMemo(
|
||||
const installedIds = useMemo(
|
||||
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
|
||||
[providerEntries],
|
||||
);
|
||||
const filteredEntries = useMemo(
|
||||
() => entries.filter((entry) => matchesSearch(entry, search)),
|
||||
[entries, search],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (entry: AcpProviderCatalogItem) => {
|
||||
if (installingProviderId) return;
|
||||
|
||||
setInstallingProviderId(entry.id);
|
||||
try {
|
||||
await patchConfig(buildAcpProviderConfigPatch(entry));
|
||||
await refresh([entry.id]);
|
||||
handleClose();
|
||||
} catch (installError) {
|
||||
Alert.alert(
|
||||
"Unable to install provider",
|
||||
installError instanceof Error ? installError.message : String(installError),
|
||||
);
|
||||
} finally {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, handleClose, patchConfig, refresh],
|
||||
const availableEntries = useMemo(
|
||||
() =>
|
||||
catalogEntries
|
||||
.filter((entry) => !installedIds.has(entry.id))
|
||||
.filter((entry) => matchesSearch(entry, search)),
|
||||
[catalogEntries, installedIds, search],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={ADD_PROVIDER_HEADER}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
desktopMaxWidth={680}
|
||||
snapPoints={MODAL_SNAP_POINTS}
|
||||
testID="add-provider-modal"
|
||||
>
|
||||
<View>
|
||||
<View style={styles.searchField}>
|
||||
<View style={styles.searchIcon}>
|
||||
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
|
||||
@@ -198,8 +140,6 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
<AdaptiveTextInput
|
||||
testID="provider-catalog-search"
|
||||
accessibilityLabel="Search providers"
|
||||
initialValue={search}
|
||||
resetKey={`provider-catalog-search-${searchResetKey}`}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search providers"
|
||||
@@ -209,32 +149,25 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
/>
|
||||
</View>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
{availableEntries.length === 0 ? (
|
||||
<View style={styles.stateBox}>
|
||||
<Text style={styles.stateText}>No providers found</Text>
|
||||
<Text style={styles.stateText}>
|
||||
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{filteredEntries.length > 0 ? (
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
{filteredEntries.map((entry) => (
|
||||
<ProviderCatalogRow
|
||||
{availableEntries.map((entry) => (
|
||||
<CatalogRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
state={getInstallState(entry, installedProviderIds)}
|
||||
installing={installingProviderId === entry.id}
|
||||
onInstall={handleInstall}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,6 +181,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
searchIcon: {
|
||||
width: 18,
|
||||
@@ -320,6 +254,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
actionButton: {
|
||||
width: 92,
|
||||
flexShrink: 0,
|
||||
},
|
||||
stateBox: {
|
||||
minHeight: 96,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -335,7 +273,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
}));
|
||||
@@ -5,6 +5,7 @@ import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import {
|
||||
@@ -24,7 +25,7 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
omp: PiIcon as unknown as ProviderIconComponent,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, test } from "vitest";
|
||||
import { validateDraftSubmission } from "./workspace-tab-core";
|
||||
|
||||
const baseComposerState = {
|
||||
providerDefinitions: [{ id: "deepseek-tui" }],
|
||||
selectedProvider: "deepseek-tui",
|
||||
providerDefinitions: [{ id: "codewhale" }],
|
||||
selectedProvider: "codewhale",
|
||||
isModelLoading: false,
|
||||
effectiveModelId: "",
|
||||
availableModels: [],
|
||||
|
||||
@@ -170,10 +170,12 @@ function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean
|
||||
function buildRealtimeVoiceButtonStyle(
|
||||
hovered: boolean | undefined,
|
||||
voiceButtonDisabled: boolean,
|
||||
reserveLeadingSpace: boolean,
|
||||
): object[] {
|
||||
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
|
||||
return [styles.realtimeVoiceButton, hoveredStyle, disabledStyle].filter(
|
||||
const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
|
||||
return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
|
||||
(value): value is object => Boolean(value),
|
||||
);
|
||||
}
|
||||
@@ -1415,8 +1417,8 @@ export function Composer({
|
||||
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
|
||||
const realtimeVoiceButtonStyle = useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled),
|
||||
[voiceButtonDisabled],
|
||||
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
|
||||
[isCompactLayout, voiceButtonDisabled],
|
||||
);
|
||||
|
||||
const cancelButton = useMemo(
|
||||
@@ -1851,6 +1853,9 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButtonCompactReserve: {
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
realtimeVoiceButtonActive: {
|
||||
backgroundColor: theme.colors.palette.green[600],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AcpProviderCatalogEntry {
|
||||
installLink: string;
|
||||
command: readonly [string, ...string[]];
|
||||
env?: Readonly<Record<string, string>>;
|
||||
params?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const CATALOG_DATA = [
|
||||
@@ -53,42 +54,33 @@ const CATALOG_DATA = [
|
||||
installLink: "https://www.autohand.ai/cli/",
|
||||
command: ["npx", "-y", "@autohandai/autohand-acp@0.2.1"],
|
||||
},
|
||||
{
|
||||
id: "claude-acp",
|
||||
title: "Claude Agent",
|
||||
description: "ACP wrapper for Anthropic's Claude",
|
||||
version: "0.42.0",
|
||||
iconId: "claude-acp",
|
||||
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
|
||||
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.42.0"],
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.20",
|
||||
version: "3.0.23",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.20", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.23", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.103.4",
|
||||
version: "2.105.0",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.4", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.105.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codex-acp",
|
||||
title: "Codex CLI",
|
||||
description: "ACP adapter for OpenAI's coding assistant",
|
||||
version: "0.13.0",
|
||||
iconId: "codex-acp",
|
||||
installLink: "https://github.com/zed-industries/codex-acp",
|
||||
command: ["codex-acp"],
|
||||
id: "codewhale",
|
||||
title: "CodeWhale",
|
||||
description: "Terminal coding agent for DeepSeek V4 and open models",
|
||||
version: "0.8.55",
|
||||
iconId: "codewhale",
|
||||
installLink: "https://codewhale.net/",
|
||||
command: ["codewhale", "serve", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "cortex-code",
|
||||
@@ -135,15 +127,6 @@ const CATALOG_DATA = [
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.12"],
|
||||
},
|
||||
{
|
||||
id: "deepseek-tui",
|
||||
title: "DeepSeek TUI",
|
||||
description: "Terminal coding agent for DeepSeek V4",
|
||||
version: "0.8.39",
|
||||
iconId: "deepseek-tui",
|
||||
installLink: "https://github.com/Hmbown/DeepSeek-TUI",
|
||||
command: ["deepseek", "serve", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
title: "Devin CLI",
|
||||
@@ -157,10 +140,10 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.1.0",
|
||||
version: "0.1.5",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.1.0", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.1.5", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
@@ -176,14 +159,15 @@ const CATALOG_DATA = [
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.142.0",
|
||||
version: "0.144.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.142.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.144.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
},
|
||||
params: { supportsMcpServers: false },
|
||||
},
|
||||
{
|
||||
id: "fast-agent",
|
||||
@@ -198,19 +182,10 @@ const CATALOG_DATA = [
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
version: "0.45.2",
|
||||
version: "0.46.0",
|
||||
iconId: "gemini",
|
||||
installLink: "https://geminicli.com",
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.45.2", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "github-copilot-cli",
|
||||
title: "GitHub Copilot",
|
||||
description: "GitHub's AI pair programmer",
|
||||
version: "1.0.60",
|
||||
iconId: "github-copilot-cli",
|
||||
installLink: "https://github.com/features/copilot/cli/",
|
||||
command: ["npx", "-y", "@github/copilot@1.0.60", "--acp"],
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.46.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "glm-acp-agent",
|
||||
@@ -309,28 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.15",
|
||||
version: "1.1.16",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.15", "acp"],
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
title: "OpenCode",
|
||||
description: "The open source coding agent",
|
||||
version: "1.14.39",
|
||||
iconId: "opencode",
|
||||
installLink: "https://opencode.ai/docs/acp/",
|
||||
command: ["opencode", "acp"],
|
||||
},
|
||||
{
|
||||
id: "pi-acp",
|
||||
title: "pi ACP",
|
||||
description: "ACP adapter for pi coding agent",
|
||||
version: "0.0.27",
|
||||
iconId: "pi-acp",
|
||||
installLink: "https://github.com/svkozak/pi-acp",
|
||||
command: ["npx", "-y", "pi-acp@0.0.27"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.16", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -345,10 +302,10 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.0.14",
|
||||
version: "1.0.16",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.14", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.16", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
@@ -402,5 +359,6 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map(
|
||||
installLink: entry.installLink,
|
||||
command: entry.command,
|
||||
env: "env" in entry ? entry.env : undefined,
|
||||
params: "params" in entry ? entry.params : undefined,
|
||||
iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null,
|
||||
}));
|
||||
|
||||
@@ -38,13 +38,22 @@ function createUpdater(
|
||||
}
|
||||
|
||||
describe("desktop app updater — check", () => {
|
||||
it("forwards the requested release channel to the port", async () => {
|
||||
it("forwards manual check intent and the requested release channel to the port", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult());
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "beta" });
|
||||
|
||||
expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]);
|
||||
expect(port.recordedChecks).toEqual([{ releaseChannel: "beta", intent: "manual" }]);
|
||||
});
|
||||
|
||||
it("forwards automatic check intent independently from silent UI state", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult());
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
|
||||
|
||||
expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "automatic" }]);
|
||||
});
|
||||
|
||||
it("moves to 'checking' during a non-silent check", async () => {
|
||||
@@ -66,7 +75,11 @@ describe("desktop app updater — check", () => {
|
||||
expect(updater.getSnapshot().status).toBe("available");
|
||||
|
||||
const deferred = port.deferNextCheck();
|
||||
const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true });
|
||||
const pending = updater.checkForUpdates({
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
silent: true,
|
||||
});
|
||||
expect(updater.getSnapshot().status).toBe("available");
|
||||
|
||||
deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
||||
@@ -128,7 +141,7 @@ describe("desktop app updater — check", () => {
|
||||
const statusBeforeSilent = updater.getSnapshot().status;
|
||||
|
||||
port.failNextCheck(new Error("boom"));
|
||||
await updater.checkForUpdates({ releaseChannel: "stable", silent: true });
|
||||
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
|
||||
|
||||
expect(updater.getSnapshot().status).toBe(statusBeforeSilent);
|
||||
});
|
||||
@@ -226,6 +239,20 @@ describe("desktop app updater — subscribe", () => {
|
||||
describe("formatStatusText", () => {
|
||||
const formatVersion = (version: string | null | undefined) =>
|
||||
version ? `v${version.replace(/^v/i, "")}` : "\u2014";
|
||||
const formatLastCheckedAt = (timestamp: number) => `time-${timestamp}`;
|
||||
|
||||
it("shows when an up-to-date check completed", () => {
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "up-to-date",
|
||||
availableUpdate: null,
|
||||
installMessage: null,
|
||||
lastCheckedAt: 42,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("Up to date. Last checked at time-42.");
|
||||
});
|
||||
|
||||
it("uses the latest version in the 'available' message when present", () => {
|
||||
expect(
|
||||
@@ -233,7 +260,9 @@ describe("formatStatusText", () => {
|
||||
status: "available",
|
||||
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
|
||||
installMessage: null,
|
||||
lastCheckedAt: null,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("Update ready: v1.2.3");
|
||||
});
|
||||
@@ -244,7 +273,9 @@ describe("formatStatusText", () => {
|
||||
status: "available",
|
||||
availableUpdate: null,
|
||||
installMessage: null,
|
||||
lastCheckedAt: null,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("An app update is ready to install.");
|
||||
});
|
||||
@@ -255,7 +286,9 @@ describe("formatStatusText", () => {
|
||||
status: "installed",
|
||||
availableUpdate: null,
|
||||
installMessage: "Restart now",
|
||||
lastCheckedAt: null,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("Restart now");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DesktopAppUpdateCheckResult,
|
||||
DesktopAppUpdateCheckIntent,
|
||||
DesktopAppUpdateInstallResult,
|
||||
DesktopReleaseChannel,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
@@ -29,6 +30,7 @@ export interface DesktopAppUpdaterSnapshot {
|
||||
export interface DesktopAppUpdaterPort {
|
||||
checkDesktopAppUpdate(input: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
intent: DesktopAppUpdateCheckIntent;
|
||||
}): Promise<DesktopAppUpdateCheckResult>;
|
||||
installDesktopAppUpdate(input: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
@@ -52,6 +54,7 @@ export interface DesktopAppUpdater {
|
||||
subscribe(listener: () => void): () => void;
|
||||
checkForUpdates(options?: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
intent?: DesktopAppUpdateCheckIntent;
|
||||
silent?: boolean;
|
||||
}): Promise<DesktopAppUpdateCheckResult | null>;
|
||||
installUpdate(options: {
|
||||
@@ -102,9 +105,18 @@ export function formatStatusText(input: {
|
||||
status: DesktopAppUpdateStatus;
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||
installMessage: string | null;
|
||||
lastCheckedAt: number | null;
|
||||
formatVersion: (version: string | null | undefined) => string;
|
||||
formatLastCheckedAt: (timestamp: number) => string;
|
||||
}): string {
|
||||
const { status, availableUpdate, installMessage, formatVersion } = input;
|
||||
const {
|
||||
status,
|
||||
availableUpdate,
|
||||
installMessage,
|
||||
lastCheckedAt,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
} = input;
|
||||
|
||||
if (status === "checking") {
|
||||
return "Checking for app updates...";
|
||||
@@ -115,7 +127,10 @@ export function formatStatusText(input: {
|
||||
}
|
||||
|
||||
if (status === "up-to-date") {
|
||||
return "App is up to date.";
|
||||
if (lastCheckedAt != null) {
|
||||
return `Up to date. Last checked at ${formatLastCheckedAt(lastCheckedAt)}.`;
|
||||
}
|
||||
return "Up to date.";
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
@@ -155,12 +170,13 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
|
||||
|
||||
async function checkForUpdates(options?: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
intent?: DesktopAppUpdateCheckIntent;
|
||||
silent?: boolean;
|
||||
}): Promise<DesktopAppUpdateCheckResult | null> {
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
const { releaseChannel, silent = false } = options;
|
||||
const { releaseChannel, intent = "manual", silent = false } = options;
|
||||
const requestVersion = state.requestVersion + 1;
|
||||
|
||||
commit({
|
||||
@@ -171,7 +187,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel });
|
||||
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel, intent });
|
||||
if (requestVersion !== state.requestVersion) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface DesktopRuntimeInfo {
|
||||
}
|
||||
|
||||
export type DesktopReleaseChannel = "stable" | "beta";
|
||||
export type DesktopAppUpdateCheckIntent = "automatic" | "manual";
|
||||
|
||||
export interface LocalDaemonUpdateResult {
|
||||
exitCode: number;
|
||||
@@ -99,10 +100,15 @@ export async function getDesktopRuntimeInfo(): Promise<DesktopRuntimeInfo> {
|
||||
|
||||
export async function checkDesktopAppUpdate({
|
||||
releaseChannel,
|
||||
intent,
|
||||
}: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
intent: DesktopAppUpdateCheckIntent;
|
||||
}): Promise<DesktopAppUpdateCheckResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
|
||||
const result = await invokeDesktopCommand<unknown>("check_app_update", {
|
||||
releaseChannel,
|
||||
intent,
|
||||
});
|
||||
if (!isRecord(result)) {
|
||||
throw new Error("Unexpected response while checking desktop updates.");
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type {
|
||||
DesktopAppUpdateCheckResult,
|
||||
DesktopAppUpdateCheckIntent,
|
||||
DesktopAppUpdateInstallResult,
|
||||
DesktopReleaseChannel,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
|
||||
|
||||
export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
|
||||
readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>;
|
||||
readonly recordedChecks: Array<{
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
intent: DesktopAppUpdateCheckIntent;
|
||||
}>;
|
||||
readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
|
||||
nextCheckResult(result: DesktopAppUpdateCheckResult): void;
|
||||
deferNextCheck(): {
|
||||
@@ -53,7 +57,10 @@ function buildInstallResult(
|
||||
}
|
||||
|
||||
export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
|
||||
const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
|
||||
const recordedChecks: Array<{
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
intent: DesktopAppUpdateCheckIntent;
|
||||
}> = [];
|
||||
const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
|
||||
const checkOutcomes: CheckOutcome[] = [];
|
||||
const installOutcomes: InstallOutcome[] = [];
|
||||
|
||||
@@ -62,10 +62,10 @@ export function UpdateCalloutSource() {
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) return;
|
||||
|
||||
void checkForUpdates({ silent: true });
|
||||
void checkForUpdates({ intent: "automatic", silent: true });
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
void checkForUpdates({ silent: true });
|
||||
void checkForUpdates({ intent: "automatic", silent: true });
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
installDesktopAppUpdate,
|
||||
shouldShowDesktopUpdateSection,
|
||||
type DesktopAppUpdateCheckResult,
|
||||
type DesktopAppUpdateCheckIntent,
|
||||
type DesktopAppUpdateInstallResult,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
formatStatusText,
|
||||
type DesktopAppUpdateStatus,
|
||||
} from "@/desktop/updates/desktop-app-updater";
|
||||
import { formatMessageTimestamp } from "@/utils/time";
|
||||
|
||||
export type { DesktopAppUpdateStatus };
|
||||
|
||||
@@ -27,7 +29,10 @@ export interface UseDesktopAppUpdaterReturn {
|
||||
lastCheckedAt: number | null;
|
||||
isChecking: boolean;
|
||||
isInstalling: boolean;
|
||||
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>;
|
||||
checkForUpdates: (options?: {
|
||||
intent?: DesktopAppUpdateCheckIntent;
|
||||
silent?: boolean;
|
||||
}) => Promise<DesktopAppUpdateCheckResult | null>;
|
||||
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>;
|
||||
}
|
||||
|
||||
@@ -57,11 +62,15 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
);
|
||||
|
||||
const checkForUpdates = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
async (options: { intent?: DesktopAppUpdateCheckIntent; silent?: boolean } = {}) => {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
return updater.checkForUpdates({ releaseChannel, silent: options.silent });
|
||||
return updater.checkForUpdates({
|
||||
releaseChannel,
|
||||
intent: options.intent ?? "manual",
|
||||
silent: options.silent,
|
||||
});
|
||||
},
|
||||
[isDesktopApp, releaseChannel, updater],
|
||||
);
|
||||
@@ -77,7 +86,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
void checkForUpdates({ silent: true });
|
||||
void checkForUpdates({ intent: "automatic", silent: true });
|
||||
}, [checkForUpdates, isDesktopApp]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +95,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void checkForUpdates({ silent: true });
|
||||
void checkForUpdates({ intent: "automatic", silent: true });
|
||||
}, PENDING_RECHECK_MS);
|
||||
|
||||
return () => {
|
||||
@@ -101,7 +110,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
status: snapshot.status,
|
||||
availableUpdate: snapshot.availableUpdate,
|
||||
installMessage: snapshot.installMessage,
|
||||
lastCheckedAt: snapshot.lastCheckedAt,
|
||||
formatVersion: formatVersionWithPrefix,
|
||||
formatLastCheckedAt: (timestamp) => formatMessageTimestamp(new Date(timestamp)),
|
||||
}),
|
||||
availableUpdate: snapshot.availableUpdate,
|
||||
errorMessage: snapshot.errorMessage,
|
||||
|
||||
@@ -166,6 +166,25 @@ describe("git-actions-policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps push available for a no-upstream Paseo worktree with local commits", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 1,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
}),
|
||||
);
|
||||
const pushAction = actions.secondary.find((action) => action.id === "push");
|
||||
|
||||
expect(pushAction).toMatchObject({
|
||||
disabled: false,
|
||||
unavailableMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows update-from-base only on feature branches that are behind the base branch", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
|
||||
@@ -70,8 +70,8 @@ export interface BuildGitActionsInput {
|
||||
baseRefLabel: string;
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
aheadOfOrigin: number | null;
|
||||
behindOfOrigin: number | null;
|
||||
shouldPromoteArchive: boolean;
|
||||
shipDefault: "merge" | "pr";
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
@@ -489,11 +489,20 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi
|
||||
}
|
||||
|
||||
function canPull(input: BuildGitActionsInput): boolean {
|
||||
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
|
||||
return input.hasRemote && !input.hasUncommittedChanges && (input.behindOfOrigin ?? 0) > 0;
|
||||
}
|
||||
|
||||
function canPush(input: BuildGitActionsInput): boolean {
|
||||
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
|
||||
return input.hasRemote && hasPushableCommits(input) && (input.behindOfOrigin ?? 0) === 0;
|
||||
}
|
||||
|
||||
function hasPushableCommits(input: BuildGitActionsInput): boolean {
|
||||
if ((input.aheadOfOrigin ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
// No-upstream Paseo worktrees are first-pushable: the daemon push sets upstream with `git push -u`.
|
||||
// Do not fold this into aheadOfOrigin; null also covers deleted/pruned upstream branches.
|
||||
return input.isPaseoOwnedWorktree && input.aheadOfOrigin === null && input.aheadCount > 0;
|
||||
}
|
||||
|
||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
@@ -587,6 +596,9 @@ function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefi
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Pull isn't available while you have local changes so commit or stash them first";
|
||||
}
|
||||
if (input.behindOfOrigin === null) {
|
||||
return "Pull isn't available here because this branch is not connected to a remote yet";
|
||||
}
|
||||
if (input.behindOfOrigin === 0) {
|
||||
return "Pull isn't available because this branch is already up to date";
|
||||
}
|
||||
@@ -597,10 +609,10 @@ function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefi
|
||||
if (!input.hasRemote) {
|
||||
return "Push isn't available here because this branch is not connected to a remote yet";
|
||||
}
|
||||
if (input.behindOfOrigin > 0) {
|
||||
if ((input.behindOfOrigin ?? 0) > 0) {
|
||||
return "Push isn't available yet because there are newer changes to bring in first";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0) {
|
||||
if (!hasPushableCommits(input)) {
|
||||
return "Push isn't available because there is nothing new to send";
|
||||
}
|
||||
return undefined;
|
||||
@@ -613,13 +625,16 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Pull and push isn't available while you have local changes so commit or stash them first";
|
||||
}
|
||||
if (input.behindOfOrigin === null) {
|
||||
return "Pull and push isn't available because there are no incoming changes to pull first";
|
||||
}
|
||||
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
|
||||
return "Pull and push isn't available because this branch is already in sync";
|
||||
}
|
||||
if (input.behindOfOrigin === 0) {
|
||||
return "Pull and push isn't available because there are no incoming changes to pull first";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0) {
|
||||
if ((input.aheadOfOrigin ?? 0) === 0) {
|
||||
return "Pull and push isn't available because there is nothing new to send after pulling";
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -59,8 +59,8 @@ interface DerivedGitActionsState {
|
||||
actionsDisabled: boolean;
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
aheadOfOrigin: number | null;
|
||||
behindOfOrigin: number | null;
|
||||
hasPullRequest: boolean;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
@@ -71,16 +71,16 @@ interface DerivedGitActionsState {
|
||||
interface GitCommitCounts {
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
aheadOfOrigin: number | null;
|
||||
behindOfOrigin: number | null;
|
||||
}
|
||||
|
||||
function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCommitCounts {
|
||||
return {
|
||||
aheadCount: gitStatus?.aheadBehind?.ahead ?? 0,
|
||||
behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0,
|
||||
behindOfOrigin: gitStatus?.behindOfOrigin ?? 0,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? null,
|
||||
behindOfOrigin: gitStatus?.behindOfOrigin ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("ACP provider catalog", () => {
|
||||
it("uses PATH commands for entries that were binary distributions upstream", () => {
|
||||
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
|
||||
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
|
||||
expect(findProvider("deepseek-tui").command).toEqual(["deepseek", "serve", "--acp"]);
|
||||
expect(findProvider("codewhale").command).toEqual(["codewhale", "serve", "--acp"]);
|
||||
expect(findProvider("devin").command).toEqual(["devin", "acp"]);
|
||||
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||
@@ -66,4 +66,12 @@ describe("ACP provider catalog", () => {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves provider params in the daemon config patch", () => {
|
||||
const droidPatch = buildAcpProviderConfigPatch(findProvider("factory-droid"));
|
||||
|
||||
expect(droidPatch.providers?.["factory-droid"]?.params).toEqual({
|
||||
supportsMcpServers: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export function buildAcpProviderConfigPatch(
|
||||
description: entry.description,
|
||||
command: [...entry.command],
|
||||
env: entry.env ? { ...entry.env } : {},
|
||||
...(entry.params ? { params: { ...entry.params } } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,13 @@ import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import { filterAndRankCommandAutocompleteEntries } from "@/utils/agent-command-autocomplete";
|
||||
import {
|
||||
applySlashCommandReplacement,
|
||||
filterAndRankCommandAutocompleteEntries,
|
||||
filterInlineSkillCommandEntries,
|
||||
findActiveSlashCommand,
|
||||
type SlashCommandRange,
|
||||
} from "@/utils/agent-command-autocomplete";
|
||||
import {
|
||||
applyFileMentionReplacement,
|
||||
findActiveFileMention,
|
||||
@@ -133,6 +139,63 @@ function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption {
|
||||
|
||||
type AutocompleteMode = "command" | "file" | null;
|
||||
|
||||
interface BuildAutocompleteOptionsInput {
|
||||
isVisible: boolean;
|
||||
mode: AutocompleteMode;
|
||||
commands: AgentSlashCommand[];
|
||||
isDraftContext: boolean;
|
||||
commandFilterQuery: string;
|
||||
activeSlashCommand: SlashCommandRange | null;
|
||||
activeFileMention: FileMentionRange | null;
|
||||
fileSuggestions: DirectorySuggestionEntry[];
|
||||
}
|
||||
|
||||
function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
if (!input.isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (input.mode === "command") {
|
||||
const providerCommands = input.commands.map(
|
||||
(command): AvailableCommand => ({ source: "provider", command }),
|
||||
);
|
||||
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
|
||||
const rootCommands: AvailableCommand[] = input.isDraftContext
|
||||
? providerCommands
|
||||
: [
|
||||
...CLIENT_SLASH_COMMANDS.map(
|
||||
(command): AvailableCommand => ({ source: "client", command }),
|
||||
),
|
||||
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
|
||||
];
|
||||
const availableCommands =
|
||||
input.activeSlashCommand?.position === "inline"
|
||||
? filterInlineSkillCommandEntries(providerCommands)
|
||||
: rootCommands;
|
||||
const matches = filterAndRankCommandAutocompleteEntries(
|
||||
availableCommands,
|
||||
input.commandFilterQuery,
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map(mapCommandToOption);
|
||||
}
|
||||
|
||||
const activeFileMention = input.activeFileMention;
|
||||
if (input.mode === "file" && activeFileMention) {
|
||||
const orderedEntries = orderAutocompleteOptions(input.fileSuggestions);
|
||||
return orderedEntries.map((entry) => ({
|
||||
type: "workspace_entry" as const,
|
||||
id: `${entry.kind}:${entry.path}`,
|
||||
label: entry.path,
|
||||
kind: entry.kind,
|
||||
entryPath: entry.path,
|
||||
mention: activeFileMention,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveAutocompleteMode(args: {
|
||||
showFileAutocomplete: boolean;
|
||||
showCommandAutocomplete: boolean;
|
||||
@@ -161,6 +224,17 @@ function resolveAutocompleteIsVisible(args: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveCanLoadCommands(args: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
isDraftContext: boolean;
|
||||
}): boolean {
|
||||
if (!args.serverId) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(args.agentId) || args.isDraftContext;
|
||||
}
|
||||
|
||||
function resolveAutocompleteIsLoading(args: {
|
||||
mode: AutocompleteMode;
|
||||
isCommandsLoading: boolean;
|
||||
@@ -209,8 +283,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
canExecuteClientSlashCommand,
|
||||
} = input;
|
||||
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : "";
|
||||
const activeSlashCommand = useMemo(
|
||||
() =>
|
||||
findActiveSlashCommand({
|
||||
text: userInput,
|
||||
cursorIndex,
|
||||
}),
|
||||
[cursorIndex, userInput],
|
||||
);
|
||||
const showCommandAutocomplete = activeSlashCommand !== null;
|
||||
const commandFilterQuery = activeSlashCommand?.query ?? "";
|
||||
|
||||
const activeFileMention = useMemo(
|
||||
() =>
|
||||
@@ -235,8 +317,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
);
|
||||
|
||||
const isDraftContext = normalizedDraftConfig !== undefined;
|
||||
const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined;
|
||||
const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext);
|
||||
const queryDraftConfig = normalizedDraftConfig;
|
||||
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||
|
||||
const agentCwd = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
|
||||
@@ -309,54 +391,29 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const options = useMemo<AgentAutocompleteOption[]>(() => {
|
||||
if (!isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (mode === "command") {
|
||||
const providerCommands = commands.map(
|
||||
(command): AvailableCommand => ({ source: "provider", command }),
|
||||
);
|
||||
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
|
||||
const availableCommands: AvailableCommand[] = isDraftContext
|
||||
? providerCommands
|
||||
: [
|
||||
...CLIENT_SLASH_COMMANDS.map(
|
||||
(command): AvailableCommand => ({ source: "client", command }),
|
||||
),
|
||||
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
|
||||
];
|
||||
const matches = filterAndRankCommandAutocompleteEntries(
|
||||
availableCommands,
|
||||
const options = useMemo<AgentAutocompleteOption[]>(
|
||||
() =>
|
||||
buildCommandAutocompleteOptions({
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map(mapCommandToOption);
|
||||
}
|
||||
|
||||
if (mode === "file" && activeFileMention) {
|
||||
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []);
|
||||
return orderedEntries.map((entry) => ({
|
||||
type: "workspace_entry" as const,
|
||||
id: `${entry.kind}:${entry.path}`,
|
||||
label: entry.path,
|
||||
kind: entry.kind,
|
||||
entryPath: entry.path,
|
||||
mention: activeFileMention,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
]);
|
||||
commands,
|
||||
activeSlashCommand,
|
||||
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
}),
|
||||
[
|
||||
activeFileMention,
|
||||
activeSlashCommand,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
],
|
||||
);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
@@ -372,7 +429,20 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
}
|
||||
|
||||
if (selected.type === "client_command" || selected.type === "provider_command") {
|
||||
setUserInput(`/${selected.id} `);
|
||||
if (!activeSlashCommand) {
|
||||
setUserInput(`/${selected.id} `);
|
||||
onAutocompleteApplied?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextInput = applySlashCommandReplacement({
|
||||
text: userInput,
|
||||
command: activeSlashCommand,
|
||||
commandName: selected.id,
|
||||
});
|
||||
const shouldAppendSpace =
|
||||
activeSlashCommand.position === "start" && activeSlashCommand.end === userInput.length;
|
||||
setUserInput(shouldAppendSpace ? `${nextInput} ` : nextInput);
|
||||
onAutocompleteApplied?.();
|
||||
return;
|
||||
}
|
||||
@@ -391,6 +461,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
onClientSlashCommand,
|
||||
setUserInput,
|
||||
userInput,
|
||||
activeSlashCommand,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -399,7 +470,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
options,
|
||||
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
|
||||
onSelectOption,
|
||||
onEscape: mode === "command" ? () => setUserInput("") : undefined,
|
||||
onEscape:
|
||||
mode === "command" && activeSlashCommand?.position === "start"
|
||||
? () => setUserInput("")
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const isLoading = resolveAutocompleteIsLoading({
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface DraftCommandConfig {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("combined model selector data", () => {
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
description: undefined,
|
||||
description: "gpt-5.4",
|
||||
isDefault: undefined,
|
||||
},
|
||||
],
|
||||
@@ -69,22 +69,22 @@ describe("combined model selector data", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
provider: "codewhale",
|
||||
label: "CodeWhale",
|
||||
models: [],
|
||||
}),
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
id: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
id: "codewhale",
|
||||
label: "CodeWhale",
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "deepseek-tui:",
|
||||
provider: "deepseek-tui",
|
||||
providerLabel: "DeepSeek TUI",
|
||||
favoriteKey: "codewhale:",
|
||||
provider: "codewhale",
|
||||
providerLabel: "CodeWhale",
|
||||
modelId: "",
|
||||
modelLabel: "Default",
|
||||
description: undefined,
|
||||
@@ -100,8 +100,8 @@ describe("combined model selector data", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
provider: "codewhale",
|
||||
label: "CodeWhale",
|
||||
enabled: false,
|
||||
models: [],
|
||||
}),
|
||||
@@ -235,8 +235,8 @@ describe("combined model selector data", () => {
|
||||
models: [codexModel],
|
||||
}),
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
provider: "codewhale",
|
||||
label: "CodeWhale",
|
||||
models: [],
|
||||
}),
|
||||
]);
|
||||
@@ -252,7 +252,7 @@ describe("combined model selector data", () => {
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider: "deepseek-tui",
|
||||
selectedProvider: "codewhale",
|
||||
selectedModel: "",
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -307,7 +307,7 @@ describe("combined model selector data", () => {
|
||||
allowsEmptyAutoSubmit: false,
|
||||
providerCount: 1,
|
||||
selection: {
|
||||
provider: "deepseek-tui",
|
||||
provider: "codewhale",
|
||||
modelId: "",
|
||||
availableModels: [],
|
||||
isModelLoading: false,
|
||||
|
||||
@@ -47,7 +47,7 @@ function buildModelRows(
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
description: model.description ?? model.id,
|
||||
isDefault: model.isDefault,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -523,7 +523,7 @@ function DesktopAppUpdateRow() {
|
||||
if (!isDesktopApp) {
|
||||
return undefined;
|
||||
}
|
||||
void checkForUpdates({ silent: true });
|
||||
void checkForUpdates({ intent: "automatic", silent: true });
|
||||
return undefined;
|
||||
}, [checkForUpdates, isDesktopApp]),
|
||||
);
|
||||
|
||||
@@ -97,7 +97,6 @@ vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
Plus: icon("Plus"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -143,8 +142,8 @@ vi.mock("@/stores/provider-settings-store", () => ({
|
||||
selector({ open: openProviderSettingsMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/add-provider-modal", () => ({
|
||||
AddProviderModal: () => null,
|
||||
vi.mock("@/components/provider-catalog-list", () => ({
|
||||
ProviderCatalogList: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
||||
|
||||
@@ -6,13 +6,17 @@ import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { AddProviderModal } from "@/components/add-provider-modal";
|
||||
import {
|
||||
buildAcpProviderConfigPatch,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { ProviderCatalogList } from "@/components/provider-catalog-list";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { ChevronRight, Plus } from "lucide-react-native";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
|
||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
||||
@@ -177,13 +181,12 @@ export interface ProvidersSectionProps {
|
||||
}
|
||||
|
||||
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { entries, isLoading } = useProvidersSnapshot(serverId);
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
||||
const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
const hasServer = serverId.length > 0;
|
||||
@@ -194,8 +197,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
},
|
||||
[openProviderSettings, serverId],
|
||||
);
|
||||
const handleOpenAddProvider = useCallback(() => setIsAddProviderOpen(true), []);
|
||||
const handleCloseAddProvider = useCallback(() => setIsAddProviderOpen(false), []);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (providerId: string, enabled: boolean) => {
|
||||
setPendingProviderId(providerId);
|
||||
@@ -213,37 +215,29 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
[patchConfig],
|
||||
);
|
||||
|
||||
const headerActions = useMemo(
|
||||
() =>
|
||||
hasServer && isConnected ? (
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={handleOpenAddProvider}
|
||||
hitSlop={8}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add provider"
|
||||
testID="add-provider-button"
|
||||
>
|
||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={settingsStyles.sectionHeaderLinkText}>Add provider</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : undefined,
|
||||
[
|
||||
hasServer,
|
||||
isConnected,
|
||||
handleOpenAddProvider,
|
||||
theme.iconSize.sm,
|
||||
theme.colors.foregroundMuted,
|
||||
],
|
||||
const handleInstall = useCallback(
|
||||
async (entry: AcpProviderCatalogItem) => {
|
||||
if (installingProviderId) return;
|
||||
setInstallingProviderId(entry.id);
|
||||
try {
|
||||
await patchConfig(buildAcpProviderConfigPatch(entry));
|
||||
await refresh([entry.id]);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Unable to add provider",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, patchConfig, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Providers"
|
||||
trailing={headerActions}
|
||||
testID="host-page-providers-card"
|
||||
style={styles.sectionSpacing}
|
||||
>
|
||||
@@ -279,8 +273,18 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
{hasServer && isConnected && isAddProviderOpen ? (
|
||||
<AddProviderModal serverId={serverId} visible onClose={handleCloseAddProvider} />
|
||||
{hasServer && isConnected ? (
|
||||
<SettingsSection
|
||||
title="Add provider"
|
||||
testID="host-page-add-provider-card"
|
||||
style={styles.addProviderSection}
|
||||
>
|
||||
<ProviderCatalogList
|
||||
serverId={serverId}
|
||||
installingProviderId={installingProviderId}
|
||||
onInstall={handleInstall}
|
||||
/>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -290,6 +294,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sectionSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
addProviderSection: {
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
@@ -298,11 +305,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[3],
|
||||
minHeight: 56,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { filterAndRankCommandAutocompleteEntries } from "./agent-command-autocomplete";
|
||||
import {
|
||||
applySlashCommandReplacement,
|
||||
filterAndRankCommandAutocompleteEntries,
|
||||
filterInlineSkillCommandEntries,
|
||||
findActiveSlashCommand,
|
||||
} from "./agent-command-autocomplete";
|
||||
|
||||
describe("filterAndRankCommandAutocompleteEntries", () => {
|
||||
const entries = [
|
||||
@@ -27,3 +32,81 @@ describe("filterAndRankCommandAutocompleteEntries", () => {
|
||||
expect(result.map((entry) => entry.command.name)).toEqual(["exit"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findActiveSlashCommand", () => {
|
||||
it("detects a slash command token in the middle of the prompt", () => {
|
||||
const text = "use /tas before implementation";
|
||||
|
||||
expect(
|
||||
findActiveSlashCommand({
|
||||
text,
|
||||
cursorIndex: "use /tas".length,
|
||||
}),
|
||||
).toEqual({
|
||||
start: 4,
|
||||
end: "use /tas".length,
|
||||
query: "tas",
|
||||
position: "inline",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a slash command token at the prompt start", () => {
|
||||
expect(
|
||||
findActiveSlashCommand({
|
||||
text: "/rew",
|
||||
cursorIndex: "/rew".length,
|
||||
}),
|
||||
).toEqual({
|
||||
start: 0,
|
||||
end: "/rew".length,
|
||||
query: "rew",
|
||||
position: "start",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the cursor is outside the slash token", () => {
|
||||
expect(
|
||||
findActiveSlashCommand({
|
||||
text: "use /taste now",
|
||||
cursorIndex: "use /taste now".length,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for slash-delimited paths", () => {
|
||||
expect(
|
||||
findActiveSlashCommand({
|
||||
text: "read /tmp/project",
|
||||
cursorIndex: "read /tmp/project".length,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySlashCommandReplacement", () => {
|
||||
it("replaces only the active slash token", () => {
|
||||
const text = "use /tas before implementation";
|
||||
|
||||
expect(
|
||||
applySlashCommandReplacement({
|
||||
text,
|
||||
command: { start: 4, end: "use /tas".length, query: "tas", position: "inline" },
|
||||
commandName: "taste",
|
||||
}),
|
||||
).toBe("use /taste before implementation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterInlineSkillCommandEntries", () => {
|
||||
it("keeps provider skills and drops executable commands", () => {
|
||||
const entries = [
|
||||
{ source: "client" as const, command: { name: "clear", kind: "command" } },
|
||||
{ source: "provider" as const, command: { name: "compact", kind: "command" } },
|
||||
{ source: "provider" as const, command: { name: "taste", kind: "skill" } },
|
||||
];
|
||||
|
||||
expect(filterInlineSkillCommandEntries(entries).map((entry) => entry.command.name)).toEqual([
|
||||
"taste",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,34 @@ interface CommandAutocompleteEntry {
|
||||
command: {
|
||||
name: string;
|
||||
aliases?: readonly string[];
|
||||
kind?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface InlineSkillCommandEntry extends CommandAutocompleteEntry {
|
||||
source: "provider" | "client";
|
||||
}
|
||||
|
||||
export type SlashCommandPosition = "start" | "inline";
|
||||
|
||||
export interface SlashCommandRange {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
position: SlashCommandPosition;
|
||||
}
|
||||
|
||||
interface FindActiveSlashCommandInput {
|
||||
text: string;
|
||||
cursorIndex: number;
|
||||
}
|
||||
|
||||
interface ApplySlashCommandReplacementInput {
|
||||
text: string;
|
||||
command: SlashCommandRange;
|
||||
commandName: string;
|
||||
}
|
||||
|
||||
interface ScoredCommandAutocompleteEntry<TEntry> {
|
||||
entry: TEntry;
|
||||
score: MatchScore;
|
||||
@@ -46,3 +71,49 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
|
||||
|
||||
return scoredEntries.map((scored) => scored.entry);
|
||||
}
|
||||
|
||||
export function filterInlineSkillCommandEntries<TEntry extends InlineSkillCommandEntry>(
|
||||
entries: readonly TEntry[],
|
||||
): TEntry[] {
|
||||
return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill");
|
||||
}
|
||||
|
||||
const INVALID_SLASH_COMMAND_QUERY_CHARS = /[/\s\n\r\t"']/;
|
||||
|
||||
export function findActiveSlashCommand(
|
||||
input: FindActiveSlashCommandInput,
|
||||
): SlashCommandRange | null {
|
||||
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
|
||||
const beforeCursor = input.text.slice(0, clampedCursor);
|
||||
|
||||
for (
|
||||
let slashIndex = beforeCursor.lastIndexOf("/");
|
||||
slashIndex >= 0;
|
||||
slashIndex = slashIndex === 0 ? -1 : beforeCursor.lastIndexOf("/", slashIndex - 1)
|
||||
) {
|
||||
const previousCharacter = slashIndex > 0 ? input.text[slashIndex - 1] : "";
|
||||
if (previousCharacter && !/\s/.test(previousCharacter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const query = beforeCursor.slice(slashIndex + 1);
|
||||
if (INVALID_SLASH_COMMAND_QUERY_CHARS.test(query)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
start: slashIndex,
|
||||
end: clampedCursor,
|
||||
query,
|
||||
position: slashIndex === 0 ? "start" : "inline",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applySlashCommandReplacement(input: ApplySlashCommandReplacementInput): string {
|
||||
const before = input.text.slice(0, input.command.start);
|
||||
const after = input.text.slice(input.command.end);
|
||||
return `${before}/${input.commandName}${after}`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseHostWorkspaceOpenIntentFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
resolveKnownHostRoute,
|
||||
} from "./host-routes";
|
||||
|
||||
describe("parseHostAgentRouteFromPathname", () => {
|
||||
@@ -190,3 +191,32 @@ describe("host settings section slugs", () => {
|
||||
expect(normalizeHostSectionSlug("daemon")).toBe("host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKnownHostRoute", () => {
|
||||
it("renders when the route host is still saved", () => {
|
||||
expect(
|
||||
resolveKnownHostRoute({
|
||||
routeServerId: "srv-current",
|
||||
hosts: [{ serverId: "srv-current" }, { serverId: "srv-next" }],
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("sends removed host routes to the next saved host home", () => {
|
||||
expect(
|
||||
resolveKnownHostRoute({
|
||||
routeServerId: "srv-removed",
|
||||
hosts: [{ serverId: "srv-next" }],
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/srv-next/open-project" });
|
||||
});
|
||||
|
||||
it("sends host routes to welcome when no hosts are saved", () => {
|
||||
expect(
|
||||
resolveKnownHostRoute({
|
||||
routeServerId: "srv-removed",
|
||||
hosts: [],
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,6 +355,27 @@ export function buildHostOpenProjectRoute(serverId: string) {
|
||||
return `${base}/open-project` as const;
|
||||
}
|
||||
|
||||
export type KnownHostRouteResolution =
|
||||
| { kind: "render" }
|
||||
| { kind: "redirect"; href: ReturnType<typeof buildHostOpenProjectRoute> | "/welcome" };
|
||||
|
||||
export function resolveKnownHostRoute(input: {
|
||||
routeServerId: string | null | undefined;
|
||||
hosts: readonly { serverId: string }[];
|
||||
}): KnownHostRouteResolution {
|
||||
const routeServerId = trimNonEmpty(input.routeServerId);
|
||||
if (routeServerId && input.hosts.some((host) => host.serverId === routeServerId)) {
|
||||
return { kind: "render" };
|
||||
}
|
||||
|
||||
const fallbackServerId = input.hosts[0]?.serverId;
|
||||
if (fallbackServerId) {
|
||||
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
|
||||
}
|
||||
|
||||
return { kind: "redirect", href: "/welcome" };
|
||||
}
|
||||
|
||||
export function buildHostNewWorkspaceRoute(
|
||||
serverId: string,
|
||||
sourceDirectory?: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/server": "0.1.91",
|
||||
"@getpaseo/client": "0.1.93",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/server": "0.1.93",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -45,6 +45,11 @@ interface ProviderListRow {
|
||||
}
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
{
|
||||
id: "claude-fable-5",
|
||||
model: "Fable 5",
|
||||
descriptionFragment: "Most powerful",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-8[1m]",
|
||||
model: "Opus 4.8 1M",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/relay": "0.1.93",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
checkForAppUpdate,
|
||||
downloadAndInstallUpdate,
|
||||
type AppUpdateCheckIntent,
|
||||
type AppReleaseChannel,
|
||||
} from "../features/auto-updater.js";
|
||||
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
||||
@@ -81,6 +82,12 @@ function parseReleaseChannel(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseAppUpdateCheckIntent(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): AppUpdateCheckIntent {
|
||||
return args?.intent === "manual" ? "manual" : "automatic";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -517,6 +524,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
return checkForAppUpdate({
|
||||
currentVersion,
|
||||
releaseChannel: await resolveRequestedReleaseChannel(args),
|
||||
intent: parseAppUpdateCheckIntent(args),
|
||||
});
|
||||
},
|
||||
install_app_update: async (args) => {
|
||||
|
||||
156
packages/desktop/src/features/app-update-rollout.test.ts
Normal file
156
packages/desktop/src/features/app-update-rollout.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { bucketFromStagingUserId, shouldAdmitAppUpdate } from "./app-update-rollout";
|
||||
|
||||
describe("shouldAdmitAppUpdate", () => {
|
||||
it("keeps automatic stable updates behind the rollout window", () => {
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
||||
bucket: 0.51,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lets manual stable checks bypass rollout admission", () => {
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "manual",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
||||
bucket: 0.99,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "beta",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||
bucket: 0.99,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: undefined,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||
bucket: 0.99,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 0,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||
bucket: 0.99,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: undefined,
|
||||
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||
bucket: 0.99,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks future automatic releases and admits the same release manually", () => {
|
||||
const input = {
|
||||
channel: "stable" as const,
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T02:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||
bucket: 0,
|
||||
};
|
||||
|
||||
expect(shouldAdmitAppUpdate({ ...input, intent: "automatic" })).toBe(false);
|
||||
expect(shouldAdmitAppUpdate({ ...input, intent: "manual" })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks the bucket-zero client at exact release time, admits as soon as time advances", () => {
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T00:00:00.000Z"),
|
||||
bucket: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-28T00:00:00.001Z"),
|
||||
bucket: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("admits the highest-bucket automatic client at and past the rollout end", () => {
|
||||
const maxBucket = (0x100000000 - 1) / 0x100000000;
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2026-04-29T00:00:00.000Z"),
|
||||
bucket: maxBucket,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
now: Date.parse("2027-04-28T00:00:00.000Z"),
|
||||
bucket: maxBucket,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("admits when releaseDate is unparseable", () => {
|
||||
expect(
|
||||
shouldAdmitAppUpdate({
|
||||
channel: "stable",
|
||||
intent: "automatic",
|
||||
rolloutHours: 24,
|
||||
releaseDate: "not a date",
|
||||
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
||||
bucket: 0.99,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("maps the maximum 32-bit slot to a bucket strictly less than 1", () => {
|
||||
const allOnes = "ffffffff-ffff-ffff-ffff-ffffffffffff";
|
||||
const allZeros = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
expect(bucketFromStagingUserId(allOnes)).toBeLessThan(1);
|
||||
expect(bucketFromStagingUserId(allOnes)).toBeGreaterThan(0.999);
|
||||
expect(bucketFromStagingUserId(allZeros)).toBe(0);
|
||||
});
|
||||
});
|
||||
42
packages/desktop/src/features/app-update-rollout.ts
Normal file
42
packages/desktop/src/features/app-update-rollout.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { UUID } from "builder-util-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
export type AppReleaseChannel = "stable" | "beta";
|
||||
export type AppUpdateCheckIntent = "automatic" | "manual";
|
||||
|
||||
export const rolloutManifestSchema = z.object({
|
||||
rolloutHours: z
|
||||
.union([z.number(), z.string().transform(Number)])
|
||||
.pipe(z.number().finite().nonnegative())
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
releaseDate: z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
export function shouldAdmitAppUpdate(args: {
|
||||
channel: AppReleaseChannel;
|
||||
intent: AppUpdateCheckIntent;
|
||||
rolloutHours: number | undefined;
|
||||
releaseDate: string | undefined;
|
||||
now: number;
|
||||
bucket: number;
|
||||
}): boolean {
|
||||
if (args.intent === "manual") return true;
|
||||
if (args.channel !== "stable") return true;
|
||||
if (args.rolloutHours == null) return true;
|
||||
if (args.rolloutHours === 0) return true;
|
||||
if (!args.releaseDate) return true;
|
||||
|
||||
const releaseTime = new Date(args.releaseDate).getTime();
|
||||
if (Number.isNaN(releaseTime)) return true;
|
||||
|
||||
const ageHours = (args.now - releaseTime) / 3_600_000;
|
||||
if (ageHours < 0) return false;
|
||||
|
||||
const pct = Math.min(100, (ageHours / args.rolloutHours) * 100);
|
||||
return args.bucket * 100 < pct;
|
||||
}
|
||||
|
||||
export function bucketFromStagingUserId(stagingUserId: string): number {
|
||||
return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000;
|
||||
}
|
||||
114
packages/desktop/src/features/app-update-service.test.ts
Normal file
114
packages/desktop/src/features/app-update-service.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createAppUpdateService,
|
||||
type AppUpdateRuntime,
|
||||
type AppUpdateRuntimeConfiguration,
|
||||
type RuntimeUpdateInfo,
|
||||
} from "./app-update-service";
|
||||
|
||||
class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
private checks: Array<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null> = [];
|
||||
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null;
|
||||
|
||||
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||
this.gate = input.shouldAdmitUpdate;
|
||||
}
|
||||
|
||||
nextCheck(result: { isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null): void {
|
||||
this.checks.push(result);
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<{
|
||||
isUpdateAvailable: boolean;
|
||||
updateInfo: RuntimeUpdateInfo;
|
||||
} | null> {
|
||||
const result = this.checks.shift() ?? null;
|
||||
if (!result || !this.gate) return result;
|
||||
const admitted = await this.gate(result.updateInfo);
|
||||
return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted };
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<void> {}
|
||||
|
||||
quitAndInstall(): void {}
|
||||
}
|
||||
|
||||
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
|
||||
const runtime = new FakeAppUpdateRuntime();
|
||||
const service = createAppUpdateService({
|
||||
runtime,
|
||||
isPackaged: () => true,
|
||||
now: input?.now ?? (() => Date.parse("2026-04-28T12:00:00.000Z")),
|
||||
bucket: input?.bucket ?? (async () => 0.99),
|
||||
});
|
||||
return { runtime, service };
|
||||
}
|
||||
|
||||
const rolledOutUpdate = {
|
||||
version: "1.2.4",
|
||||
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||
rolloutHours: 24,
|
||||
};
|
||||
|
||||
describe("app update service", () => {
|
||||
it("does not expose automatic stable updates before the user is admitted to rollout", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes manual stable updates even before the user is admitted to rollout", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts the runtime availability decision before comparing versions", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
303
packages/desktop/src/features/app-update-service.ts
Normal file
303
packages/desktop/src/features/app-update-service.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
rolloutManifestSchema,
|
||||
shouldAdmitAppUpdate,
|
||||
type AppReleaseChannel,
|
||||
type AppUpdateCheckIntent,
|
||||
} from "./app-update-rollout.js";
|
||||
|
||||
export interface AppUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
readyToInstall: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
body: string | null;
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
export interface AppUpdateInstallResult {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RuntimeUpdateInfo {
|
||||
version: string;
|
||||
releaseNotes?: unknown;
|
||||
releaseDate?: unknown;
|
||||
rolloutHours?: unknown;
|
||||
}
|
||||
|
||||
export interface RuntimeUpdateCheckResult {
|
||||
isUpdateAvailable: boolean;
|
||||
updateInfo: RuntimeUpdateInfo;
|
||||
}
|
||||
|
||||
export interface AppUpdateRuntimeConfiguration {
|
||||
releaseChannel: AppReleaseChannel;
|
||||
shouldAdmitUpdate(info: RuntimeUpdateInfo): boolean | Promise<boolean>;
|
||||
onUpdateAvailable(info: RuntimeUpdateInfo): void;
|
||||
onUpdateDownloaded(info: RuntimeUpdateInfo): void;
|
||||
onUpdateNotAvailable(): void;
|
||||
onError(error: unknown): void;
|
||||
}
|
||||
|
||||
export interface AppUpdateRuntime {
|
||||
configure(input: AppUpdateRuntimeConfiguration): void;
|
||||
checkForUpdates(): Promise<RuntimeUpdateCheckResult | null>;
|
||||
downloadUpdate(): Promise<unknown>;
|
||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void;
|
||||
}
|
||||
|
||||
export interface AppUpdateService {
|
||||
checkForAppUpdate(input: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
intent: AppUpdateCheckIntent;
|
||||
}): Promise<AppUpdateCheckResult>;
|
||||
downloadAndInstallUpdate(
|
||||
input: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
},
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<AppUpdateInstallResult>;
|
||||
}
|
||||
|
||||
export interface AppUpdateServiceDeps {
|
||||
runtime: AppUpdateRuntime;
|
||||
isPackaged(): boolean;
|
||||
now(): number;
|
||||
bucket(): Promise<number>;
|
||||
reportCheckError?(error: unknown): void;
|
||||
reportRuntimeError?(error: unknown): void;
|
||||
reportInstallError?(message: string): void;
|
||||
}
|
||||
|
||||
function buildCheckResult(input: {
|
||||
currentVersion: string;
|
||||
hasUpdate: boolean;
|
||||
readyToInstall: boolean;
|
||||
info?: RuntimeUpdateInfo | null;
|
||||
}): AppUpdateCheckResult {
|
||||
const { currentVersion, hasUpdate, readyToInstall, info } = input;
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
readyToInstall,
|
||||
currentVersion,
|
||||
latestVersion: info?.version ?? currentVersion,
|
||||
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
|
||||
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function performQuitAndInstall(
|
||||
runtime: AppUpdateRuntime,
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
}
|
||||
|
||||
export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService {
|
||||
let cachedUpdateInfo: RuntimeUpdateInfo | null = null;
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
let downloading = false;
|
||||
let configuredReleaseChannel: AppReleaseChannel | null = null;
|
||||
|
||||
function isReadyToInstallVersion(version: string): boolean {
|
||||
return downloadedUpdateVersion === version;
|
||||
}
|
||||
|
||||
function clearUpdateState(): void {
|
||||
cachedUpdateInfo = null;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = false;
|
||||
}
|
||||
|
||||
function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
|
||||
if (configuredReleaseChannel !== releaseChannel) {
|
||||
clearUpdateState();
|
||||
configuredReleaseChannel = releaseChannel;
|
||||
}
|
||||
|
||||
deps.runtime.configure({
|
||||
releaseChannel,
|
||||
shouldAdmitUpdate: async (info) => {
|
||||
const parsed = rolloutManifestSchema.parse(info);
|
||||
return shouldAdmitAppUpdate({
|
||||
channel: releaseChannel,
|
||||
intent,
|
||||
rolloutHours: parsed.rolloutHours,
|
||||
releaseDate: parsed.releaseDate,
|
||||
now: deps.now(),
|
||||
bucket: await deps.bucket(),
|
||||
});
|
||||
},
|
||||
onUpdateAvailable(info) {
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = true;
|
||||
},
|
||||
onUpdateDownloaded(info) {
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = info.version;
|
||||
downloading = false;
|
||||
},
|
||||
onUpdateNotAvailable() {
|
||||
clearUpdateState();
|
||||
},
|
||||
onError(error) {
|
||||
downloading = false;
|
||||
deps.reportRuntimeError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForAppUpdate({
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
intent,
|
||||
}: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
intent: AppUpdateCheckIntent;
|
||||
}): Promise<AppUpdateCheckResult> {
|
||||
if (!deps.isPackaged()) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
|
||||
configureRuntime(releaseChannel, intent);
|
||||
|
||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
||||
info: cachedUpdateInfo,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deps.runtime.checkForUpdates();
|
||||
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
|
||||
clearUpdateState();
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
|
||||
const info = result.updateInfo;
|
||||
const latestVersion = info.version;
|
||||
const hasUpdate = latestVersion !== currentVersion;
|
||||
|
||||
if (hasUpdate) {
|
||||
cachedUpdateInfo = info;
|
||||
downloading = !isReadyToInstallVersion(latestVersion);
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
readyToInstall: isReadyToInstallVersion(latestVersion),
|
||||
info,
|
||||
});
|
||||
}
|
||||
|
||||
clearUpdateState();
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
} catch (error) {
|
||||
deps.reportCheckError?.(error);
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndInstallUpdate(
|
||||
{
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
}: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
},
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<AppUpdateInstallResult> {
|
||||
if (!deps.isPackaged()) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Auto-update is not available in development mode.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!cachedUpdateInfo) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "No update available. Check for updates first.",
|
||||
};
|
||||
}
|
||||
|
||||
configureRuntime(releaseChannel, "manual");
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Update is still being prepared. Try again in a moment.",
|
||||
};
|
||||
}
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
await deps.runtime.downloadUpdate();
|
||||
downloadedUpdateVersion = readyVersion;
|
||||
downloading = false;
|
||||
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
} catch (error) {
|
||||
downloading = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.reportInstallError?.(message);
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: `Update failed: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkForAppUpdate,
|
||||
downloadAndInstallUpdate,
|
||||
};
|
||||
}
|
||||
@@ -3,48 +3,34 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import { UUID } from "builder-util-runtime";
|
||||
import { autoUpdater, type UpdateInfo } from "electron-updater";
|
||||
import { z } from "zod";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import {
|
||||
createAppUpdateService,
|
||||
type AppUpdateCheckResult,
|
||||
type AppUpdateInstallResult,
|
||||
type AppUpdateRuntime,
|
||||
type AppUpdateRuntimeConfiguration,
|
||||
type RuntimeUpdateCheckResult,
|
||||
type RuntimeUpdateInfo,
|
||||
} from "./app-update-service.js";
|
||||
import {
|
||||
bucketFromStagingUserId,
|
||||
rolloutManifestSchema,
|
||||
shouldAdmitAppUpdate,
|
||||
type AppReleaseChannel,
|
||||
type AppUpdateCheckIntent,
|
||||
} from "./app-update-rollout.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
bucketFromStagingUserId,
|
||||
rolloutManifestSchema,
|
||||
shouldAdmitAppUpdate,
|
||||
type AppReleaseChannel,
|
||||
type AppUpdateCheckIntent,
|
||||
type AppUpdateCheckResult,
|
||||
type AppUpdateInstallResult,
|
||||
};
|
||||
|
||||
export interface AppUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
readyToInstall: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
body: string | null;
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
export interface AppUpdateInstallResult {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type AppReleaseChannel = "stable" | "beta";
|
||||
|
||||
export const rolloutManifestSchema = z.object({
|
||||
rolloutHours: z
|
||||
.union([z.number(), z.string().transform(Number)])
|
||||
.pipe(z.number().finite().nonnegative())
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
releaseDate: z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedUpdateInfo: UpdateInfo | null = null;
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
let downloading = false;
|
||||
let autoUpdaterConfigured = false;
|
||||
let configuredReleaseChannel: AppReleaseChannel | null = null;
|
||||
let cachedStagingUserIdPromise: Promise<string> | null = null;
|
||||
|
||||
export function shouldAdmitToRollout(args: {
|
||||
@@ -54,23 +40,7 @@ export function shouldAdmitToRollout(args: {
|
||||
now: number;
|
||||
bucket: number;
|
||||
}): boolean {
|
||||
if (args.channel !== "stable") return true;
|
||||
if (args.rolloutHours == null) return true;
|
||||
if (args.rolloutHours === 0) return true;
|
||||
if (!args.releaseDate) return true;
|
||||
|
||||
const releaseTime = new Date(args.releaseDate).getTime();
|
||||
if (Number.isNaN(releaseTime)) return true;
|
||||
|
||||
const ageHours = (args.now - releaseTime) / 3_600_000;
|
||||
if (ageHours < 0) return false;
|
||||
|
||||
const pct = Math.min(100, (ageHours / args.rolloutHours) * 100);
|
||||
return args.bucket * 100 < pct;
|
||||
}
|
||||
|
||||
export function bucketFromStagingUserId(stagingUserId: string): number {
|
||||
return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000;
|
||||
return shouldAdmitAppUpdate({ ...args, intent: "automatic" });
|
||||
}
|
||||
|
||||
export async function resolveStagingUserId(filePath: string): Promise<string> {
|
||||
@@ -106,100 +76,74 @@ export function getStagingUserId(): Promise<string> {
|
||||
return cachedStagingUserIdPromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
||||
private configured = false;
|
||||
|
||||
function configureAutoUpdater(releaseChannel: AppReleaseChannel): void {
|
||||
// Download updates in the background and only prompt once they are ready to install.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
autoUpdater.allowPrerelease = input.releaseChannel === "beta";
|
||||
autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest";
|
||||
autoUpdater.allowDowngrade = false;
|
||||
autoUpdater.isUserWithinRollout = async (info) => {
|
||||
try {
|
||||
return await input.shouldAdmitUpdate(info as RuntimeUpdateInfo);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Suppress built-in dialogs; the renderer handles UI.
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
autoUpdater.allowPrerelease = releaseChannel === "beta";
|
||||
autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest";
|
||||
autoUpdater.allowDowngrade = false;
|
||||
autoUpdater.isUserWithinRollout = async (info) => {
|
||||
try {
|
||||
const parsed = rolloutManifestSchema.parse(info);
|
||||
const stagingUserId = await getStagingUserId();
|
||||
if (this.configured) return;
|
||||
this.configured = true;
|
||||
|
||||
return shouldAdmitToRollout({
|
||||
channel: releaseChannel,
|
||||
rolloutHours: parsed.rolloutHours,
|
||||
releaseDate: parsed.releaseDate,
|
||||
now: Date.now(),
|
||||
bucket: bucketFromStagingUserId(stagingUserId),
|
||||
});
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
if (configuredReleaseChannel !== releaseChannel) {
|
||||
cachedUpdateInfo = null;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = false;
|
||||
configuredReleaseChannel = releaseChannel;
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
input.onUpdateAvailable(info as RuntimeUpdateInfo);
|
||||
});
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
input.onUpdateDownloaded(info as RuntimeUpdateInfo);
|
||||
});
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
input.onUpdateNotAvailable();
|
||||
});
|
||||
autoUpdater.on("error", (error) => {
|
||||
input.onError(error);
|
||||
});
|
||||
}
|
||||
|
||||
if (autoUpdaterConfigured) {
|
||||
return;
|
||||
async checkForUpdates(): Promise<RuntimeUpdateCheckResult | null> {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
if (!result) return null;
|
||||
return {
|
||||
isUpdateAvailable: result.isUpdateAvailable,
|
||||
updateInfo: result.updateInfo as RuntimeUpdateInfo,
|
||||
};
|
||||
}
|
||||
|
||||
autoUpdaterConfigured = true;
|
||||
downloadUpdate(): Promise<unknown> {
|
||||
return autoUpdater.downloadUpdate();
|
||||
}
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = true;
|
||||
});
|
||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
|
||||
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
|
||||
}
|
||||
}
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = info.version;
|
||||
downloading = false;
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
cachedUpdateInfo = null;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = false;
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
downloading = false;
|
||||
const appUpdateService = createAppUpdateService({
|
||||
runtime: new ElectronAppUpdateRuntime(),
|
||||
isPackaged: () => app.isPackaged,
|
||||
now: () => Date.now(),
|
||||
bucket: async () => bucketFromStagingUserId(await getStagingUserId()),
|
||||
reportCheckError: (error) => {
|
||||
console.error("[auto-updater] Failed to check for updates:", error);
|
||||
},
|
||||
reportRuntimeError: (error) => {
|
||||
console.error("[auto-updater] Updater event failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function isReadyToInstallVersion(version: string): boolean {
|
||||
return downloadedUpdateVersion === version;
|
||||
}
|
||||
|
||||
function buildCheckResult(input: {
|
||||
currentVersion: string;
|
||||
hasUpdate: boolean;
|
||||
readyToInstall: boolean;
|
||||
info?: UpdateInfo | null;
|
||||
}): AppUpdateCheckResult {
|
||||
const { currentVersion, hasUpdate, readyToInstall, info } = input;
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
readyToInstall,
|
||||
currentVersion,
|
||||
latestVersion: info?.version ?? currentVersion,
|
||||
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
|
||||
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
}
|
||||
},
|
||||
reportInstallError: (message) => {
|
||||
console.error("[auto-updater] Failed to download/install update:", message);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
@@ -208,73 +152,13 @@ async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promis
|
||||
export async function checkForAppUpdate({
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
intent,
|
||||
}: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
intent: AppUpdateCheckIntent;
|
||||
}): Promise<AppUpdateCheckResult> {
|
||||
if (!app.isPackaged) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
|
||||
configureAutoUpdater(releaseChannel);
|
||||
|
||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
||||
info: cachedUpdateInfo,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
|
||||
if (!result || !result.updateInfo) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
|
||||
const info = result.updateInfo;
|
||||
const latestVersion = info.version;
|
||||
const hasUpdate = latestVersion !== currentVersion;
|
||||
|
||||
if (hasUpdate) {
|
||||
cachedUpdateInfo = info;
|
||||
downloading = !isReadyToInstallVersion(latestVersion);
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
readyToInstall: isReadyToInstallVersion(latestVersion),
|
||||
info,
|
||||
});
|
||||
}
|
||||
|
||||
cachedUpdateInfo = null;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = false;
|
||||
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] Failed to check for updates:", error);
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent });
|
||||
}
|
||||
|
||||
export async function downloadAndInstallUpdate(
|
||||
@@ -287,63 +171,8 @@ export async function downloadAndInstallUpdate(
|
||||
},
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<AppUpdateInstallResult> {
|
||||
if (!app.isPackaged) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Auto-update is not available in development mode.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!cachedUpdateInfo) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "No update available. Check for updates first.",
|
||||
};
|
||||
}
|
||||
|
||||
configureAutoUpdater(releaseChannel);
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Update is still being prepared. Try again in a moment.",
|
||||
};
|
||||
}
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
downloadedUpdateVersion = readyVersion;
|
||||
downloading = false;
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
} catch (error) {
|
||||
downloading = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[auto-updater] Failed to download/install update:", message);
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: `Update failed: ${message}`,
|
||||
};
|
||||
}
|
||||
return appUpdateService.downloadAndInstallUpdate(
|
||||
{ currentVersion, releaseChannel },
|
||||
onBeforeQuit,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,45 @@ describe("desktop editor targets", () => {
|
||||
expect(recorder.calls[0]?.args).toEqual(["-R", "/tmp/repo/src/index.ts"]);
|
||||
});
|
||||
|
||||
it("reveals files in Explorer on Windows", async () => {
|
||||
it("opens the workspace directory in Explorer using Windows path separators", async () => {
|
||||
const recorder = createSpawnRecorder();
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "explorer", path: "C:/Users/me/project" },
|
||||
{
|
||||
platform: "win32",
|
||||
env: { PATH: "C:/Windows" },
|
||||
existsSync: createExistsSync(["C:/Users/me/project", "C:/Windows/explorer.exe"]),
|
||||
spawn: recorder.spawn,
|
||||
},
|
||||
);
|
||||
|
||||
// explorer.exe reads each "/segment" of a forward-slash arg as a switch and
|
||||
// falls back to the Documents folder. The path must use backslashes.
|
||||
expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe");
|
||||
expect(recorder.calls[0]?.args).toEqual(["C:\\Users\\me\\project"]);
|
||||
expect(recorder.calls[0]?.options.shell).toBe(false);
|
||||
});
|
||||
|
||||
it("opens UNC workspace directories in Explorer using Windows path separators", async () => {
|
||||
const recorder = createSpawnRecorder();
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "explorer", path: "//server/share/project" },
|
||||
{
|
||||
platform: "win32",
|
||||
env: { PATH: "C:/Windows" },
|
||||
existsSync: createExistsSync(["//server/share/project", "C:/Windows/explorer.exe"]),
|
||||
spawn: recorder.spawn,
|
||||
},
|
||||
);
|
||||
|
||||
// UNC paths must become \\server\share\... — the leading // is preserved by
|
||||
// the app's path normalization and explorer accepts the backslash form.
|
||||
expect(recorder.calls[0]?.args).toEqual(["\\\\server\\share\\project"]);
|
||||
});
|
||||
|
||||
it("reveals files in Explorer on Windows using Windows path separators", async () => {
|
||||
const recorder = createSpawnRecorder();
|
||||
|
||||
await openEditorTarget(
|
||||
@@ -176,7 +214,7 @@ describe("desktop editor targets", () => {
|
||||
);
|
||||
|
||||
expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe");
|
||||
expect(recorder.calls[0]?.args).toEqual(["/select,", "C:/repo/src/index.ts"]);
|
||||
expect(recorder.calls[0]?.args).toEqual(["/select,", "C:\\repo\\src\\index.ts"]);
|
||||
expect(recorder.calls[0]?.options.shell).toBe(false);
|
||||
});
|
||||
|
||||
@@ -196,9 +234,80 @@ describe("desktop editor targets", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Separators flip to backslashes; "&" stays literal and the path stays a
|
||||
// separate arg token (Node only quotes the path, not the "/select," switch).
|
||||
expect(recorder.calls[0]).toMatchObject({
|
||||
command: "C:/Windows/explorer.exe",
|
||||
args: ["/select,", "C:/repo/src/file & calculator.ts"],
|
||||
args: ["/select,", "C:\\repo\\src\\file & calculator.ts"],
|
||||
options: { shell: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not convert separators for editor targets on Windows", async () => {
|
||||
const recorder = createSpawnRecorder();
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "vscode", path: "C:/repo/src/index.ts", cwd: "C:/repo" },
|
||||
{
|
||||
platform: "win32",
|
||||
env: { PATH: "C:/Program Files/Microsoft VS Code/bin" },
|
||||
existsSync: createExistsSync([
|
||||
"C:/repo/src/index.ts",
|
||||
"C:/repo",
|
||||
"C:/Program Files/Microsoft VS Code/bin/code.exe",
|
||||
]),
|
||||
spawn: recorder.spawn,
|
||||
},
|
||||
);
|
||||
|
||||
// Editors accept forward slashes; the backslash conversion is Explorer-scoped.
|
||||
expect(recorder.calls[0]?.args).toEqual(["C:/repo", "C:/repo/src/index.ts"]);
|
||||
});
|
||||
|
||||
it("prefers Windows command shims over extensionless shell launchers", async () => {
|
||||
const recorder = createSpawnRecorder();
|
||||
const vscodeBin = "C:/Users/me/AppData/Local/Programs/Microsoft VS Code/bin";
|
||||
|
||||
await openEditorTarget(
|
||||
{
|
||||
editorId: "vscode",
|
||||
path: "C:/repo",
|
||||
},
|
||||
{
|
||||
platform: "win32",
|
||||
env: { PATH: vscodeBin },
|
||||
existsSync: createExistsSync(["C:/repo", `${vscodeBin}/code`, `${vscodeBin}/code.cmd`]),
|
||||
spawn: recorder.spawn,
|
||||
},
|
||||
);
|
||||
|
||||
expect(recorder.calls[0]).toMatchObject({
|
||||
command: `"${vscodeBin}/code.cmd"`,
|
||||
args: ["C:/repo"],
|
||||
options: { shell: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Windows extensionless fallback when no command shim exists", async () => {
|
||||
const recorder = createSpawnRecorder();
|
||||
const vscodeBin = "C:/Portable/VS Code/bin";
|
||||
|
||||
await openEditorTarget(
|
||||
{
|
||||
editorId: "vscode",
|
||||
path: "C:/repo",
|
||||
},
|
||||
{
|
||||
platform: "win32",
|
||||
env: { PATH: vscodeBin },
|
||||
existsSync: createExistsSync(["C:/repo", `${vscodeBin}/code`]),
|
||||
spawn: recorder.spawn,
|
||||
},
|
||||
);
|
||||
|
||||
expect(recorder.calls[0]).toMatchObject({
|
||||
command: `${vscodeBin}/code`,
|
||||
args: ["C:/repo"],
|
||||
options: { shell: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,16 +147,19 @@ function resolveExecutable(
|
||||
continue;
|
||||
}
|
||||
const candidate = `${directory}/${command}`;
|
||||
if (input.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (input.platform === "win32") {
|
||||
for (const extension of [".exe", ".cmd"]) {
|
||||
const hasExtension = Boolean(win32.extname(command));
|
||||
const extensions = hasExtension ? [""] : [".exe", ".cmd", ".bat", ".com", ""];
|
||||
for (const extension of extensions) {
|
||||
const windowsCandidate = `${candidate}${extension}`;
|
||||
if (input.existsSync(windowsCandidate)) {
|
||||
return windowsCandidate;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (input.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -187,6 +190,16 @@ function dirnameForPlatform(value: string, platform: NodeJS.Platform): string {
|
||||
return platform === "win32" ? win32.dirname(value) : posix.dirname(value);
|
||||
}
|
||||
|
||||
// App paths are normalized to forward slashes everywhere (see file-open), but
|
||||
// explorer.exe parses each "/segment" of an argument as a command-line switch.
|
||||
// Given a POSIX-style path it finds no path token and silently falls back to
|
||||
// the default shell folder (the user's Documents). Hand it native backslashes.
|
||||
// Only the argument needs converting — CreateProcess resolves the command path
|
||||
// fine with forward slashes.
|
||||
function toWindowsPathSeparators(value: string): string {
|
||||
return value.replace(/\//g, "\\");
|
||||
}
|
||||
|
||||
function isWindowsCommandScript(executable: string, platform: NodeJS.Platform): boolean {
|
||||
if (platform !== "win32") {
|
||||
return false;
|
||||
@@ -234,7 +247,7 @@ function buildLaunch(input: {
|
||||
return { command: input.executable, args: ["-R", input.path] };
|
||||
}
|
||||
if (input.target.id === "explorer" && input.platform === "win32") {
|
||||
return { command: input.executable, args: ["/select,", input.path] };
|
||||
return { command: input.executable, args: ["/select,", toWindowsPathSeparators(input.path)] };
|
||||
}
|
||||
if (input.target.id === "file-manager") {
|
||||
return { command: input.executable, args: [dirnameForPlatform(input.path, input.platform)] };
|
||||
@@ -244,6 +257,9 @@ function buildLaunch(input: {
|
||||
if (input.target.kind === "editor" && input.cwd && input.cwd !== input.path) {
|
||||
return { command: input.executable, args: [input.cwd, input.path] };
|
||||
}
|
||||
if (input.target.id === "explorer" && input.platform === "win32") {
|
||||
return { command: input.executable, args: [toWindowsPathSeparators(input.path)] };
|
||||
}
|
||||
return { command: input.executable, args: [input.path] };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SessionInboundMessageSchema } from "./messages.js";
|
||||
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
describe("list_commands_request schema", () => {
|
||||
test("accepts legacy agent-only payload", () => {
|
||||
@@ -49,4 +49,61 @@ describe("list_commands_request schema", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves command kind metadata in responses", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId: "agent-123",
|
||||
requestId: "req-123",
|
||||
error: null,
|
||||
commands: [
|
||||
{
|
||||
name: "taste",
|
||||
description: "Apply code taste",
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("list_commands_response");
|
||||
if (parsed.type !== "list_commands_response") {
|
||||
throw new Error("Expected list_commands_response message");
|
||||
}
|
||||
expect(parsed.payload.commands).toEqual([
|
||||
{
|
||||
name: "taste",
|
||||
description: "Apply code taste",
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("falls back to command for unknown future command kinds", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId: "agent-123",
|
||||
requestId: "req-123",
|
||||
error: null,
|
||||
commands: [
|
||||
{
|
||||
name: "future-command",
|
||||
description: "Future command kind",
|
||||
argumentHint: "",
|
||||
kind: "future-kind",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("list_commands_response");
|
||||
if (parsed.type !== "list_commands_response") {
|
||||
throw new Error("Expected list_commands_response message");
|
||||
}
|
||||
expect(parsed.payload.commands[0]?.kind).toBe("command");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3557,6 +3557,7 @@ const AgentSlashCommandSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
argumentHint: z.string(),
|
||||
kind: z.enum(["command", "skill"]).optional().catch("command"),
|
||||
});
|
||||
|
||||
export const ListCommandsResponseSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.93",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -59,10 +59,10 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/highlight": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/client": "0.1.93",
|
||||
"@getpaseo/highlight": "0.1.93",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/relay": "0.1.93",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
|
||||
import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
PersistedAgentDescriptor,
|
||||
ImportProviderSessionInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
@@ -48,6 +48,7 @@ function deferred<T>(): Deferred<T> {
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsSessionListing: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
@@ -63,30 +64,6 @@ function createFeature(args: { id: string; label: string; value: boolean }): Age
|
||||
};
|
||||
}
|
||||
|
||||
function createPersistedDescriptor(args: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
nativeHandle?: string;
|
||||
}): PersistedAgentDescriptor {
|
||||
return {
|
||||
provider: "codex",
|
||||
sessionId: args.sessionId,
|
||||
cwd: args.cwd,
|
||||
title: null,
|
||||
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: args.sessionId,
|
||||
nativeHandle: args.nativeHandle,
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
cwd: args.cwd,
|
||||
},
|
||||
},
|
||||
timeline: [],
|
||||
};
|
||||
}
|
||||
|
||||
function expectArchivedAgentRecord(
|
||||
record: StoredAgentRecord | null,
|
||||
expectedLastStatus: "closed" | "idle",
|
||||
@@ -1471,36 +1448,48 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
||||
});
|
||||
});
|
||||
|
||||
test("findPersistedAgent returns matching descriptors by session id or native handle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-find-persisted-"));
|
||||
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const session = new TestAgentSession({ provider: "codex", cwd: workdir });
|
||||
const events: AgentManagerEvent[] = [];
|
||||
|
||||
const descriptors: PersistedAgentDescriptor[] = [
|
||||
createPersistedDescriptor({
|
||||
cwd: workdir,
|
||||
sessionId: "session-direct",
|
||||
nativeHandle: "native-direct",
|
||||
}),
|
||||
createPersistedDescriptor({
|
||||
cwd: workdir,
|
||||
sessionId: "session-other",
|
||||
nativeHandle: "native-match",
|
||||
}),
|
||||
];
|
||||
class ImportClient extends TestAgentClient {
|
||||
listCalls = 0;
|
||||
importInput: unknown = null;
|
||||
|
||||
class PersistedAgentsClient extends TestAgentClient {
|
||||
lastLimit: number | undefined;
|
||||
lastCwd: string | undefined;
|
||||
async listImportableSessions() {
|
||||
this.listCalls += 1;
|
||||
return [];
|
||||
}
|
||||
|
||||
override async listPersistedAgents(options?: { limit?: number; cwd?: string }) {
|
||||
this.lastLimit = options?.limit;
|
||||
this.lastCwd = options?.cwd;
|
||||
return descriptors;
|
||||
async importSession(input: ImportProviderSessionInput) {
|
||||
this.importInput = input;
|
||||
return {
|
||||
session,
|
||||
config: { provider: "codex" as const, cwd: workdir },
|
||||
persistence: {
|
||||
provider: "codex" as const,
|
||||
sessionId: input.providerHandleId,
|
||||
nativeHandle: input.providerHandleId,
|
||||
metadata: { provider: "codex", cwd: workdir },
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
item: { type: "user_message" as const, text: "Trace provider imports" },
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
item: { type: "assistant_message" as const, text: "Done" },
|
||||
timestamp: "2026-01-02T00:00:01.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const client = new PersistedAgentsClient();
|
||||
const client = new ImportClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
@@ -1508,15 +1497,32 @@ test("findPersistedAgent returns matching descriptors by session id or native ha
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
manager.subscribe((event) => events.push(event), { replayState: false });
|
||||
|
||||
await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]);
|
||||
await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]);
|
||||
await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull();
|
||||
await expect(
|
||||
manager.findPersistedAgent("codex", "session-direct", { cwd: "/tmp/project" }),
|
||||
).resolves.toBe(descriptors[0]);
|
||||
expect(client.lastLimit).toBe(200);
|
||||
expect(client.lastCwd).toBe("/tmp/project");
|
||||
const imported = await manager.importProviderSession({
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-selected",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
expect(client.listCalls).toBe(0);
|
||||
expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir });
|
||||
expect(imported.lifecycle).toBe("idle");
|
||||
expect(imported.historyPrimed).toBe(true);
|
||||
expect(manager.getTimeline(imported.id)).toEqual([
|
||||
{ type: "user_message", text: "Trace provider imports" },
|
||||
{ type: "assistant_message", text: "Done" },
|
||||
]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "agent_state",
|
||||
agent: {
|
||||
id: imported.id,
|
||||
lifecycle: "idle",
|
||||
persistence: { nativeHandle: "thread-selected" },
|
||||
},
|
||||
});
|
||||
expect((await storage.get(imported.id))?.title).toBe("Trace provider imports");
|
||||
});
|
||||
|
||||
test("reloadAgentSession passes daemon launch env through the provider launch context", async () => {
|
||||
@@ -5711,17 +5717,16 @@ class RecordingPersistedAgentsClient implements AgentClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
async listPersistedAgents(): Promise<PersistedAgentDescriptor[]> {
|
||||
async listImportableSessions() {
|
||||
this.calls += 1;
|
||||
return [
|
||||
{
|
||||
provider: this.provider,
|
||||
sessionId: `${this.provider}-session`,
|
||||
providerHandleId: `${this.provider}-session`,
|
||||
cwd: "/tmp/recent",
|
||||
title: null,
|
||||
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
|
||||
persistence: { provider: this.provider, sessionId: `${this.provider}-session` },
|
||||
timeline: [],
|
||||
firstPromptPreview: null,
|
||||
lastPromptPreview: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -5738,7 +5743,7 @@ test.each([
|
||||
},
|
||||
],
|
||||
])(
|
||||
"listImportablePersistedAgents skips %s providers in fan-out",
|
||||
"listImportableSessions skips %s providers in fan-out",
|
||||
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
|
||||
const includedClient = new RecordingPersistedAgentsClient(includedProvider);
|
||||
const skippedClient = new RecordingPersistedAgentsClient(skippedProvider);
|
||||
@@ -5748,7 +5753,7 @@ test.each([
|
||||
logger,
|
||||
});
|
||||
|
||||
const result = await manager.listImportablePersistedAgents();
|
||||
const result = await manager.listImportableSessions();
|
||||
|
||||
expect(includedClient.calls).toBe(1);
|
||||
expect(skippedClient.calls).toBe(0);
|
||||
@@ -5756,7 +5761,7 @@ test.each([
|
||||
},
|
||||
);
|
||||
|
||||
test("listImportablePersistedAgents includes derived providers that list persisted agents", async () => {
|
||||
test("listImportableSessions includes derived providers that list persisted agents", async () => {
|
||||
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
||||
const ompClient = new RecordingPersistedAgentsClient("omp");
|
||||
const manager = new AgentManager({
|
||||
@@ -5768,14 +5773,14 @@ test("listImportablePersistedAgents includes derived providers that list persist
|
||||
logger,
|
||||
});
|
||||
|
||||
const result = await manager.listImportablePersistedAgents();
|
||||
const result = await manager.listImportableSessions();
|
||||
|
||||
expect(claudeClient.calls).toBe(1);
|
||||
expect(ompClient.calls).toBe(1);
|
||||
expect(result.map((d) => d.provider).sort()).toEqual(["claude", "omp"]);
|
||||
});
|
||||
|
||||
test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => {
|
||||
test("listImportableSessions narrows to the providerFilter when supplied", async () => {
|
||||
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
||||
const codexClient = new RecordingPersistedAgentsClient("codex");
|
||||
const manager = new AgentManager({
|
||||
@@ -5787,7 +5792,7 @@ test("listImportablePersistedAgents narrows to the providerFilter when supplied"
|
||||
logger,
|
||||
});
|
||||
|
||||
const result = await manager.listImportablePersistedAgents({
|
||||
const result = await manager.listImportableSessions({
|
||||
providerFilter: new Set(["claude"]),
|
||||
});
|
||||
|
||||
@@ -5796,6 +5801,33 @@ test("listImportablePersistedAgents narrows to the providerFilter when supplied"
|
||||
expect(result.map((d) => d.provider)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
test("listImportableSessions skips providers that lack supportsSessionListing even when row listing is defined", async () => {
|
||||
const listableClient = new RecordingPersistedAgentsClient("claude");
|
||||
const nonListableClient = new RecordingPersistedAgentsClient("acp");
|
||||
// Override capabilities to remove session listing support
|
||||
Object.defineProperty(nonListableClient, "capabilities", {
|
||||
value: {
|
||||
...TEST_CAPABILITIES,
|
||||
supportsSessionListing: false,
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { claude: listableClient, acp: nonListableClient },
|
||||
providerDefinitions: {
|
||||
claude: { enabled: true, derivedFromProviderId: null },
|
||||
acp: { enabled: true, derivedFromProviderId: null },
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
const result = await manager.listImportableSessions();
|
||||
|
||||
expect(listableClient.calls).toBe(1);
|
||||
expect(nonListableClient.calls).toBe(0);
|
||||
expect(result.map((d) => d.provider)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -33,8 +33,9 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ListPersistedAgentsOptions,
|
||||
type PersistedAgentDescriptor,
|
||||
type ImportedTimelineEntry,
|
||||
type ImportableProviderSession,
|
||||
type ListImportableSessionsOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
@@ -57,6 +58,7 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||
|
||||
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||
@@ -146,7 +148,7 @@ interface HydrateTimelineOptions {
|
||||
broadcast?: boolean;
|
||||
}
|
||||
|
||||
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
|
||||
export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & {
|
||||
/**
|
||||
* When set, only providers in this set are scanned, in addition to the
|
||||
* built-in importable allowlist + enabled + non-derived rules.
|
||||
@@ -154,6 +156,10 @@ export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions &
|
||||
providerFilter?: Set<string>;
|
||||
};
|
||||
|
||||
export interface ManagedImportableProviderSession extends ImportableProviderSession {
|
||||
provider: AgentProvider;
|
||||
}
|
||||
|
||||
export type AgentAttentionCallback = (params: {
|
||||
agentId: string;
|
||||
provider: AgentProvider;
|
||||
@@ -415,6 +421,50 @@ function buildExplicitTimelineSeedForRegister(
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] {
|
||||
const rows: AgentTimelineRow[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.item.type === "user_message" && isSystemInjectedEnvelope(entry.item.text)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
seq: rows.length + 1,
|
||||
timestamp: entry.timestamp ?? new Date().toISOString(),
|
||||
item: entry.item,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function resolveImportedAgentTitle(
|
||||
config: AgentSessionConfig,
|
||||
timelineRows: readonly AgentTimelineRow[],
|
||||
): string | null {
|
||||
const initialPrompt = getFirstUserMessageTextFromRows(timelineRows);
|
||||
if (!initialPrompt) {
|
||||
return null;
|
||||
}
|
||||
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
|
||||
configTitle: config.title,
|
||||
initialPrompt,
|
||||
});
|
||||
return explicitTitle ?? provisionalTitle ?? null;
|
||||
}
|
||||
|
||||
function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): string | null {
|
||||
for (const row of rows) {
|
||||
const item = row.item;
|
||||
if (item.type !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
const text = item.text.trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
@@ -612,34 +662,37 @@ export class AgentManager {
|
||||
.map((agent) => Object.assign({}, agent));
|
||||
}
|
||||
|
||||
async listImportablePersistedAgents(
|
||||
async listImportableSessions(
|
||||
options?: ImportablePersistedAgentQueryOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
): Promise<ManagedImportableProviderSession[]> {
|
||||
const providerEntries = Array.from(this.clients.entries()).filter(
|
||||
([provider, client]) =>
|
||||
!!client.listPersistedAgents &&
|
||||
client.capabilities.supportsSessionListing &&
|
||||
!!client.listImportableSessions &&
|
||||
this.isProviderImportable(provider, options?.providerFilter),
|
||||
);
|
||||
const descriptorLists = await Promise.all(
|
||||
const sessionLists = await Promise.all(
|
||||
providerEntries.map(async ([provider, client]) => {
|
||||
try {
|
||||
return await client.listPersistedAgents!({
|
||||
limit: options?.limit,
|
||||
cwd: options?.cwd,
|
||||
});
|
||||
return (
|
||||
await client.listImportableSessions!({
|
||||
limit: options?.limit,
|
||||
cwd: options?.cwd,
|
||||
})
|
||||
).map((session) => Object.assign(session, { provider }));
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, provider },
|
||||
"Failed to list persisted agents for provider",
|
||||
"Failed to list importable sessions for provider",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const descriptors: PersistedAgentDescriptor[] = descriptorLists.flat();
|
||||
const sessions: ManagedImportableProviderSession[] = sessionLists.flat();
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
return descriptors
|
||||
return sessions
|
||||
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -657,26 +710,6 @@ export class AgentManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
async findPersistedAgent(
|
||||
provider: AgentProvider,
|
||||
sessionId: string,
|
||||
options?: Pick<ListPersistedAgentsOptions, "cwd">,
|
||||
): Promise<PersistedAgentDescriptor | null> {
|
||||
const client = this.requireClient(provider);
|
||||
if (!client.listPersistedAgents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const descriptors = await client.listPersistedAgents({ limit: 200, cwd: options?.cwd });
|
||||
return (
|
||||
descriptors.find((descriptor) => {
|
||||
return (
|
||||
descriptor.sessionId === sessionId || descriptor.persistence.nativeHandle === sessionId
|
||||
);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async listProviderAvailability(): Promise<ProviderAvailability[]> {
|
||||
const checks = Array.from(this.clients.keys()).map(async (provider) => {
|
||||
const client = this.clients.get(provider);
|
||||
@@ -871,6 +904,50 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, options);
|
||||
}
|
||||
|
||||
async importProviderSession(input: {
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
|
||||
this.requireEnabledProvider(input.provider);
|
||||
|
||||
const client = await this.requireAvailableClient({ provider: input.provider });
|
||||
if (!client.importSession) {
|
||||
throw new Error(`Provider '${input.provider}' does not support importing sessions`);
|
||||
}
|
||||
|
||||
const { storedConfig, launchConfig } = await this.prepareSessionConfig(
|
||||
{
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
resolvedAgentId,
|
||||
);
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const imported = await client.importSession(
|
||||
{
|
||||
providerHandleId: input.providerHandleId,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
{ config: launchConfig, storedConfig, launchContext },
|
||||
);
|
||||
const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config));
|
||||
const timelineRows = buildImportedTimelineRows(imported.timeline);
|
||||
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
|
||||
|
||||
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||
labels: input.labels,
|
||||
timelineRows,
|
||||
timelineNextSeq: timelineRows.length + 1,
|
||||
persistence: imported.persistence,
|
||||
historyPrimed: true,
|
||||
initialTitle,
|
||||
publishWhenReady: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Hot-reload an active agent session with config overrides. By default the
|
||||
// in-memory timeline is preserved (used for voice-mode toggles and similar
|
||||
// config swaps). When `rehydrateFromDisk` is set, the timeline is wiped so a
|
||||
@@ -2236,11 +2313,13 @@ export class AgentManager {
|
||||
timeline?: AgentTimelineItem[];
|
||||
timelineRows?: AgentTimelineRow[];
|
||||
timelineNextSeq?: number;
|
||||
persistence?: AgentPersistenceHandle;
|
||||
historyPrimed?: boolean;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
attention?: AttentionState;
|
||||
initialTitle?: string | null;
|
||||
publishWhenReady?: boolean;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||
@@ -2272,14 +2351,16 @@ export class AgentManager {
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
// Initialize previousStatus to track transitions
|
||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||
await this.refreshRuntimeInfo(managed);
|
||||
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
|
||||
await this.persistSnapshot(managed, {
|
||||
workspaceId: options?.workspaceId,
|
||||
title: initialPersistedTitle,
|
||||
});
|
||||
this.emitState(managed, { persist: false });
|
||||
if (!options?.publishWhenReady) {
|
||||
this.emitState(managed, { persist: false });
|
||||
}
|
||||
|
||||
await this.refreshSessionState(managed);
|
||||
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
|
||||
managed.lifecycle = "idle";
|
||||
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId });
|
||||
this.emitState(managed, { persist: false });
|
||||
@@ -2295,6 +2376,7 @@ export class AgentManager {
|
||||
timeline?: AgentTimelineItem[];
|
||||
timelineRows?: AgentTimelineRow[];
|
||||
timelineNextSeq?: number;
|
||||
persistence?: AgentPersistenceHandle;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
@@ -2337,6 +2419,7 @@ export class AgentManager {
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
attention?: AttentionState;
|
||||
persistence?: AgentPersistenceHandle;
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
@@ -2362,7 +2445,10 @@ export class AgentManager {
|
||||
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
||||
finalizedForegroundTurnIds: new Set<string>(),
|
||||
unsubscribeSession: null,
|
||||
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd),
|
||||
persistence: attachPersistenceCwd(
|
||||
options?.persistence ?? session.describePersistence(),
|
||||
config.cwd,
|
||||
),
|
||||
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
|
||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||
lastUsage: options?.lastUsage,
|
||||
@@ -2560,7 +2646,10 @@ export class AgentManager {
|
||||
return this.registry;
|
||||
}
|
||||
|
||||
private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> {
|
||||
private async refreshSessionState(
|
||||
agent: ActiveManagedAgent,
|
||||
options?: { emit?: boolean },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const modes = await agent.session.getAvailableModes();
|
||||
agent.availableModes = modes;
|
||||
@@ -2582,10 +2671,13 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
this.syncFeaturesFromSession(agent);
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
await this.refreshRuntimeInfo(agent, options);
|
||||
}
|
||||
|
||||
private async refreshRuntimeInfo(agent: ActiveManagedAgent): Promise<void> {
|
||||
private async refreshRuntimeInfo(
|
||||
agent: ActiveManagedAgent,
|
||||
options?: { emit?: boolean },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const newInfo = await agent.session.getRuntimeInfo();
|
||||
const changed =
|
||||
@@ -2601,7 +2693,7 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
// Emit state if runtimeInfo changed so clients get the updated model
|
||||
if (changed) {
|
||||
if (changed && options?.emit !== false) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
import type { AgentSession } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentFeature,
|
||||
ImportableProviderSession,
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
AgentSessionConfig,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
|
||||
@@ -400,26 +400,18 @@ describe("toAgentPayload", () => {
|
||||
});
|
||||
|
||||
describe("toRecentProviderSessionDescriptorPayload", () => {
|
||||
it("projects persisted descriptors to provider-opaque public recent sessions", () => {
|
||||
const descriptor: PersistedAgentDescriptor = {
|
||||
it("projects provider import rows to provider-opaque public recent sessions", () => {
|
||||
const session: ImportableProviderSession & { provider: string } = {
|
||||
provider: "codex-custom",
|
||||
sessionId: "legacy-session-id",
|
||||
providerHandleId: "provider-native-handle",
|
||||
cwd: "/tmp/project",
|
||||
title: "Import me",
|
||||
firstPromptPreview: "First prompt with spacing",
|
||||
lastPromptPreview: "Second prompt",
|
||||
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
|
||||
persistence: {
|
||||
provider: "codex-custom",
|
||||
sessionId: "legacy-session-id",
|
||||
nativeHandle: "provider-native-handle",
|
||||
},
|
||||
timeline: [
|
||||
{ type: "assistant_message", text: "Ready" },
|
||||
{ type: "user_message", text: " First prompt\n\nwith spacing " },
|
||||
{ type: "user_message", text: "Second prompt" },
|
||||
],
|
||||
};
|
||||
|
||||
const payload = toRecentProviderSessionDescriptorPayload(descriptor, {
|
||||
const payload = toRecentProviderSessionDescriptorPayload(session, {
|
||||
providerLabel: "Custom Codex",
|
||||
});
|
||||
|
||||
@@ -438,28 +430,25 @@ describe("toRecentProviderSessionDescriptorPayload", () => {
|
||||
expect(payload).not.toHaveProperty("nativeHandle");
|
||||
});
|
||||
|
||||
it("falls back to persistence session id when no provider native handle exists", () => {
|
||||
const descriptor: PersistedAgentDescriptor = {
|
||||
it("preserves null prompt previews", () => {
|
||||
const session: ImportableProviderSession & { provider: string } = {
|
||||
provider: "claude-custom",
|
||||
sessionId: "descriptor-session-id",
|
||||
providerHandleId: "provider-session-id",
|
||||
cwd: "/tmp/project",
|
||||
title: null,
|
||||
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
|
||||
persistence: {
|
||||
provider: "claude-custom",
|
||||
sessionId: "persistence-session-id",
|
||||
},
|
||||
timeline: [],
|
||||
firstPromptPreview: null,
|
||||
lastPromptPreview: null,
|
||||
};
|
||||
|
||||
expect(
|
||||
toRecentProviderSessionDescriptorPayload(descriptor, {
|
||||
toRecentProviderSessionDescriptorPayload(session, {
|
||||
providerLabel: "Custom Claude",
|
||||
}),
|
||||
).toMatchObject({
|
||||
providerId: "claude-custom",
|
||||
providerLabel: "Custom Claude",
|
||||
providerHandleId: "persistence-session-id",
|
||||
providerHandleId: "provider-session-id",
|
||||
firstPromptPreview: null,
|
||||
lastPromptPreview: null,
|
||||
});
|
||||
|
||||
@@ -14,9 +14,8 @@ import type {
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
AgentRuntimeInfo,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
PersistedAgentDescriptor,
|
||||
ImportableProviderSession,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { JsonValue } from "../json-utils.js";
|
||||
@@ -33,8 +32,6 @@ interface RecentProviderSessionProjectionOptions {
|
||||
providerLabel: string;
|
||||
}
|
||||
|
||||
const PROMPT_PREVIEW_MAX_LENGTH = 160;
|
||||
|
||||
function normalizeThinkingOptionId(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
@@ -263,20 +260,18 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
|
||||
}
|
||||
|
||||
export function toRecentProviderSessionDescriptorPayload(
|
||||
descriptor: PersistedAgentDescriptor,
|
||||
session: ImportableProviderSession & { provider: string },
|
||||
options: RecentProviderSessionProjectionOptions,
|
||||
): RecentProviderSessionDescriptorPayload {
|
||||
const promptPreviews = collectPromptPreviews(descriptor.timeline);
|
||||
|
||||
return {
|
||||
providerId: descriptor.provider,
|
||||
providerId: session.provider,
|
||||
providerLabel: options.providerLabel,
|
||||
providerHandleId: descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId,
|
||||
cwd: descriptor.cwd,
|
||||
title: descriptor.title,
|
||||
firstPromptPreview: promptPreviews[0] ?? null,
|
||||
lastPromptPreview: promptPreviews.at(-1) ?? null,
|
||||
lastActivityAt: descriptor.lastActivityAt.toISOString(),
|
||||
providerHandleId: session.providerHandleId,
|
||||
cwd: session.cwd,
|
||||
title: session.title,
|
||||
firstPromptPreview: session.firstPromptPreview,
|
||||
lastPromptPreview: session.lastPromptPreview,
|
||||
lastActivityAt: session.lastActivityAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -297,26 +292,6 @@ export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): s
|
||||
return timestamps[0].raw;
|
||||
}
|
||||
|
||||
function collectPromptPreviews(timeline: readonly AgentTimelineItem[]): string[] {
|
||||
return timeline.flatMap((item) => {
|
||||
if (item.type !== "user_message") {
|
||||
return [];
|
||||
}
|
||||
const preview = normalizePromptPreview(item.text);
|
||||
return preview ? [preview] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePromptPreview(text: string): string | null {
|
||||
const normalized = text.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return normalized.length > PROMPT_PREVIEW_MAX_LENGTH
|
||||
? normalized.slice(0, PROMPT_PREVIEW_MAX_LENGTH)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||
const serializable: SerializableAgentConfig = {};
|
||||
if (config.modeId) {
|
||||
|
||||
@@ -162,6 +162,7 @@ export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||
export interface AgentCapabilityFlags {
|
||||
supportsStreaming: boolean;
|
||||
supportsSessionPersistence: boolean;
|
||||
supportsSessionListing?: boolean;
|
||||
supportsDynamicModes: boolean;
|
||||
supportsMcpServers: boolean;
|
||||
supportsReasoningStream: boolean;
|
||||
@@ -481,6 +482,8 @@ export interface AgentRuntimeInfo {
|
||||
extra?: AgentMetadata;
|
||||
}
|
||||
|
||||
export type AgentSlashCommandKind = "command" | "skill";
|
||||
|
||||
/**
|
||||
* Represents a slash command available in an agent session.
|
||||
* Commands are executed by sending them as prompts with / prefix.
|
||||
@@ -489,27 +492,48 @@ export interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
kind?: AgentSlashCommandKind;
|
||||
}
|
||||
|
||||
export interface ListPersistedAgentsOptions {
|
||||
export interface ListImportableSessionsOptions {
|
||||
limit?: number;
|
||||
/**
|
||||
* Optional cwd hint. Providers that can cheaply pre-filter persisted
|
||||
* sessions by working directory should do so before doing expensive
|
||||
* work like fetching turn timelines. Providers that can't filter
|
||||
* cheaply may ignore this hint.
|
||||
* Optional cwd hint. Providers that can cheaply pre-filter importable
|
||||
* sessions by working directory should do so before doing expensive work.
|
||||
*/
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface PersistedAgentDescriptor {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
export interface ImportableProviderSession {
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
firstPromptPreview: string | null;
|
||||
lastPromptPreview: string | null;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
export interface ImportProviderSessionInput {
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface ImportProviderSessionContext {
|
||||
config: AgentSessionConfig;
|
||||
storedConfig: AgentSessionConfig;
|
||||
launchContext?: AgentLaunchContext;
|
||||
}
|
||||
|
||||
export interface ImportedTimelineEntry {
|
||||
item: AgentTimelineItem;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ImportedProviderSession {
|
||||
session: AgentSession;
|
||||
config: AgentSessionConfig;
|
||||
persistence: AgentPersistenceHandle;
|
||||
timeline: AgentTimelineItem[];
|
||||
timeline: ImportedTimelineEntry[];
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
@@ -637,7 +661,13 @@ export interface AgentClient {
|
||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
listImportableSessions?(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
importSession?(
|
||||
input: ImportProviderSessionInput,
|
||||
context: ImportProviderSessionContext,
|
||||
): Promise<ImportedProviderSession>;
|
||||
/**
|
||||
* Check if this provider is available (CLI binary is installed).
|
||||
* Returns true if available, false otherwise.
|
||||
|
||||
@@ -2,10 +2,14 @@ import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type {
|
||||
AgentManager,
|
||||
ManagedAgent,
|
||||
ManagedImportableProviderSession,
|
||||
} from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { FetchRecentProviderSessionsRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentTimelineItem, PersistedAgentDescriptor } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import {
|
||||
ImportSessionsRequestError,
|
||||
importProviderSession,
|
||||
@@ -28,7 +32,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeDescriptor(args: {
|
||||
function makeImportableSession(args: {
|
||||
provider?: string;
|
||||
sessionId: string;
|
||||
nativeHandle?: string;
|
||||
@@ -37,25 +41,17 @@ function makeDescriptor(args: {
|
||||
lastActivityAt: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
}): PersistedAgentDescriptor {
|
||||
}): ManagedImportableProviderSession {
|
||||
const provider = args.provider ?? "codex";
|
||||
const cwd = args.cwd ?? "/tmp/project";
|
||||
return {
|
||||
provider,
|
||||
sessionId: args.sessionId,
|
||||
providerHandleId: args.nativeHandle ?? args.sessionId,
|
||||
cwd,
|
||||
title: args.title ?? null,
|
||||
lastActivityAt: new Date(args.lastActivityAt),
|
||||
persistence: {
|
||||
provider,
|
||||
sessionId: args.sessionId,
|
||||
...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
|
||||
metadata: { provider, cwd },
|
||||
},
|
||||
timeline: [
|
||||
...(args.firstPrompt ? [{ type: "user_message" as const, text: args.firstPrompt }] : []),
|
||||
...(args.lastPrompt ? [{ type: "user_message" as const, text: args.lastPrompt }] : []),
|
||||
],
|
||||
firstPromptPreview: args.firstPrompt ?? null,
|
||||
lastPromptPreview: args.lastPrompt ?? args.firstPrompt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,15 +110,15 @@ function makeRequest(
|
||||
|
||||
test("listImportableProviderSessions filters, sorts, limits, and projects importable sessions", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const descriptors = [
|
||||
makeDescriptor({
|
||||
const sessions = [
|
||||
makeImportableSession({
|
||||
sessionId: "outside-cwd",
|
||||
nativeHandle: "outside-cwd-handle",
|
||||
cwd: "/tmp/elsewhere",
|
||||
title: "Outside cwd",
|
||||
lastActivityAt: "2026-04-30T12:05:00.000Z",
|
||||
}),
|
||||
makeDescriptor({
|
||||
makeImportableSession({
|
||||
sessionId: "stored-session",
|
||||
nativeHandle: "stored-handle",
|
||||
cwd,
|
||||
@@ -130,14 +126,14 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
lastActivityAt: "2026-04-30T12:04:00.000Z",
|
||||
firstPrompt: "stored prompt",
|
||||
}),
|
||||
makeDescriptor({
|
||||
makeImportableSession({
|
||||
sessionId: "older-session",
|
||||
nativeHandle: "older-handle",
|
||||
cwd,
|
||||
title: "Older than since",
|
||||
lastActivityAt: "2026-04-29T23:59:59.000Z",
|
||||
}),
|
||||
makeDescriptor({
|
||||
makeImportableSession({
|
||||
sessionId: "newer-session",
|
||||
nativeHandle: "newer-handle",
|
||||
cwd,
|
||||
@@ -146,7 +142,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
firstPrompt: "newer first prompt",
|
||||
lastPrompt: "newer last prompt",
|
||||
}),
|
||||
makeDescriptor({
|
||||
makeImportableSession({
|
||||
sessionId: "second-session",
|
||||
nativeHandle: "second-handle",
|
||||
cwd,
|
||||
@@ -154,7 +150,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
||||
firstPrompt: "second prompt",
|
||||
}),
|
||||
makeDescriptor({
|
||||
makeImportableSession({
|
||||
sessionId: "third-session",
|
||||
nativeHandle: "third-handle",
|
||||
cwd,
|
||||
@@ -162,7 +158,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
lastActivityAt: "2026-04-30T11:59:00.000Z",
|
||||
firstPrompt: "third prompt",
|
||||
}),
|
||||
makeDescriptor({
|
||||
makeImportableSession({
|
||||
sessionId: "live-session",
|
||||
nativeHandle: "live-handle",
|
||||
cwd,
|
||||
@@ -171,7 +167,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
firstPrompt: "live prompt",
|
||||
}),
|
||||
];
|
||||
const listImportablePersistedAgents = vi.fn(async () => descriptors);
|
||||
const listImportableSessions = vi.fn(async () => sessions);
|
||||
const agentManager = {
|
||||
listAgents: () =>
|
||||
[
|
||||
@@ -184,8 +180,8 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
},
|
||||
},
|
||||
] as ManagedAgent[],
|
||||
listImportablePersistedAgents,
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">;
|
||||
listImportableSessions,
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
|
||||
const agentStorage = {
|
||||
list: async () => [
|
||||
{
|
||||
@@ -211,7 +207,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
providerSnapshotManager: { getProviderLabel: () => "Codex" },
|
||||
});
|
||||
|
||||
expect(listImportablePersistedAgents).toHaveBeenCalledWith({
|
||||
expect(listImportableSessions).toHaveBeenCalledWith({
|
||||
limit: 2,
|
||||
providerFilter: new Set(["codex"]),
|
||||
cwd,
|
||||
@@ -245,8 +241,8 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
|
||||
test("listImportableProviderSessions filters out metadata generation sessions", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const descriptors = [
|
||||
makeDescriptor({
|
||||
const sessions = [
|
||||
makeImportableSession({
|
||||
sessionId: "metadata-session",
|
||||
nativeHandle: "metadata-handle",
|
||||
cwd,
|
||||
@@ -255,7 +251,7 @@ test("listImportableProviderSessions filters out metadata generation sessions",
|
||||
firstPrompt:
|
||||
"Generate metadata for a coding agent based on the user prompt.\nTitle: short descriptive label (<= 40 chars).",
|
||||
}),
|
||||
makeDescriptor({
|
||||
makeImportableSession({
|
||||
sessionId: "real-session",
|
||||
nativeHandle: "real-handle",
|
||||
cwd,
|
||||
@@ -269,8 +265,8 @@ test("listImportableProviderSessions filters out metadata generation sessions",
|
||||
request: makeRequest({ cwd, providers: ["codex"] }),
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
listImportablePersistedAgents: async () => descriptors,
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
|
||||
listImportableSessions: async () => sessions,
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
@@ -294,8 +290,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
|
||||
request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }),
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
listImportablePersistedAgents: async () => [
|
||||
makeDescriptor({
|
||||
listImportableSessions: async () => [
|
||||
makeImportableSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session",
|
||||
nativeHandle: "pi-handle",
|
||||
@@ -305,7 +301,7 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
|
||||
firstPrompt: "remember this",
|
||||
}),
|
||||
],
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
@@ -321,8 +317,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
|
||||
request: makeRequest({ since: "not-a-date" }),
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
listImportablePersistedAgents: async () => [],
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
|
||||
listImportableSessions: async () => [],
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
@@ -361,7 +357,7 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes",
|
||||
});
|
||||
});
|
||||
|
||||
test("importProviderSession resumes by provider handle, hydrates the timeline, and applies title metadata", async () => {
|
||||
test("importProviderSession imports a selected provider session without listing", async () => {
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
|
||||
@@ -375,22 +371,9 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a
|
||||
nativeHandle: "provider-thread-imported",
|
||||
title: null,
|
||||
});
|
||||
const descriptor = makeDescriptor({
|
||||
provider: "custom-codex",
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "provider-thread-imported",
|
||||
cwd,
|
||||
title: null,
|
||||
firstPrompt: "Trace recent provider sessions",
|
||||
lastActivityAt: "2026-04-30T00:00:00.000Z",
|
||||
});
|
||||
const agentManager = {
|
||||
findPersistedAgent: vi.fn().mockResolvedValue(descriptor),
|
||||
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
importProviderSession: vi.fn().mockResolvedValue(snapshot),
|
||||
getTimeline: vi.fn().mockReturnValue(timeline),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAgentState: vi.fn(),
|
||||
} as unknown as AgentManager;
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
@@ -411,19 +394,12 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a
|
||||
deps: { scheduleAgentMetadataGeneration },
|
||||
});
|
||||
|
||||
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
|
||||
"custom-codex",
|
||||
"provider-thread-imported",
|
||||
{ cwd },
|
||||
);
|
||||
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
|
||||
descriptor.persistence,
|
||||
{ cwd },
|
||||
undefined,
|
||||
{ labels: undefined },
|
||||
);
|
||||
expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id);
|
||||
expect(agentManager.setTitle).toHaveBeenCalledWith(snapshot.id, "Trace recent provider sessions");
|
||||
expect(agentManager.importProviderSession).toHaveBeenCalledWith({
|
||||
provider: "custom-codex",
|
||||
providerHandleId: "provider-thread-imported",
|
||||
cwd,
|
||||
labels: undefined,
|
||||
});
|
||||
expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentManager,
|
||||
@@ -436,7 +412,7 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a
|
||||
expect(result).toEqual({ snapshot, timelineSize: 2 });
|
||||
});
|
||||
|
||||
test("importProviderSession builds a fallback handle when a non-OpenCode provider has no descriptor", async () => {
|
||||
test("importProviderSession passes labels through the manager import operation", async () => {
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const snapshot = makeManagedAgent({
|
||||
provider: "codex",
|
||||
@@ -445,12 +421,8 @@ test("importProviderSession builds a fallback handle when a non-OpenCode provide
|
||||
nativeHandle: "thread-imported",
|
||||
});
|
||||
const agentManager = {
|
||||
findPersistedAgent: vi.fn().mockResolvedValue(null),
|
||||
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
importProviderSession: vi.fn().mockResolvedValue(snapshot),
|
||||
getTimeline: vi.fn().mockReturnValue([]),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAgentState: vi.fn(),
|
||||
} as unknown as AgentManager;
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
@@ -463,29 +435,23 @@ test("importProviderSession builds a fallback handle when a non-OpenCode provide
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-imported",
|
||||
cwd,
|
||||
labels: { source: "import" },
|
||||
},
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
});
|
||||
|
||||
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "codex",
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "thread-imported",
|
||||
metadata: { provider: "codex", cwd },
|
||||
},
|
||||
{ cwd },
|
||||
undefined,
|
||||
{ labels: undefined },
|
||||
);
|
||||
expect(agentManager.importProviderSession).toHaveBeenCalledWith({
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-imported",
|
||||
cwd,
|
||||
labels: { source: "import" },
|
||||
});
|
||||
});
|
||||
|
||||
test("importProviderSession requires cwd for missing OpenCode descriptors", async () => {
|
||||
const agentManager = {
|
||||
findPersistedAgent: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentManager;
|
||||
test("importProviderSession requires cwd from the selected provider row", async () => {
|
||||
const agentManager = {} as unknown as AgentManager;
|
||||
|
||||
await expect(
|
||||
importProviderSession({
|
||||
@@ -498,7 +464,5 @@ test("importProviderSession requires cwd for missing OpenCode descriptors", asyn
|
||||
agentStorage: { list: vi.fn() } as unknown as AgentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
|
||||
);
|
||||
).rejects.toThrow("Import requires cwd from the selected provider session");
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type {
|
||||
AgentManager,
|
||||
ManagedAgent,
|
||||
ManagedImportableProviderSession,
|
||||
} from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
AgentTimelineItem,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type { StructuredGenerationDaemonConfig } from "./structured-generation-providers.js";
|
||||
@@ -29,7 +31,7 @@ const METADATA_GENERATION_PROMPT_PREFIX =
|
||||
"Generate metadata for a coding agent based on the user prompt.";
|
||||
|
||||
export interface NormalizedImportAgentRequest {
|
||||
provider: string;
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd?: string;
|
||||
labels?: Record<string, string>;
|
||||
@@ -48,7 +50,7 @@ export class ImportSessionsRequestError extends Error {
|
||||
|
||||
export interface ListImportableProviderSessionsInput {
|
||||
request: FetchRecentProviderSessionsRequestMessage;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
|
||||
agentStorage: Pick<AgentStorage, "list">;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
|
||||
}
|
||||
@@ -91,7 +93,7 @@ export function normalizeImportAgentRequest(
|
||||
return { error: "Import requires providerId and providerHandleId" };
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
provider: provider as AgentProvider,
|
||||
providerHandleId,
|
||||
cwd: msg.cwd,
|
||||
labels: msg.labels,
|
||||
@@ -108,34 +110,31 @@ export async function listImportableProviderSessions(
|
||||
const providerFilter = request.providers ? new Set(request.providers) : undefined;
|
||||
const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage);
|
||||
|
||||
const descriptors = await agentManager.listImportablePersistedAgents({
|
||||
const sessions = await agentManager.listImportableSessions({
|
||||
limit,
|
||||
providerFilter,
|
||||
cwd: request.cwd,
|
||||
});
|
||||
let filteredAlreadyImportedCount = 0;
|
||||
const candidates: PersistedAgentDescriptor[] = [];
|
||||
const candidates: ManagedImportableProviderSession[] = [];
|
||||
const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null;
|
||||
for (const descriptor of descriptors) {
|
||||
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) {
|
||||
for (const session of sessions) {
|
||||
if (matchesRequestCwd && !matchesRequestCwd(session.cwd)) {
|
||||
continue;
|
||||
}
|
||||
if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) {
|
||||
if (sinceTimestamp !== null && session.lastActivityAt.getTime() < sinceTimestamp) {
|
||||
continue;
|
||||
}
|
||||
if (isMetadataGenerationDescriptor(descriptor)) {
|
||||
if (isMetadataGenerationSession(session)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasUserPrompt(descriptor)) {
|
||||
continue;
|
||||
}
|
||||
const providerHandleId =
|
||||
descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId;
|
||||
if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) {
|
||||
if (
|
||||
importedHandles.has(toProviderSessionHandleKey(session.provider, session.providerHandleId))
|
||||
) {
|
||||
filteredAlreadyImportedCount += 1;
|
||||
continue;
|
||||
}
|
||||
candidates.push(descriptor);
|
||||
candidates.push(session);
|
||||
}
|
||||
|
||||
const entries = candidates
|
||||
@@ -154,32 +153,20 @@ export async function importProviderSession(
|
||||
input: ImportProviderSessionInput,
|
||||
): Promise<ImportProviderSessionResult> {
|
||||
const { provider, providerHandleId, cwd, labels } = input.request;
|
||||
const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId, {
|
||||
cwd,
|
||||
});
|
||||
if (!descriptor && provider === "opencode" && !cwd) {
|
||||
throw new Error(
|
||||
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
|
||||
);
|
||||
if (!cwd) {
|
||||
throw new Error("Import requires cwd from the selected provider session");
|
||||
}
|
||||
|
||||
const handle = descriptor
|
||||
? applyImportCwdOverride(descriptor.persistence, cwd)
|
||||
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
|
||||
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
|
||||
|
||||
const handle = buildImportPersistenceHandle({ provider, providerHandleId, cwd });
|
||||
await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle);
|
||||
const snapshot = await input.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
overrides,
|
||||
undefined,
|
||||
{
|
||||
labels,
|
||||
},
|
||||
);
|
||||
const snapshot = await input.agentManager.importProviderSession({
|
||||
provider,
|
||||
providerHandleId,
|
||||
cwd,
|
||||
labels,
|
||||
});
|
||||
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
|
||||
await input.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
||||
await applyImportedAgentTitle({
|
||||
scheduleImportedAgentMetadata({
|
||||
snapshot,
|
||||
agentManager: input.agentManager,
|
||||
workspaceGitService: input.workspaceGitService,
|
||||
@@ -206,7 +193,8 @@ async function unarchiveAgentByHandle(
|
||||
const matched = records.find(
|
||||
(record) =>
|
||||
record.persistence?.provider === handle.provider &&
|
||||
record.persistence?.sessionId === handle.sessionId,
|
||||
(record.persistence.sessionId === handle.sessionId ||
|
||||
record.persistence.nativeHandle === handle.nativeHandle),
|
||||
);
|
||||
if (!matched) {
|
||||
return;
|
||||
@@ -214,7 +202,7 @@ async function unarchiveAgentByHandle(
|
||||
await unarchiveAgentState(agentStorage, agentManager, matched.id);
|
||||
}
|
||||
|
||||
async function applyImportedAgentTitle(input: {
|
||||
function scheduleImportedAgentMetadata(input: {
|
||||
snapshot: ManagedAgent;
|
||||
agentManager: AgentManager;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
@@ -223,19 +211,16 @@ async function applyImportedAgentTitle(input: {
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
|
||||
}): Promise<void> {
|
||||
}): void {
|
||||
const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id));
|
||||
if (!initialPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
|
||||
const { explicitTitle } = resolveCreateAgentTitles({
|
||||
configTitle: input.snapshot.config.title,
|
||||
initialPrompt,
|
||||
});
|
||||
if (!explicitTitle && provisionalTitle) {
|
||||
await input.agentManager.setTitle(input.snapshot.id, provisionalTitle);
|
||||
}
|
||||
|
||||
input.scheduleAgentMetadataGeneration({
|
||||
agentManager: input.agentManager,
|
||||
@@ -271,36 +256,17 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
|
||||
}
|
||||
|
||||
function buildImportPersistenceHandle(input: {
|
||||
provider: AgentProvider;
|
||||
provider: string;
|
||||
providerHandleId: string;
|
||||
cwd?: string;
|
||||
cwd: string;
|
||||
}): AgentPersistenceHandle {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
return {
|
||||
provider: input.provider,
|
||||
sessionId: input.providerHandleId,
|
||||
nativeHandle: input.providerHandleId,
|
||||
metadata: {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyImportCwdOverride(
|
||||
handle: AgentPersistenceHandle,
|
||||
cwd: string | undefined,
|
||||
): AgentPersistenceHandle {
|
||||
if (!cwd) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
return {
|
||||
...handle,
|
||||
metadata: {
|
||||
...handle.metadata,
|
||||
provider: handle.provider,
|
||||
cwd,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -339,17 +305,9 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
|
||||
return `${provider}\0${providerHandleId}`;
|
||||
}
|
||||
|
||||
function isMetadataGenerationDescriptor(descriptor: PersistedAgentDescriptor): boolean {
|
||||
for (const item of descriptor.timeline) {
|
||||
if (item.type !== "user_message") continue;
|
||||
return item.text.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasUserPrompt(descriptor: PersistedAgentDescriptor): boolean {
|
||||
return descriptor.timeline.some(
|
||||
(item) => item.type === "user_message" && item.text.trim() !== "",
|
||||
function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
|
||||
return (
|
||||
input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockState = vi.hoisted(() => {
|
||||
cursor: [] as Array<{
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}>,
|
||||
pi: [] as ConstructorEntry[],
|
||||
genericAcp: [] as Array<{
|
||||
@@ -24,6 +25,7 @@ const mockState = vi.hoisted(() => {
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
}>,
|
||||
},
|
||||
isCommandAvailable: vi.fn(async (_command: string) => false),
|
||||
@@ -239,7 +241,7 @@ vi.mock("./providers/pi/agent.js", () => ({
|
||||
|
||||
vi.mock("./providers/generic-acp-agent.js", () => ({
|
||||
GenericACPAgentClient: class GenericACPAgentClient {
|
||||
readonly capabilities = {
|
||||
capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
@@ -255,7 +257,21 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
}) {
|
||||
const providerParams =
|
||||
options.providerParams &&
|
||||
typeof options.providerParams === "object" &&
|
||||
!Array.isArray(options.providerParams)
|
||||
? (options.providerParams as Record<string, unknown>)
|
||||
: {};
|
||||
this.capabilities = {
|
||||
...this.capabilities,
|
||||
supportsMcpServers:
|
||||
typeof providerParams.supportsMcpServers === "boolean"
|
||||
? providerParams.supportsMcpServers
|
||||
: this.capabilities.supportsMcpServers,
|
||||
};
|
||||
this.runtimeSettings = {
|
||||
command: {
|
||||
mode: "replace",
|
||||
@@ -268,6 +284,7 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
|
||||
env: options.env,
|
||||
providerId: options.providerId,
|
||||
label: options.label,
|
||||
providerParams: options.providerParams,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +323,11 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
readonly provider = "acp";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { command: string[]; env?: Record<string, string> }) {
|
||||
constructor(options: {
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}) {
|
||||
this.runtimeSettings = {
|
||||
command: {
|
||||
mode: "replace",
|
||||
@@ -317,6 +338,7 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
mockState.constructorArgs.cursor.push({
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerParams: options.providerParams,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,6 +539,7 @@ test("new provider extending acp uses GenericACPAgentClient", () => {
|
||||
},
|
||||
providerId: "my-agent",
|
||||
label: "My Agent",
|
||||
providerParams: undefined,
|
||||
},
|
||||
{
|
||||
command: ["my-agent", "--acp"],
|
||||
@@ -525,6 +548,46 @@ test("new provider extending acp uses GenericACPAgentClient", () => {
|
||||
},
|
||||
providerId: "my-agent",
|
||||
label: "My Agent",
|
||||
providerParams: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("ACP provider params can disable MCP support", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
"no-mcp-acp": {
|
||||
extends: "acp",
|
||||
label: "No MCP ACP",
|
||||
command: ["no-mcp-acp", "serve"],
|
||||
params: {
|
||||
supportsMcpServers: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = registry["no-mcp-acp"].createClient(logger);
|
||||
|
||||
expect(client.capabilities.supportsMcpServers).toBe(false);
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([
|
||||
{
|
||||
command: ["no-mcp-acp", "serve"],
|
||||
env: undefined,
|
||||
providerId: "no-mcp-acp",
|
||||
label: "No MCP ACP",
|
||||
providerParams: {
|
||||
supportsMcpServers: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
command: ["no-mcp-acp", "serve"],
|
||||
env: undefined,
|
||||
providerId: "no-mcp-acp",
|
||||
label: "No MCP ACP",
|
||||
providerParams: {
|
||||
supportsMcpServers: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -550,12 +613,14 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||
env: {
|
||||
CURSOR_AGENT_LOG: "debug",
|
||||
},
|
||||
providerParams: undefined,
|
||||
},
|
||||
{
|
||||
command: ["cursor-agent", "acp"],
|
||||
env: {
|
||||
CURSOR_AGENT_LOG: "debug",
|
||||
},
|
||||
providerParams: undefined,
|
||||
},
|
||||
]);
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
AgentStreamEvent,
|
||||
ListModelsOptions,
|
||||
ListModesOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
ResolveAgentCreateConfigInput,
|
||||
ResolveAgentCreateConfigResult,
|
||||
} from "./agent-sdk-types.js";
|
||||
@@ -277,20 +275,6 @@ function mapStreamEvent(provider: AgentProvider, event: AgentStreamEvent): Agent
|
||||
};
|
||||
}
|
||||
|
||||
function mapPersistedAgentDescriptor(
|
||||
provider: AgentProvider,
|
||||
descriptor: PersistedAgentDescriptor,
|
||||
): PersistedAgentDescriptor {
|
||||
return {
|
||||
...descriptor,
|
||||
provider,
|
||||
persistence: {
|
||||
...descriptor.persistence,
|
||||
provider,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapModel(
|
||||
provider: AgentProvider,
|
||||
model: AgentModelDefinition | ProviderProfileModel,
|
||||
@@ -401,7 +385,8 @@ function wrapClientProvider(
|
||||
additionalModels: ProviderProfileModel[],
|
||||
profileModelsAreAdditive: boolean,
|
||||
): AgentClient {
|
||||
const listPersistedAgents = inner.listPersistedAgents?.bind(inner);
|
||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||
const importSession = inner.importSession?.bind(inner);
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -441,11 +426,36 @@ function wrapClientProvider(
|
||||
listModes: inner.listModes?.bind(inner),
|
||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||
listPersistedAgents: listPersistedAgents
|
||||
? async (options?: ListPersistedAgentsOptions) =>
|
||||
(await listPersistedAgents(options)).map((descriptor) =>
|
||||
mapPersistedAgentDescriptor(provider, descriptor),
|
||||
)
|
||||
listImportableSessions: listImportableSessions
|
||||
? async (options) => await listImportableSessions(options)
|
||||
: undefined,
|
||||
importSession: importSession
|
||||
? async (input, context) => {
|
||||
const imported = await importSession(input, {
|
||||
...context,
|
||||
config: {
|
||||
...context.config,
|
||||
provider: inner.provider,
|
||||
},
|
||||
storedConfig: {
|
||||
...context.storedConfig,
|
||||
provider: inner.provider,
|
||||
},
|
||||
});
|
||||
const persistence = mapPersistenceHandle(provider, imported.persistence);
|
||||
if (!persistence) {
|
||||
throw new Error(`Provider '${provider}' import did not return persistence`);
|
||||
}
|
||||
return {
|
||||
...imported,
|
||||
session: wrapSessionProvider(provider, imported.session),
|
||||
config: {
|
||||
...imported.config,
|
||||
provider,
|
||||
},
|
||||
persistence,
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
isAvailable: () => inner.isAvailable(),
|
||||
getDiagnostic: inner.getDiagnostic?.bind(inner),
|
||||
@@ -602,6 +612,7 @@ function addDerivedProviders(
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
})
|
||||
: new GenericACPAgentClient({
|
||||
logger,
|
||||
@@ -609,6 +620,7 @@ function addDerivedProviders(
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
|
||||
77
packages/server/src/server/agent/provider-session-import.ts
Normal file
77
packages/server/src/server/agent/provider-session-import.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
ImportedProviderSession,
|
||||
ImportedTimelineEntry,
|
||||
ImportProviderSessionContext,
|
||||
ImportProviderSessionInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
export async function importSessionFromPersistence(input: {
|
||||
provider: AgentProvider;
|
||||
request: ImportProviderSessionInput;
|
||||
context: ImportProviderSessionContext;
|
||||
resumeSession: AgentClient["resumeSession"];
|
||||
config?: Partial<AgentSessionConfig>;
|
||||
persistence?: AgentPersistenceHandle;
|
||||
}): Promise<ImportedProviderSession> {
|
||||
const config = {
|
||||
...input.context.config,
|
||||
...input.config,
|
||||
provider: input.provider,
|
||||
cwd: input.request.cwd,
|
||||
} as AgentSessionConfig;
|
||||
const storedConfig = {
|
||||
...input.context.storedConfig,
|
||||
...input.config,
|
||||
provider: input.provider,
|
||||
cwd: input.request.cwd,
|
||||
} as AgentSessionConfig;
|
||||
const persistence =
|
||||
input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig);
|
||||
const session = await input.resumeSession(persistence, config, input.context.launchContext);
|
||||
const timeline = await collectImportedTimeline(session.streamHistory());
|
||||
|
||||
return {
|
||||
session,
|
||||
config: storedConfig,
|
||||
persistence,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportPersistenceHandle(
|
||||
provider: AgentProvider,
|
||||
input: ImportProviderSessionInput,
|
||||
config: AgentSessionConfig,
|
||||
): AgentPersistenceHandle {
|
||||
return {
|
||||
provider,
|
||||
sessionId: input.providerHandleId,
|
||||
nativeHandle: input.providerHandleId,
|
||||
metadata: {
|
||||
...config,
|
||||
provider,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectImportedTimeline(
|
||||
events: AsyncGenerator<AgentStreamEvent>,
|
||||
): Promise<ImportedTimelineEntry[]> {
|
||||
const timeline: ImportedTimelineEntry[] = [];
|
||||
for await (const event of events) {
|
||||
if (event.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
timeline.push({
|
||||
item: event.item,
|
||||
...(event.timestamp ? { timestamp: event.timestamp } : {}),
|
||||
});
|
||||
}
|
||||
return timeline;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
mapACPUsage,
|
||||
resolveACPModeSelection,
|
||||
resolveACPModelSelection,
|
||||
summarizeACPRequestError,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
COPILOT_ALLOW_ALL_MODE_ID,
|
||||
@@ -48,6 +49,7 @@ interface ACPSessionInternals {
|
||||
activeForegroundTurnId: string | null;
|
||||
configOptions: SessionConfigOption[];
|
||||
translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[];
|
||||
acpMcpServers(): unknown[];
|
||||
}
|
||||
|
||||
interface ACPModelSelectionInternals {
|
||||
@@ -1486,11 +1488,13 @@ describe("ACPAgentSession slash commands", () => {
|
||||
name: "research_codebase",
|
||||
description: "Search the workspace for relevant files",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "create_plan",
|
||||
description: "Draft a plan for the requested work",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1499,17 +1503,65 @@ describe("ACPAgentSession slash commands", () => {
|
||||
name: "research_codebase",
|
||||
description: "Search the workspace for relevant files",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "create_plan",
|
||||
description: "Draft a plan for the requested work",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession", () => {
|
||||
test("drops MCP servers from ACP requests when the provider does not support MCP", () => {
|
||||
const session = new ACPAgentSession(
|
||||
{
|
||||
provider: "no-mcp-acp",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "no-mcp-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["no-mcp-acp", "serve"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(asInternals<ACPSessionInternals>(session).acpMcpServers()).toEqual([]);
|
||||
});
|
||||
|
||||
test("summarizes JSON-RPC error details without stringifying objects", () => {
|
||||
const summary = summarizeACPRequestError(
|
||||
new RequestError(-32603, "Internal error", {
|
||||
details: "Droid process exited unexpectedly (exit code 1)",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
message: "Internal error: Droid process exited unexpectedly (exit code 1)",
|
||||
code: "-32603",
|
||||
});
|
||||
expect(summary.message).not.toContain("[object Object]");
|
||||
expect(summary.diagnostic).toContain("Droid process exited unexpectedly");
|
||||
});
|
||||
|
||||
test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => {
|
||||
const logger = createTestLogger();
|
||||
const trace = vi.spyOn(logger, "trace");
|
||||
|
||||
@@ -78,14 +78,17 @@ import {
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
type ListImportableSessionsOptions,
|
||||
type ListModesOptions,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type McpServerConfig,
|
||||
type PersistedAgentDescriptor,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
createProviderEnvSpec,
|
||||
@@ -112,7 +115,22 @@ function isACPError(value: unknown): value is ACPError {
|
||||
return isRecord(value) && typeof value.message === "string" && typeof value.code === "number";
|
||||
}
|
||||
|
||||
function summarizeACPRequestError(error: unknown): {
|
||||
function extractACPErrorDataMessage(data: unknown): string | null {
|
||||
if (!isRecord(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of ["details", "errorMessage", "message", "detail", "title"]) {
|
||||
const value = data[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return extractACPErrorDataMessage(data.error);
|
||||
}
|
||||
|
||||
export function summarizeACPRequestError(error: unknown): {
|
||||
message: string;
|
||||
code?: string;
|
||||
diagnostic?: string;
|
||||
@@ -120,11 +138,14 @@ function summarizeACPRequestError(error: unknown): {
|
||||
// Promise rejections are untyped, but the ACP SDK rejects JSON-RPC failures as response.error.
|
||||
if (isACPError(error)) {
|
||||
const code = String(error.code);
|
||||
const detail = extractACPErrorDataMessage(error.data);
|
||||
const message =
|
||||
detail && detail !== error.message ? `${error.message}: ${detail}` : error.message;
|
||||
const data = error.data === undefined ? "" : ` | data=${JSON.stringify(error.data)}`;
|
||||
return {
|
||||
message: error.message,
|
||||
message,
|
||||
code,
|
||||
diagnostic: `${error.message} | code=${code}${data}`,
|
||||
diagnostic: `${message} | code=${code}${data}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +156,17 @@ function summarizeACPRequestError(error: unknown): {
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
function toACPRequestError(error: unknown): Error {
|
||||
if (!isACPError(error)) {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
const summary = summarizeACPRequestError(error);
|
||||
const next = new Error(summary.message);
|
||||
next.name = "ACPRequestError";
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveTerminalCommand(
|
||||
command: string,
|
||||
args?: string[],
|
||||
@@ -151,7 +183,7 @@ function resolveTerminalCommand(
|
||||
return { command: shell.command, args: [...shell.flag, command] };
|
||||
}
|
||||
|
||||
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
@@ -673,10 +705,12 @@ export class ACPAgentClient implements AgentClient {
|
||||
const { cwd } = options;
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
const response = await probe.connection.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
const response = await this.runACPRequest(() =>
|
||||
probe.connection.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
}),
|
||||
);
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
const models = deriveModelDefinitionsFromACP(
|
||||
this.provider,
|
||||
@@ -693,10 +727,12 @@ export class ACPAgentClient implements AgentClient {
|
||||
const { cwd } = options;
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
const response = await probe.connection.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
const response = await this.runACPRequest(() =>
|
||||
probe.connection.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
}),
|
||||
);
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
const modeInfo = deriveModesFromACP(
|
||||
this.defaultModes,
|
||||
@@ -709,39 +745,29 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions: PersistedAgentDescriptor[] = [];
|
||||
const sessions: ImportableProviderSession[] = [];
|
||||
let cursor: string | null | undefined;
|
||||
for (;;) {
|
||||
const page: ListSessionsResponse = await probe.connection.listSessions(
|
||||
cursor ? { cursor } : {},
|
||||
const page: ListSessionsResponse = await this.runACPRequest(() =>
|
||||
probe.connection.listSessions(cursor ? { cursor } : {}),
|
||||
);
|
||||
for (const session of page.sessions) {
|
||||
sessions.push({
|
||||
provider: this.provider,
|
||||
sessionId: session.sessionId,
|
||||
providerHandleId: session.sessionId,
|
||||
cwd: session.cwd,
|
||||
title: session.title ?? null,
|
||||
firstPromptPreview: null,
|
||||
lastPromptPreview: null,
|
||||
lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0),
|
||||
persistence: {
|
||||
provider: this.provider,
|
||||
sessionId: session.sessionId,
|
||||
nativeHandle: session.sessionId,
|
||||
metadata: {
|
||||
provider: this.provider,
|
||||
cwd: session.cwd,
|
||||
title: session.title ?? null,
|
||||
},
|
||||
},
|
||||
timeline: [],
|
||||
});
|
||||
}
|
||||
cursor = page.nextCursor ?? null;
|
||||
@@ -755,6 +781,15 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
return importSessionFromPersistence({
|
||||
provider: this.provider,
|
||||
request: input,
|
||||
context,
|
||||
resumeSession: this.resumeSession.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await this.resolveLaunchCommand();
|
||||
@@ -809,15 +844,17 @@ export class ACPAgentClient implements AgentClient {
|
||||
|
||||
let initialize: InitializeResponse;
|
||||
try {
|
||||
initialize = await Promise.race([
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: ACP_CLIENT_CAPABILITIES,
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
spawnErrorPromise,
|
||||
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
|
||||
]);
|
||||
initialize = await this.runACPRequest(() =>
|
||||
Promise.race([
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: ACP_CLIENT_CAPABILITIES,
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
spawnErrorPromise,
|
||||
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
await terminateChildProcess(child, 2_000);
|
||||
throw error;
|
||||
@@ -861,6 +898,14 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
protected async runACPRequest<T>(request: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
throw toACPRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
|
||||
const prefix = await resolveProviderLaunch({
|
||||
commandConfig: this.runtimeSettings?.command,
|
||||
@@ -998,10 +1043,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.connection = spawned.connection;
|
||||
this.agentCapabilities = spawned.initialize.agentCapabilities ?? null;
|
||||
|
||||
const response = await this.connection.newSession({
|
||||
cwd: this.config.cwd,
|
||||
mcpServers: normalizeMcpServers(this.config.mcpServers),
|
||||
});
|
||||
const response = await this.runACPRequest(() =>
|
||||
this.connection!.newSession({
|
||||
cwd: this.config.cwd,
|
||||
mcpServers: this.acpMcpServers(),
|
||||
}),
|
||||
);
|
||||
this.sessionId = response.sessionId;
|
||||
this.bootstrapThreadEventPending = true;
|
||||
this.applySessionState(response);
|
||||
@@ -1024,20 +1071,24 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const sessionCapabilities = this.agentCapabilities?.sessionCapabilities;
|
||||
if (this.agentCapabilities?.loadSession) {
|
||||
this.replayingHistory = true;
|
||||
const response = await this.connection.loadSession({
|
||||
sessionId: handle.sessionId,
|
||||
cwd: this.config.cwd,
|
||||
mcpServers: normalizeMcpServers(this.config.mcpServers),
|
||||
});
|
||||
const response = await this.runACPRequest(() =>
|
||||
this.connection!.loadSession({
|
||||
sessionId: handle.sessionId,
|
||||
cwd: this.config.cwd,
|
||||
mcpServers: this.acpMcpServers(),
|
||||
}),
|
||||
);
|
||||
this.replayingHistory = false;
|
||||
this.historyPending = this.persistedHistory.length > 0;
|
||||
this.applySessionState(response);
|
||||
} else if (sessionCapabilities?.resume) {
|
||||
const response = await this.connection.unstable_resumeSession({
|
||||
sessionId: handle.sessionId,
|
||||
cwd: this.config.cwd,
|
||||
mcpServers: normalizeMcpServers(this.config.mcpServers),
|
||||
});
|
||||
const response = await this.runACPRequest(() =>
|
||||
this.connection!.unstable_resumeSession({
|
||||
sessionId: handle.sessionId,
|
||||
cwd: this.config.cwd,
|
||||
mcpServers: this.acpMcpServers(),
|
||||
}),
|
||||
);
|
||||
this.applySessionState(response);
|
||||
} else {
|
||||
throw new Error(`${this.provider} does not support ACP session resume`);
|
||||
@@ -1853,15 +1904,29 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
{ logger: this.logger, provider: this.provider },
|
||||
);
|
||||
const connection = new ClientSideConnection(() => this, stream);
|
||||
const initialize = await connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: ACP_CLIENT_CAPABILITIES,
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
});
|
||||
const initialize = await this.runACPRequest(() =>
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: ACP_CLIENT_CAPABILITIES,
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
);
|
||||
|
||||
return { child, connection, initialize };
|
||||
}
|
||||
|
||||
private async runACPRequest<T>(request: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
throw toACPRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private acpMcpServers(): McpServer[] {
|
||||
return this.capabilities.supportsMcpServers ? normalizeMcpServers(this.config.mcpServers) : [];
|
||||
}
|
||||
|
||||
private applySessionState(response: SessionStateResponse): void {
|
||||
const transformed = this.sessionResponseTransformer
|
||||
? this.sessionResponseTransformer(response)
|
||||
@@ -1989,6 +2054,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
}));
|
||||
this.settleCommandsReady();
|
||||
return [];
|
||||
|
||||
@@ -408,6 +408,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7[1m]",
|
||||
|
||||
@@ -73,11 +73,14 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
type ListImportableSessionsOptions,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type McpServerConfig,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../../provider-session-import.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
createProviderEnv,
|
||||
@@ -210,6 +213,7 @@ type ClaudeConversationRewindTarget =
|
||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsSessionListing: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
@@ -1351,9 +1355,9 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
const projectsRoot = path.join(configDir, "projects");
|
||||
if (!(await pathExists(projectsRoot))) {
|
||||
@@ -1365,10 +1369,19 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)),
|
||||
);
|
||||
return parsed
|
||||
.filter((descriptor): descriptor is PersistedAgentDescriptor => descriptor !== null)
|
||||
.filter((session): session is ImportableProviderSession => session !== null)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
return importSessionFromPersistence({
|
||||
provider: "claude",
|
||||
request: input,
|
||||
context,
|
||||
resumeSession: this.resumeSession.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const launch = await resolveProviderLaunch({
|
||||
commandConfig: this.runtimeSettings?.command,
|
||||
@@ -2081,6 +2094,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
argumentHint: cmd.argumentHint,
|
||||
kind: "command",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4907,7 +4921,8 @@ interface ClaudeSessionDescriptorAccumulator {
|
||||
sessionId: string | null;
|
||||
cwd: string | null;
|
||||
title: string | null;
|
||||
timeline: AgentTimelineItem[];
|
||||
firstPromptPreview: string | null;
|
||||
lastPromptPreview: string | null;
|
||||
}
|
||||
|
||||
function isFinishedAccumulator(acc: ClaudeSessionDescriptorAccumulator): boolean {
|
||||
@@ -4940,22 +4955,18 @@ function applyClaudeSessionEntryToAccumulator(
|
||||
if (!acc.title) {
|
||||
acc.title = text;
|
||||
}
|
||||
acc.timeline.push({ type: "user_message", text });
|
||||
const preview = normalizeImportablePromptPreview(text);
|
||||
acc.firstPromptPreview ??= preview;
|
||||
acc.lastPromptPreview = preview;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entry.type === "assistant" && entry.message) {
|
||||
const text = extractClaudeUserText(entry.message);
|
||||
if (text) {
|
||||
acc.timeline.push({ type: "assistant_message", text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseClaudeSessionDescriptor(
|
||||
filePath: string,
|
||||
mtime: Date,
|
||||
): Promise<PersistedAgentDescriptor | null> {
|
||||
): Promise<ImportableProviderSession | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(filePath, "utf8");
|
||||
@@ -4967,7 +4978,8 @@ async function parseClaudeSessionDescriptor(
|
||||
sessionId: null,
|
||||
cwd: null,
|
||||
title: null,
|
||||
timeline: [],
|
||||
firstPromptPreview: null,
|
||||
lastPromptPreview: null,
|
||||
};
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
@@ -4985,33 +4997,28 @@ async function parseClaudeSessionDescriptor(
|
||||
}
|
||||
}
|
||||
|
||||
const { sessionId, cwd, title, timeline } = acc;
|
||||
const { sessionId, cwd, title } = acc;
|
||||
|
||||
if (!sessionId || !cwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistence: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
nativeHandle: sessionId,
|
||||
metadata: {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
providerHandleId: sessionId,
|
||||
cwd,
|
||||
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
|
||||
firstPromptPreview: acc.firstPromptPreview,
|
||||
lastPromptPreview: acc.lastPromptPreview,
|
||||
lastActivityAt: mtime,
|
||||
persistence,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImportablePromptPreview(text: string): string | null {
|
||||
const normalized = text.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) return null;
|
||||
return normalized.length > 160 ? normalized.slice(0, 160) : normalized;
|
||||
}
|
||||
|
||||
function extractClaudeUserText(messageRaw: unknown): string | null {
|
||||
const message = toObjectRecord(messageRaw);
|
||||
if (!message) {
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7[1m]",
|
||||
@@ -179,6 +180,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
it("returns exact match for known model IDs", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
||||
@@ -186,6 +188,7 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
});
|
||||
|
||||
it("normalizes dated model IDs to base model", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
||||
|
||||
@@ -21,6 +21,13 @@ const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5",
|
||||
label: "Fable 5",
|
||||
description: "Fable 5 · Most powerful model",
|
||||
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-8[1m]",
|
||||
@@ -206,6 +213,15 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Fable uses a single-segment version (claude-fable-5), not the {major}-{minor}
|
||||
// scheme of opus/sonnet/haiku, so match it separately. This maps dated runtime
|
||||
// strings (e.g. claude-fable-5-20260301) back to the catalog ID. No [1m] variant:
|
||||
// Fable 5 is natively 1M, so there is no 200K-default model to opt into 1M.
|
||||
const fableMatch = trimmed.match(/(?:claude-)?fable[-_ ]+(\d+)/i);
|
||||
if (fableMatch) {
|
||||
return `claude-fable-${fableMatch[1]}`;
|
||||
}
|
||||
|
||||
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix
|
||||
const runtimeMatch = trimmed.match(
|
||||
/(?:claude-)?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("listPersistedAgents rejects gracefully when the codex binary does not exist", async () => {
|
||||
test("listImportableSessions rejects gracefully when the codex binary does not exist", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger, {
|
||||
command: {
|
||||
mode: "replace",
|
||||
@@ -45,7 +45,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
process.on("uncaughtException", onUncaught);
|
||||
|
||||
try {
|
||||
await expect(client.listPersistedAgents()).rejects.toThrow();
|
||||
await expect(client.listImportableSessions()).rejects.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(uncaughtErrors).toHaveLength(0);
|
||||
} finally {
|
||||
|
||||
@@ -725,6 +725,7 @@ describe("Codex app-server provider", () => {
|
||||
name: "shipper",
|
||||
description: "Ship changes carefully.",
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
});
|
||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
|
||||
} finally {
|
||||
@@ -983,6 +984,7 @@ describe("Codex app-server provider", () => {
|
||||
name: "paseo",
|
||||
description: "Shared orchestration skill.",
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1890,6 +1892,7 @@ describe("Codex app-server provider", () => {
|
||||
name: "compact",
|
||||
description: "Summarize conversation to prevent hitting the context limit",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
});
|
||||
|
||||
const handler = session.tryHandleOutOfBand?.("/compact");
|
||||
@@ -2668,8 +2671,8 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex persisted sessions", () => {
|
||||
test("listPersistedAgents uses thread list metadata without hydrating thread history", async () => {
|
||||
describe("Codex importable sessions", () => {
|
||||
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
|
||||
const allThreads = [
|
||||
{
|
||||
id: "thread-a1",
|
||||
@@ -2722,15 +2725,19 @@ describe("Codex persisted sessions", () => {
|
||||
return child;
|
||||
};
|
||||
|
||||
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });
|
||||
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
|
||||
|
||||
expect(descriptors.map((d) => d.sessionId).sort()).toEqual(["thread-a1", "thread-a2"]);
|
||||
expect(descriptors.every((d) => d.cwd === "/workspace/project-a")).toBe(true);
|
||||
expect(descriptors[0]).toEqual(
|
||||
expect(sessions.map((session) => session.providerHandleId).sort()).toEqual([
|
||||
"thread-a1",
|
||||
"thread-a2",
|
||||
]);
|
||||
expect(sessions.every((session) => session.cwd === "/workspace/project-a")).toBe(true);
|
||||
expect(sessions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionId: "thread-a1",
|
||||
providerHandleId: "thread-a1",
|
||||
title: "Named first A session",
|
||||
timeline: [{ type: "user_message", text: "First A session" }],
|
||||
firstPromptPreview: "First A session",
|
||||
lastPromptPreview: "First A session",
|
||||
}),
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type ToolCallTimelineItem,
|
||||
type AgentUsage,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
type ImportProviderSessionInput,
|
||||
type ListImportableSessionsOptions,
|
||||
type ListModelsOptions,
|
||||
type ListPersistedAgentsOptions,
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
@@ -172,6 +175,7 @@ function formatOutOfBandStatusMessage(text: string): string {
|
||||
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsSessionListing: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
@@ -631,6 +635,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
||||
name: `prompts:${name}`,
|
||||
description,
|
||||
argumentHint,
|
||||
kind: "command",
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -695,6 +700,7 @@ export async function listCodexSkills(
|
||||
name,
|
||||
description,
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -839,11 +845,6 @@ function filterCodexThreadsByCwd(
|
||||
return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd));
|
||||
}
|
||||
|
||||
function buildCodexThreadListTimeline(thread: Record<string, unknown>): AgentTimelineItem[] {
|
||||
const preview = typeof thread.preview === "string" ? thread.preview.trim() : "";
|
||||
return preview ? [{ type: "user_message", text: preview }] : [];
|
||||
}
|
||||
|
||||
export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
const usage = toObjectRecord(tokenUsage);
|
||||
if (!usage) return undefined;
|
||||
@@ -3949,6 +3950,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
argumentHint: "",
|
||||
kind: "skill" as const,
|
||||
}));
|
||||
const fallbackSkills =
|
||||
appServerSkills.length === 0
|
||||
@@ -3959,6 +3961,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
name: "compact",
|
||||
description: "Summarize conversation to prevent hitting the context limit",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
},
|
||||
];
|
||||
if (this.goalsEnabled) {
|
||||
@@ -3966,6 +3969,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
name: "goal",
|
||||
description: "Set, pause, resume, or clear the agent's goal",
|
||||
argumentHint: "[<objective>|pause|resume|clear]",
|
||||
kind: "command",
|
||||
});
|
||||
}
|
||||
return [...builtin, ...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) =>
|
||||
@@ -5482,9 +5486,9 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const child = await this.spawnAppServer();
|
||||
const client =
|
||||
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
|
||||
@@ -5507,43 +5511,39 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
);
|
||||
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
|
||||
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
|
||||
const descriptors: PersistedAgentDescriptor[] = threads.slice(0, limit).map((thread) => {
|
||||
return threads.slice(0, limit).map((thread) => {
|
||||
const threadId = typeof thread.id === "string" ? thread.id : "";
|
||||
const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd();
|
||||
const preview = typeof thread.preview === "string" ? thread.preview : null;
|
||||
const title = typeof thread.name === "string" && thread.name.trim() ? thread.name : preview;
|
||||
|
||||
return {
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: threadId,
|
||||
providerHandleId: threadId,
|
||||
cwd,
|
||||
title,
|
||||
firstPromptPreview: preview,
|
||||
lastPromptPreview: preview,
|
||||
lastActivityAt: new Date(
|
||||
((typeof thread.updatedAt === "number" ? thread.updatedAt : undefined) ??
|
||||
(typeof thread.createdAt === "number" ? thread.createdAt : undefined) ??
|
||||
0) * 1000,
|
||||
),
|
||||
persistence: {
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: threadId,
|
||||
nativeHandle: threadId,
|
||||
metadata: {
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd,
|
||||
title,
|
||||
threadId,
|
||||
},
|
||||
},
|
||||
timeline: buildCodexThreadListTimeline(thread),
|
||||
};
|
||||
});
|
||||
|
||||
return descriptors;
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
return importSessionFromPersistence({
|
||||
provider: CODEX_PROVIDER,
|
||||
request: input,
|
||||
context,
|
||||
resumeSession: this.resumeSession.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
|
||||
const child = await this.spawnAppServer();
|
||||
|
||||
@@ -11,6 +11,7 @@ interface CursorACPAgentClientOptions {
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
}
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||
@@ -28,6 +29,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
|
||||
env: options.env,
|
||||
providerId: options.providerId,
|
||||
label: options.label,
|
||||
providerParams: options.providerParams,
|
||||
// cursor-agent publishes slash commands asynchronously via available_commands_update.
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
|
||||
|
||||
@@ -7,6 +7,17 @@ const mockState = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("./acp-agent.js", () => ({
|
||||
DEFAULT_ACP_CAPABILITIES: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
},
|
||||
ACPAgentClient: class ACPAgentClient {
|
||||
readonly provider: string;
|
||||
|
||||
@@ -40,7 +51,35 @@ describe("GenericACPAgentClient", () => {
|
||||
},
|
||||
},
|
||||
defaultCommand: ["hermes", "acp"],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses provider params to report MCP support", () => {
|
||||
const _client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: ["no-mcp-acp", "serve"],
|
||||
providerParams: {
|
||||
supportsMcpServers: false,
|
||||
},
|
||||
});
|
||||
void _client;
|
||||
|
||||
expect(mockState.superConstructorOptions.at(-1)).toMatchObject({
|
||||
capabilities: {
|
||||
supportsMcpServers: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { homedir } from "node:os";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentProvider } from "../agent-sdk-types.js";
|
||||
import type { AgentCapabilityFlags, AgentProvider } from "../agent-sdk-types.js";
|
||||
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
DEFAULT_ACP_CAPABILITIES,
|
||||
deriveModelDefinitionsFromACP,
|
||||
deriveModesFromACP,
|
||||
type SessionStateResponse,
|
||||
@@ -20,12 +22,21 @@ import {
|
||||
const ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS = 8_000;
|
||||
const ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS = 8_000;
|
||||
|
||||
export const GenericACPProviderParamsSchema = z
|
||||
.object({
|
||||
supportsMcpServers: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type GenericACPProviderParams = z.infer<typeof GenericACPProviderParamsSchema>;
|
||||
|
||||
interface GenericACPAgentClientOptions {
|
||||
logger: Logger;
|
||||
command: [string, ...string[]];
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
}
|
||||
@@ -43,6 +54,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
env: options.env,
|
||||
},
|
||||
defaultCommand: options.command,
|
||||
capabilities: buildGenericACPCapabilities(options),
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
@@ -173,6 +185,18 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags {
|
||||
const params = parseGenericACPProviderParams(options.providerParams);
|
||||
return {
|
||||
...DEFAULT_ACP_CAPABILITIES,
|
||||
supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGenericACPProviderParams(params: unknown): GenericACPProviderParams {
|
||||
return GenericACPProviderParamsSchema.parse(params ?? {});
|
||||
}
|
||||
|
||||
interface ACPDiagnosticProbeResult {
|
||||
status: string;
|
||||
initialize: string;
|
||||
|
||||
@@ -20,13 +20,15 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ImportableProviderSession,
|
||||
ImportProviderSessionContext,
|
||||
ImportProviderSessionInput,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export const MOCK_LOAD_TEST_PROVIDER_ID = "mock";
|
||||
@@ -38,6 +40,7 @@ const MOCK_LOAD_TEST_INTERVAL_MS = 40;
|
||||
const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsSessionListing: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
@@ -427,12 +430,19 @@ export class MockLoadTestAgentClient implements AgentClient {
|
||||
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
_options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
async listImportableSessions(): Promise<ImportableProviderSession[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
return importSessionFromPersistence({
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
request: input,
|
||||
context,
|
||||
resumeSession: this.resumeSession.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user