Compare commits

..
104 changed files with 4265 additions and 1306 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
- '!v*-rc.*' - '!v*-beta.*'
- 'app-v*' - 'app-v*'
- '!app-v*-rc.*' - '!app-v*-beta.*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+12 -6
View File
@@ -66,7 +66,9 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \ --title "Paseo $RELEASE_TAG" \
--generate-notes \ --generate-notes \
$prerelease_flag $prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
}
fi fi
publish-macos: publish-macos:
@@ -145,6 +147,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
@@ -154,7 +157,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: mac-manifest-${{ matrix.electron_arch }} name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
retention-days: 1 retention-days: 1
finalize-mac-manifest: finalize-mac-manifest:
@@ -227,8 +230,9 @@ jobs:
return out; return out;
} }
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8'); const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8'); const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
const arm64 = parseManifest(arm64Text); const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text); const x64 = parseManifest(x64Text);
@@ -243,7 +247,7 @@ jobs:
}; };
const output = toYaml(merged); const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output); fs.writeFileSync(manifestName, output);
console.log('Merged manifest:\n' + output); console.log('Merged manifest:\n' + output);
NODE NODE
@@ -251,7 +255,7 @@ jobs:
if: env.IS_SMOKE_TAG != 'true' if: env.IS_SMOKE_TAG != 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}" run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
publish-linux: publish-linux:
needs: [create-release] needs: [create-release]
@@ -316,6 +320,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
@@ -391,6 +396,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.1.60-beta.1 - 2026-04-20
### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
## 0.1.59 - 2026-04-16 ## 0.1.59 - 2026-04-16
### Added ### Added
+1 -1
View File
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit) - `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release) - `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores. Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands ### Useful commands
+28 -25
View File
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
There are two supported ways to ship from `main`: There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately. 1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet. 2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch) ## Standard release (patch)
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows) npm run release:push # Push HEAD + tag (triggers CI workflows)
``` ```
## Release candidate flow ## Beta flow
```bash ```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ... # ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ... npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
``` ```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1` - Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows - Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag - `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop` - Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release. - Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
Use the RC path when you need to: Use the beta path when you need to:
- test a build manually in a Linux or Windows VM - test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem - send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly - iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Website behavior ## Website behavior
- The website download page points to GitHub's latest published **stable** release. - The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target. - Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`. - The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build ## Fixing a failed release build
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK # Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC # Beta
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
``` ```
This ensures the checkout ref matches the actual code on `main` with the fix included. This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release - `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only - `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform - `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only - `android-vX.Y.Z` rebuilds the Android APK release only
@@ -105,24 +106,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types - `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop` - `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions - If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target. - The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
## Changelog format ## Changelog format
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed: Release notes depend on the changelog heading format. The heading **must** be strictly followed:
``` ```
## X.Y.Z - YYYY-MM-DD ## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
``` ```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage. No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy ## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**. - `CHANGELOG.md` includes stable releases and the current beta line.
- Do not add or edit changelog entries while iterating on RCs. - The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle. - The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Between stable releases, keep changelog work out of the repo until the final release is ready. - Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
## Changelog ownership ## Changelog ownership
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check ## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs. Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like: Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
The changelog always covers **stable-to-HEAD**: The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`. - **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC. - **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next. In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
## Completion checklist ## Completion checklist
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-wa+PE3X1UP/5Efvr73DnKTvgXYsLBjNLuN+HiRM6tGI="; npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -37303,16 +37303,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.3", "@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-rc.3", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.3", "@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -37456,11 +37456,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-rc.3", "@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.3", "@getpaseo/server": "0.1.60-beta.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -37501,11 +37501,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.60-rc.3", "@getpaseo/cli": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.3", "@getpaseo/server": "0.1.60-beta.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -37539,7 +37539,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -37740,7 +37740,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -37766,7 +37766,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -37782,14 +37782,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-rc.3", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-rc.3", "@getpaseo/relay": "0.1.60-beta.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68", "@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
@@ -38543,7 +38543,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+9 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
@@ -47,19 +47,19 @@
"version:all:patch": "node scripts/set-release-version.mjs --mode patch", "version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor", "version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major", "version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch", "version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor", "version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major", "version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next", "version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote", "version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs", "release:push": "node scripts/push-current-release-tag.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push", "release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push", "release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push", "release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push", "release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push", "release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push", "release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push", "release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "APP_VARIANT=development expo start", "start": "APP_VARIANT=development expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.3", "@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-rc.3", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.3", "@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -92,7 +92,7 @@ type ControlledAgentStatusBarProps = {
export interface DraftAgentStatusBarProps { export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[]; providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider; selectedProvider: AgentProvider | null;
onSelectProvider: (provider: AgentProvider) => void; onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[]; modeOptions: AgentMode[];
selectedMode: string; selectedMode: string;
@@ -255,7 +255,8 @@ function ControlledStatusBar({
: undefined; : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette); const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider); const hasSelectedProvider = provider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
const hasAnyControl = const hasAnyControl =
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
@@ -620,7 +621,9 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences" accessibilityLabel="Agent preferences"
testID="agent-preferences-button" testID="agent-preferences-button"
> >
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.prefsButtonText} numberOfLines={1}> <Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel} {displayModel}
</Text> </Text>
@@ -662,7 +665,12 @@ function ControlledStatusBar({
pointerEvents="none" pointerEvents="none"
testID="agent-preferences-model" testID="agent-preferences-model"
> >
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text> <Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View> </View>
@@ -872,15 +880,15 @@ export const AgentStatusBar = memo(function AgentStatusBar({
refetchIfStale: refetchSnapshotIfStale, refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, agent?.cwd); } = useProvidersSnapshot(serverId, agent?.cwd);
const snapshotModels = useMemo(() => { const snapshotSelectedEntry = useMemo(() => {
if (!snapshotEntries || !agent?.provider) { if (!snapshotEntries || !agent?.provider) {
return null; return null;
} }
const entry = snapshotEntries.find((e) => e.provider === agent.provider); return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
return entry?.models ?? null;
}, [snapshotEntries, agent?.provider]); }, [snapshotEntries, agent?.provider]);
const models = snapshotModels; const models = snapshotSelectedEntry?.models ?? null;
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
const agentProviderDefinitions = useMemo(() => { const agentProviderDefinitions = useMemo(() => {
const definition = agent?.provider const definition = agent?.provider
@@ -891,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const agentProviderModels = useMemo(() => { const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && snapshotModels) { if (agent?.provider && models) {
map.set(agent.provider, snapshotModels); map.set(agent.provider, models);
} }
return map; return map;
}, [agent?.provider, snapshotModels]); }, [agent?.provider, models]);
const displayMode = const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -1039,8 +1047,8 @@ export const AgentStatusBar = memo(function AgentStatusBar({
toast.error(toErrorMessage(error)); toast.error(toErrorMessage(error));
}); });
}} }}
isModelLoading={snapshotIsLoading} isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={refetchSnapshotIfStale} onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
disabled={!client} disabled={!client}
/> />
@@ -1096,6 +1104,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ""; const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
const hasSelectedProvider = selectedProvider !== null;
if (platformIsWeb) { if (platformIsWeb) {
return ( return (
@@ -1103,7 +1112,7 @@ export function DraftAgentStatusBar({
<CombinedModelSelector <CombinedModelSelector
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels} allProviderModels={allProviderModels}
selectedProvider={selectedProvider} selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel} selectedModel={selectedModel}
onSelect={onSelectProviderAndModel} onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
@@ -1119,20 +1128,22 @@ export function DraftAgentStatusBar({
onOpen={onModelSelectorOpen} onOpen={onModelSelectorOpen}
onClose={onDropdownClose} onClose={onDropdownClose}
/> />
<ControlledStatusBar {selectedProvider ? (
provider={selectedProvider} <ControlledStatusBar
providerDefinitions={providerDefinitions} provider={selectedProvider}
modeOptions={mappedModeOptions} providerDefinitions={providerDefinitions}
selectedModeId={effectiveSelectedMode} modeOptions={mappedModeOptions}
onSelectMode={onSelectMode} selectedModeId={effectiveSelectedMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} onSelectMode={onSelectMode}
selectedThinkingOptionId={effectiveSelectedThinkingOption} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
onSelectThinkingOption={onSelectThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
features={features} onSelectThinkingOption={onSelectThinkingOption}
onSetFeature={onSetFeature} features={features}
onDropdownClose={onDropdownClose} onSetFeature={onSetFeature}
disabled={disabled} onDropdownClose={onDropdownClose}
/> disabled={disabled}
/>
) : null}
</View> </View>
); );
} }
@@ -1145,10 +1156,10 @@ export function DraftAgentStatusBar({
return ( return (
<> <>
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider ?? ""}
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels} allProviderModels={allProviderModels}
modeOptions={mappedModeOptions} modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
selectedModeId={effectiveSelectedMode} selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode} onSelectMode={onSelectMode}
modelOptions={modelOptions} modelOptions={modelOptions}
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
[onSelect], [onSelect],
); );
const ProviderIcon = getProviderIcon(selectedProvider); const hasSelectedProvider = selectedProvider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => { const selectedModelLabel = useMemo(() => {
if (!selectedModel) { if (!selectedModel) {
if (!hasSelectedProvider) {
return "Select model";
}
return isLoading ? "Loading..." : "Select model"; return isLoading ? "Loading..." : "Select model";
} }
const models = allProviderModels.get(selectedProvider); const models = allProviderModels.get(selectedProvider);
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
} }
const model = models.find((entry) => entry.id === selectedModel); const model = models.find((entry) => entry.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models); return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]); }, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => { const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") { if (view.kind !== "provider") {
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
}) })
) : ( ) : (
<> <>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail"> <Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel} {triggerLabel}
</Text> </Text>
@@ -8,18 +8,8 @@ import {
Text, Text,
View, View,
} from "react-native"; } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
@@ -34,6 +24,7 @@ import {
X, X,
} from "lucide-react-native"; } from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons"; import { getFileIconSvg } from "@/components/material-file-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -312,48 +303,6 @@ export function FileExplorerPane({
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void refetchExplorer(); void refetchExplorer();
}, [refetchExplorer]); }, [refetchExplorer]);
const refreshIconRotation = useSharedValue(0);
useEffect(() => {
if (isRefreshFetching) {
refreshIconRotation.value = 0;
refreshIconRotation.value = withRepeat(
withTiming(360, {
duration: 700,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(refreshIconRotation);
const remainder = refreshIconRotation.value % 360;
if (Math.abs(remainder) < 0.001) {
refreshIconRotation.value = 0;
return;
}
const remaining = 360 - remainder;
const duration = Math.max(80, Math.round((remaining / 360) * 700));
refreshIconRotation.value = withTiming(
360,
{
duration,
easing: Easing.linear,
},
(finished) => {
if (finished) {
refreshIconRotation.value = 0;
}
},
);
}, [isRefreshFetching, refreshIconRotation]);
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
}));
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
@@ -563,11 +512,15 @@ export function FileExplorerPane({
(hovered || pressed) && styles.iconButtonHovered, (hovered || pressed) && styles.iconButtonHovered,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Refresh files" accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
> >
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}> <View style={styles.refreshIcon}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> {isRefreshFetching ? (
</Animated.View> <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable> </Pressable>
</View> </View>
<FlatList <FlatList
+1 -1
View File
@@ -185,7 +185,7 @@ export const LeftSidebar = memo(function LeftSidebar({
enabled: isCompactLayout || isOpen, enabled: isCompactLayout || isOpen,
}); });
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel(projects); useSidebarShortcutModel({ projects, isInitialLoad });
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
+29 -2
View File
@@ -118,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes"; const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer"; const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
"data:image/png;base64",
"data:image/gif;base64",
"data:image/jpeg;base64",
"https://",
"http://",
] as const;
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
type MarkdownWithStableRendererProps = {
children: ReactNode;
style: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules;
markdownit: MarkdownIt;
onLinkPress: (url: string) => boolean;
allowedImageHandlers: readonly string[];
topLevelMaxExceededItem: ReactNode;
};
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% { 0% {
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
onLinkPress, onLinkPress,
}: MemoizedMarkdownBlockProps) { }: MemoizedMarkdownBlockProps) {
return ( return (
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}> <MarkdownWithStableRenderer
style={styles}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text} {text}
</Markdown> </MarkdownWithStableRenderer>
); );
}); });
@@ -1,9 +1,9 @@
import { AlertCircle, Search } from "lucide-react-native"; import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
); );
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void refresh([provider as AgentProvider]); if (!provider) {
void fetchDiagnostic({ keepCurrent: true }); return;
}
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
});
}, [fetchDiagnostic, provider, refresh]); }, [fetchDiagnostic, provider, refresh]);
useEffect(() => { useEffect(() => {
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
refreshInFlight ? sheetStyles.disabled : null, refreshInFlight ? sheetStyles.disabled : null,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`} accessibilityLabel={
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
}
> >
<SpinningRefreshIcon {refreshInFlight ? (
spinning={refreshInFlight} <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
size={theme.iconSize.sm} ) : (
color={theme.colors.foregroundMuted} <RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
/> )}
</Pressable> </Pressable>
} }
> >
@@ -1,67 +0,0 @@
import { useEffect } from "react";
import { RefreshCw } from "lucide-react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
interface SpinningRefreshIconProps {
spinning: boolean;
size: number;
color: string;
}
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
const rotation = useSharedValue(0);
useEffect(() => {
if (spinning) {
rotation.value = 0;
rotation.value = withRepeat(
withTiming(360, {
duration: 1000,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(rotation);
const remainder = rotation.value % 360;
if (Math.abs(remainder) < 0.001) {
rotation.value = 0;
return;
}
rotation.value = withTiming(360, {
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
easing: Easing.linear,
});
}, [rotation, spinning]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View
style={[
{
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
},
animatedStyle,
]}
>
<RefreshCw size={size} color={color} />
</Animated.View>
);
}
@@ -0,0 +1,10 @@
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
interface LoadingSpinnerProps {
color: string;
size?: ActivityIndicatorProps["size"];
}
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
return <ActivityIndicator size={size} color={color} />;
}
@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
testID, testID,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd; const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd; const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md; const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return ( return (
<View style={[styles.container, style]} testID={testID}> <View style={[styles.container, containerSizeStyle, style]} testID={testID}>
{options.map((option) => { {options.map((option) => {
const isSelected = option.value === value; const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted; const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "stretch",
maxWidth: "100%", maxWidth: "100%",
gap: 4, backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
}, },
segment: { segment: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0, flexShrink: 0,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: "transparent",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
segmentSm: { segmentSm: {
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[4],
}, },
segmentMd: { segmentMd: {
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[6],
}, },
segmentSelected: { segmentSelected: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border, shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
}, },
segmentHover: { segmentHover: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
}, },
segmentPressed: { segmentPressed: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
}, },
segmentDisabled: { segmentDisabled: {
opacity: theme.opacity[50], opacity: theme.opacity[50],
@@ -135,10 +143,10 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
labelSm: { labelSm: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
}, },
labelMd: { labelMd: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
}, },
labelSelected: { labelSelected: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
if (!composerState) { if (!composerState) {
throw new Error("Workspace setup composer state is required"); throw new Error("Workspace setup composer state is required");
} }
if (!composerState.selectedProvider) {
throw new Error("Select a model");
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments); const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(wirePayload.images); const encodedImages = await encodeImages(wirePayload.images);
@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
message: string; message: string;
} }
export type DesktopReleaseChannel = "stable" | "beta";
export interface LocalDaemonUpdateResult { export interface LocalDaemonUpdateResult {
exitCode: number; exitCode: number;
stdout: string; stdout: string;
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
return parseLocalDaemonVersionResult(result); return parseLocalDaemonVersionResult(result);
} }
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> { export async function checkDesktopAppUpdate({
const result = await invokeDesktopCommand<unknown>("check_app_update"); releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
if (!isRecord(result)) { if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates."); throw new Error("Unexpected response while checking desktop updates.");
} }
@@ -85,8 +91,12 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
}; };
} }
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> { export async function installDesktopAppUpdate({
const result = await invokeDesktopCommand<unknown>("install_app_update"); releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
if (!isRecord(result)) { if (!isRecord(result)) {
throw new Error("Unexpected response while installing desktop update."); throw new Error("Unexpected response while installing desktop update.");
} }
@@ -7,6 +7,7 @@ import {
type DesktopAppUpdateCheckResult, type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult, type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
import { useAppSettings } from "@/hooks/use-settings";
export type DesktopAppUpdateStatus = export type DesktopAppUpdateStatus =
| "idle" | "idle"
@@ -83,6 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection(); const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const requestVersionRef = useRef(0); const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle"); const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null); const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await checkDesktopAppUpdate(); const result = await checkDesktopAppUpdate({ releaseChannel });
if (requestVersion !== requestVersionRef.current) { if (requestVersion !== requestVersionRef.current) {
return result; return result;
} }
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null; return null;
} }
}, },
[isDesktopApp], [isDesktopApp, releaseChannel],
); );
useEffect(() => {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => { useEffect(() => {
if (!isDesktopApp || status !== "pending") { if (!isDesktopApp || status !== "pending") {
return undefined; return undefined;
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await installDesktopAppUpdate(); const result = await installDesktopAppUpdate({ releaseChannel });
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
if (result.installed) { if (result.installed) {
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message); setErrorMessage(message);
return null; return null;
} }
}, [isDesktopApp]); }, [isDesktopApp, releaseChannel]);
return { return {
isDesktopApp, isDesktopApp,
@@ -0,0 +1,209 @@
import { renderHook, waitFor } from "@testing-library/react";
import { JSDOM } from "jsdom";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { FormPreferences } from "./use-form-preferences";
import { useAgentFormState } from "./use-agent-form-state";
const { mocks } = vi.hoisted(() => ({
mocks: {
preferences: {} as FormPreferences,
isPreferencesLoading: false,
snapshotEntries: [] as ProviderSnapshotEntry[],
updatePreferences: vi.fn(),
refreshSnapshot: vi.fn(),
refetchSnapshotIfStale: vi.fn(),
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "host-1" }],
}));
vi.mock("./use-form-preferences", async (importOriginal) => {
const actual = await importOriginal<typeof import("./use-form-preferences")>();
return {
...actual,
useFormPreferences: () => ({
preferences: mocks.preferences,
isLoading: mocks.isPreferencesLoading,
updatePreferences: mocks.updatePreferences,
}),
};
});
vi.mock("./use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries: mocks.snapshotEntries,
isLoading: false,
isFetching: false,
isRefreshing: false,
error: null,
supportsSnapshot: true,
refresh: mocks.refreshSnapshot,
refetchIfStale: mocks.refetchSnapshotIfStale,
}),
}));
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
writable: true,
});
});
describe("useAgentFormState live preference hydration", () => {
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: dom.window,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
mocks.preferences = {};
mocks.isPreferencesLoading = false;
mocks.snapshotEntries = [
{
provider: "codex",
status: "ready",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{
id: "full-access",
label: "Full Access",
icon: "ShieldAlert",
colorTier: "dangerous",
},
],
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "low",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "xhigh", label: "XHigh" },
],
},
],
},
{
provider: "claude",
status: "ready",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
models: [
{
provider: "claude",
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
isDefault: true,
},
],
},
];
mocks.updatePreferences.mockReset();
mocks.refreshSnapshot.mockReset();
mocks.refetchSnapshotIfStale.mockReset();
});
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
mocks.preferences = {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
},
};
const { result, rerender } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4": "low",
},
},
},
};
rerender();
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
});
it("uses the latest preferences when a separate composer hydrates later", async () => {
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
},
};
const { result } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
expect(result.current.selectedMode).toBe("default");
});
});
});
@@ -146,6 +146,112 @@ describe("useAgentFormState", () => {
}); });
}); });
describe("__private__.mergeSelectedComposerPreferences", () => {
it("stores the selected model for the selected provider", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
},
},
});
});
it("preserves existing provider preferences when the selected model changes", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "claude",
providerPreferences: {
codex: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
});
});
it("stores mode and thinking preferences without dropping the selected model", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
},
},
},
},
provider: "codex",
updates: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "low",
"gpt-5.4": "xhigh",
},
},
},
});
});
});
describe("__private__.resolveFormState", () => { describe("__private__.resolveFormState", () => {
const codexModels: AgentModelDefinition[] = [ const codexModels: AgentModelDefinition[] = [
{ {
@@ -161,6 +267,37 @@ describe("useAgentFormState", () => {
}, },
]; ];
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("does not auto-select a model on fresh drafts without preferences", () => { it("does not auto-select a model on fresh drafts without preferences", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
@@ -374,7 +511,7 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("low"); expect(resolved.thinkingOptionId).toBe("low");
}); });
it("resolves provider only from allowed provider map", () => { it("clears an invalid provider instead of falling back to the first allowed provider", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
{ provider: "codex" }, { provider: "codex" },
@@ -399,7 +536,121 @@ describe("useAgentFormState", () => {
claudeProviderMap, claudeProviderMap,
); );
expect(resolved.provider).toBe("claude"); expect(resolved.provider).toBeNull();
});
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
const loadingEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "loading",
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(loadingEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: loadingEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: true,
model: true,
thinkingOptionId: true,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("full-access");
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: unavailableEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
}); });
it("does not force fallback provider when allowed provider map is empty", () => { it("does not force fallback provider when allowed provider map is empty", () => {
+184 -58
View File
@@ -48,7 +48,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
// Internal form state // Internal form state
interface FormState { interface FormState {
serverId: string | null; serverId: string | null;
provider: AgentProvider; provider: AgentProvider | null;
modeId: string; modeId: string;
model: string; model: string;
thinkingOptionId: string; thinkingOptionId: string;
@@ -68,7 +68,7 @@ export type UseAgentFormStateResult = {
selectedServerId: string | null; selectedServerId: string | null;
setSelectedServerId: (value: string | null) => void; setSelectedServerId: (value: string | null) => void;
setSelectedServerIdFromUser: (value: string | null) => void; setSelectedServerIdFromUser: (value: string | null) => void;
selectedProvider: AgentProvider; selectedProvider: AgentProvider | null;
setProviderFromUser: (provider: AgentProvider) => void; setProviderFromUser: (provider: AgentProvider) => void;
selectedMode: string; selectedMode: string;
setModeFromUser: (modeId: string) => void; setModeFromUser: (modeId: string) => void;
@@ -97,9 +97,6 @@ export type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>; persistFormPreferences: () => Promise<void>;
}; };
const DEFAULT_PROVIDER: AgentProvider = "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
function normalizeSelectedModelId(modelId: string | null | undefined): string { function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : ""; const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized) { if (!normalized) {
@@ -160,6 +157,18 @@ function resolveThinkingOptionId(args: {
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? ""; return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
} }
function mergeSelectedComposerPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: args.updates,
});
}
/** /**
* Pure function that resolves form state from multiple data sources. * Pure function that resolves form state from multiple data sources.
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback * Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
@@ -177,8 +186,6 @@ function resolveFormState(
): FormState { ): FormState {
// Start with current state - we only update non-user-modified fields // Start with current state - we only update non-user-modified fields
const result = { ...currentState }; const result = { ...currentState };
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
// 1. Resolve provider first (other fields depend on it) // 1. Resolve provider first (other fields depend on it)
if (!userModified.provider) { if (!userModified.provider) {
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) { if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
@@ -188,22 +195,33 @@ function resolveFormState(
allowedProviderMap.has(preferences.provider as AgentProvider) allowedProviderMap.has(preferences.provider as AgentProvider)
) { ) {
result.provider = preferences.provider as AgentProvider; result.provider = preferences.provider as AgentProvider;
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) { } else if (
result.provider = fallbackProvider; result.provider &&
allowedProviderMap.size > 0 &&
!allowedProviderMap.has(result.provider)
) {
result.provider = null;
} }
// else keep current (initialized to DEFAULT_PROVIDER) } else if (
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) { result.provider &&
result.provider = fallbackProvider; allowedProviderMap.size > 0 &&
!allowedProviderMap.has(result.provider)
) {
result.provider = null;
} }
const providerDef = allowedProviderMap.get(result.provider); const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
const providerPrefs = preferences?.providerPreferences?.[result.provider]; const providerPrefs = result.provider
? preferences?.providerPreferences?.[result.provider]
: undefined;
// 2. Resolve modeId (depends on provider) // 2. Resolve modeId (depends on provider)
if (!userModified.modeId) { if (!userModified.modeId) {
const validModeIds = providerDef?.modes.map((m) => m.id) ?? []; const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if ( if (!result.provider) {
result.modeId = "";
} else if (
typeof initialValues?.modeId === "string" && typeof initialValues?.modeId === "string" &&
initialValues.modeId.length > 0 && initialValues.modeId.length > 0 &&
validModeIds.includes(initialValues.modeId) validModeIds.includes(initialValues.modeId)
@@ -223,7 +241,9 @@ function resolveFormState(
const preferredModel = normalizeSelectedModelId(providerPrefs?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(availableModels); const defaultModelId = resolveDefaultModelId(availableModels);
if (initialModel) { if (!result.provider) {
result.model = "";
} else if (initialModel) {
if (!availableModels || isValidModel(initialModel)) { if (!availableModels || isValidModel(initialModel)) {
result.model = initialModel; result.model = initialModel;
} else { } else {
@@ -246,7 +266,9 @@ function resolveFormState(
? initialValues.thinkingOptionId.trim() ? initialValues.thinkingOptionId.trim()
: ""; : "";
if (!userModified.thinkingOptionId) { if (!result.provider) {
result.thinkingOptionId = "";
} else if (!userModified.thinkingOptionId) {
const effectiveModelId = result.model.trim(); const effectiveModelId = result.model.trim();
const preferredThinking = effectiveModelId const preferredThinking = effectiveModelId
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "") ? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
@@ -262,7 +284,7 @@ function resolveFormState(
} }
// Validate thinking option once model metadata is available. // Validate thinking option once model metadata is available.
if (availableModels) { if (result.provider && availableModels) {
result.thinkingOptionId = resolveThinkingOptionId({ result.thinkingOptionId = resolveThinkingOptionId({
availableModels, availableModels,
modelId: result.model, modelId: result.model,
@@ -312,6 +334,37 @@ function combineInitialValues(
return initialValues; return initialValues;
} }
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
function buildProviderDefinitionMap(
providerDefinitions: AgentProviderDefinition[],
): Map<AgentProvider, AgentProviderDefinition> {
return new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id, definition]),
);
}
function buildProviderDefinitionMapForStatuses(args: {
snapshotEntries: ProviderSnapshotEntry[] | undefined;
providerDefinitions: AgentProviderDefinition[];
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
}): Map<AgentProvider, AgentProviderDefinition> {
if (!args.snapshotEntries?.length) {
return buildProviderDefinitionMap(args.providerDefinitions);
}
const matchingProviders = new Set(
args.snapshotEntries
.filter((entry) => args.statuses.has(entry.status))
.map((entry) => entry.provider),
);
return buildProviderDefinitionMap(
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
);
}
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
const { const {
initialServerId = null, initialServerId = null,
@@ -335,8 +388,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
// Form state // Form state
const [formState, setFormState] = useState<FormState>(() => ({ const [formState, setFormState] = useState<FormState>(() => ({
serverId: initialServerId, serverId: initialServerId,
provider: DEFAULT_PROVIDER, provider: null,
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER, modeId: "",
model: "", model: "",
thinkingOptionId: "", thinkingOptionId: "",
workingDir: "", workingDir: "",
@@ -348,12 +401,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
// Track if we've done initial resolution (to avoid flickering) // Track if we've done initial resolution (to avoid flickering)
const hasResolvedRef = useRef(false); const hasResolvedRef = useRef(false);
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
// Reset user modifications when form becomes invisible // Reset user modifications when form becomes invisible
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
setUserModified(INITIAL_USER_MODIFIED); setUserModified(INITIAL_USER_MODIFIED);
hasResolvedRef.current = false; hasResolvedRef.current = false;
hydrationPreferencesRef.current = null;
} }
}, [isVisible]); }, [isVisible]);
@@ -371,25 +426,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotEntries], [snapshotEntries],
); );
const snapshotProviderDefinitionMap = useMemo( const snapshotProviderDefinitionMap = useMemo(
() => () => buildProviderDefinitionMap(snapshotProviderDefinitions),
new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
),
[snapshotProviderDefinitions], [snapshotProviderDefinitions],
); );
const snapshotResolvableProviderDefinitionMap = useMemo(
() =>
buildProviderDefinitionMapForStatuses({
snapshotEntries,
providerDefinitions: snapshotProviderDefinitions,
statuses: RESOLVABLE_PROVIDER_STATUSES,
}),
[snapshotEntries, snapshotProviderDefinitions],
);
const snapshotSelectableProviderDefinitionMap = useMemo(() => { const snapshotSelectableProviderDefinitionMap = useMemo(() => {
if (!snapshotEntries?.length) { return buildProviderDefinitionMapForStatuses({
return snapshotProviderDefinitionMap; snapshotEntries,
} providerDefinitions: snapshotProviderDefinitions,
const readyProviders = new Set( statuses: SELECTABLE_PROVIDER_STATUSES,
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider), });
); }, [snapshotEntries, snapshotProviderDefinitions]);
return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]),
);
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => { const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) { for (const entry of snapshotEntries ?? []) {
@@ -398,13 +453,17 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return map; return map;
}, [snapshotEntries]); }, [snapshotEntries]);
const snapshotSelectedEntry = useMemo( const snapshotSelectedEntry = useMemo(
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null, () =>
formState.provider
? ((snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null)
: null,
[formState.provider, snapshotEntries], [formState.provider, snapshotEntries],
); );
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null; const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
const snapshotSelectedProviderModes = const snapshotSelectedProviderModes =
snapshotSelectedEntry?.modes ?? snapshotSelectedEntry?.modes ??
snapshotProviderDefinitionMap.get(formState.provider)?.modes ?? (formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ??
[]; [];
const providerDefinitions = snapshotProviderDefinitions; const providerDefinitions = snapshotProviderDefinitions;
const providerDefinitionMap = snapshotProviderDefinitionMap; const providerDefinitionMap = snapshotProviderDefinitionMap;
@@ -412,7 +471,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const allProviderModels = snapshotAllProviderModels; const allProviderModels = snapshotAllProviderModels;
const availableModels = snapshotSelectedProviderModels; const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes; const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading; const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
// Combine initialValues with initialServerId for resolution // Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => { const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -425,19 +484,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return; return;
} }
// Wait for preferences to load before first resolution, unless explicit URL overrides exist. if (isPreferencesLoading && !hasResolvedRef.current) {
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
return; return;
} }
if (!hasResolvedRef.current) {
hydrationPreferencesRef.current = preferences;
}
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
const resolved = resolveFormState( const resolved = resolveFormState(
combinedInitialValues, combinedInitialValues,
preferences, hydrationPreferences,
availableModels, availableModels,
userModified, userModified,
formStateRef.current, formStateRef.current,
validServerIds, validServerIds,
selectableProviderDefinitionMap, snapshotResolvableProviderDefinitionMap,
); );
// Only update if something changed // Only update if something changed
@@ -462,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
availableModels, availableModels,
userModified, userModified,
validServerIds, validServerIds,
selectableProviderDefinitionMap, snapshotResolvableProviderDefinitionMap,
]); ]);
// Auto-select the first online host when: // Auto-select the first online host when:
@@ -571,15 +634,38 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId, thinkingOptionId: nextThinkingOptionId,
})); }));
setUserModified((prev) => ({ ...prev, provider: true, model: true })); setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider }); void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
model: nextModelId || undefined,
},
}),
);
}, },
[allProviderModels, selectableProviderDefinitionMap, updatePreferences], [allProviderModels, selectableProviderDefinitionMap, updatePreferences],
); );
const setModeFromUser = useCallback((modeId: string) => { const setModeFromUser = useCallback(
setFormState((prev) => ({ ...prev, modeId })); (modeId: string) => {
setUserModified((prev) => ({ ...prev, modeId: true })); setFormState((prev) => ({ ...prev, modeId }));
}, []); setUserModified((prev) => ({ ...prev, modeId: true }));
const provider = formStateRef.current.provider;
if (provider) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
mode: modeId || undefined,
},
}),
);
}
},
[updatePreferences],
);
const setModelFromUser = useCallback( const setModelFromUser = useCallback(
(modelId: string) => { (modelId: string) => {
@@ -598,14 +684,44 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId, thinkingOptionId: nextThinkingOptionId,
})); }));
setUserModified((prev) => ({ ...prev, model: true })); setUserModified((prev) => ({ ...prev, model: true }));
const provider = formStateRef.current.provider;
if (provider) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
model: nextModelId || undefined,
},
}),
);
}
}, },
[availableModels, userModified.thinkingOptionId], [availableModels, updatePreferences, userModified.thinkingOptionId],
); );
const setThinkingOptionFromUser = useCallback((thinkingOptionId: string) => { const setThinkingOptionFromUser = useCallback(
setFormState((prev) => ({ ...prev, thinkingOptionId })); (thinkingOptionId: string) => {
setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); setFormState((prev) => ({ ...prev, thinkingOptionId }));
}, []); setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
const provider = formStateRef.current.provider;
const modelId = formStateRef.current.model;
if (provider && modelId) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
thinkingByModel: {
[modelId]: thinkingOptionId,
},
},
}),
);
}
},
[updatePreferences],
);
const setWorkingDir = useCallback((value: string) => { const setWorkingDir = useCallback((value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value })); setFormState((prev) => ({ ...prev, workingDir: value }));
@@ -625,16 +741,21 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [refreshSnapshot]); }, [refreshSnapshot]);
const refetchProviderModelsIfStale = useCallback(() => { const refetchProviderModelsIfStale = useCallback(() => {
refetchSnapshotIfStale(); refetchSnapshotIfStale(formStateRef.current.provider);
}, [refetchSnapshotIfStale]); }, [refetchSnapshotIfStale]);
const persistFormPreferences = useCallback(async () => { const persistFormPreferences = useCallback(async () => {
if (!formState.provider) {
return;
}
const provider = formState.provider;
const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model; const modelId = resolvedModel?.id ?? formState.model;
await updatePreferences((current) => await updatePreferences((current) =>
mergeProviderPreferences({ mergeProviderPreferences({
preferences: current, preferences: current,
provider: formState.provider, provider,
updates: { updates: {
model: modelId || undefined, model: modelId || undefined,
mode: formState.modeId || undefined, mode: formState.modeId || undefined,
@@ -657,11 +778,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
updatePreferences, updatePreferences,
]); ]);
const agentDefinition = providerDefinitionMap.get(formState.provider); const agentDefinition = formState.provider
? providerDefinitionMap.get(formState.provider)
: undefined;
const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model; const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = snapshotIsLoading; const isModelLoading = snapshotIsLoading || selectedProviderIsLoading;
const modelError = snapshotError; const modelError = snapshotError;
const workingDirIsEmpty = !formState.workingDir.trim(); const workingDirIsEmpty = !formState.workingDir.trim();
@@ -738,7 +861,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
export type CreateAgentInitialValues = FormInitialValues; export type CreateAgentInitialValues = FormInitialValues;
export const __private__ = { export const __private__ = {
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
combineInitialValues, combineInitialValues,
mergeSelectedComposerPreferences,
resolveDefaultModel, resolveDefaultModel,
resolveFormState, resolveFormState,
resolveThinkingOptionId, resolveThinkingOptionId,
@@ -10,6 +10,7 @@ import {
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
type AttachmentUpdater = type AttachmentUpdater =
| ComposerAttachment[] | ComposerAttachment[]
@@ -113,7 +114,7 @@ function resolveEffectiveComposerThinkingOptionId(input: {
} }
function buildDraftComposerCommandConfig(input: { function buildDraftComposerCommandConfig(input: {
provider: DraftAgentStatusBarProps["selectedProvider"]; provider: AgentProvider | null;
cwd: string; cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"]; modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string; selectedMode: string;
@@ -122,7 +123,7 @@ function buildDraftComposerCommandConfig(input: {
featureValues?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined { }): DraftCommandConfig | undefined {
const cwd = input.cwd.trim(); const cwd = input.cwd.trim();
if (!cwd) { if (!input.provider || !cwd) {
return undefined; return undefined;
} }
@@ -16,7 +16,7 @@ type DraftFeatureConfig = Pick<
export function useDraftAgentFeatures(input: { export function useDraftAgentFeatures(input: {
serverId: string | null | undefined; serverId: string | null | undefined;
provider: AgentProvider; provider: AgentProvider | null;
cwd: string | null | undefined; cwd: string | null | undefined;
modeId: string | null | undefined; modeId: string | null | undefined;
modelId: string | null | undefined; modelId: string | null | undefined;
@@ -28,30 +28,31 @@ export function useDraftAgentFeatures(input: {
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || ""; const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null;
const persistedFeatureValues = useMemo( const persistedFeatureValues = useMemo(
() => preferences.providerPreferences?.[provider]?.featureValues ?? {}, () => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider], [preferences.providerPreferences, provider],
); );
const draftConfig = useMemo<DraftFeatureConfig | null>(() => { const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) { if (!normalizedProvider || !normalizedCwd) {
return null; return null;
} }
return { return {
provider, provider: normalizedProvider,
cwd: normalizedCwd, cwd: normalizedCwd,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}), ...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
}; };
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]); }, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
const featuresQuery = useQuery({ const featuresQuery = useQuery({
queryKey: [ queryKey: [
"providerFeatures", "providerFeatures",
serverId ?? null, serverId ?? null,
provider, normalizedProvider,
normalizedCwd || null, normalizedCwd || null,
modeId ?? null, modeId ?? null,
modelId ?? null, modelId ?? null,
@@ -106,6 +107,9 @@ export function useDraftAgentFeatures(input: {
return { ...current, [featureId]: value }; return { ...current, [featureId]: value };
}); });
if (!provider) {
return;
}
void updatePreferences((current) => void updatePreferences((current) =>
mergeProviderPreferences({ mergeProviderPreferences({
preferences: current, preferences: current,
@@ -0,0 +1,277 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { useSessionStore } from "@/stores/session-store";
import {
providersSnapshotQueryKey,
shouldApplyProvidersSnapshotUpdate,
useProvidersSnapshot,
} from "./use-providers-snapshot";
type ProviderSnapshotUpdateMessage = {
type: "providers_snapshot_update";
payload: {
cwd: string;
entries: ProviderSnapshotEntry[];
generatedAt: string;
};
};
type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void;
type ProvidersSnapshot = {
entries: ProviderSnapshotEntry[];
generatedAt: string;
requestId: string;
};
type HookResult = ReturnType<typeof renderProvidersSnapshotHook>["result"];
const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => {
const snapshotUpdateListeners: ProviderSnapshotUpdateListener[] = [];
const mockClient = {
getProvidersSnapshot: vi.fn(),
refreshProvidersSnapshot: vi.fn(),
on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => {
snapshotUpdateListeners.push(listener);
return () => {};
}),
};
return {
mockClient,
mockRuntime: {
client: mockClient,
isConnected: true,
},
snapshotUpdateListeners,
};
});
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockRuntime.client,
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
}));
const serverId = "server-1";
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function enableProvidersSnapshot(): void {
act(() => {
useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: "localhost",
version: "test",
features: { providersSnapshot: true },
} as never);
});
}
function renderProvidersSnapshotHook(cwd?: string | null) {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
}
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
function providersSnapshot(entries: ProviderSnapshotEntry[]): ProvidersSnapshot {
return {
entries,
generatedAt: "2026-01-01T00:00:00.000Z",
requestId: "snapshot",
};
}
function codexEntry(
status: ProviderSnapshotEntry["status"],
models?: ProviderSnapshotEntry["models"],
): ProviderSnapshotEntry {
return {
provider: "codex",
status,
...(models ? { models } : {}),
};
}
async function waitForSnapshotReads(count: number): Promise<void> {
await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(count);
});
}
async function waitForSnapshotEntries(
result: HookResult,
entries: ProviderSnapshotEntry[],
): Promise<void> {
await waitFor(() => {
expect(result.current.entries).toEqual(entries);
});
}
async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Promise<void> {
const listener = snapshotUpdateListeners.at(-1);
expect(listener).toBeDefined();
await act(async () => {
listener?.({
type: "providers_snapshot_update",
payload: {
cwd: "/repo",
entries,
generatedAt: "2026-01-01T00:00:01.000Z",
},
});
});
}
async function openSelectorForSelectedProvider(result: HookResult): Promise<void> {
await act(async () => {
result.current.refetchIfStale("codex");
});
}
afterEach(() => {
act(() => {
useSessionStore.getState().clearSession(serverId);
});
vi.clearAllMocks();
snapshotUpdateListeners.length = 0;
});
describe("providers snapshot hook cache scope", () => {
it("uses no cwd in the settings query key", () => {
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
});
it("accepts concrete home update events for settings snapshots", () => {
expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true);
expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true);
});
it("keeps workspace snapshot updates scoped to matching cwd keys", () => {
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe(
true,
);
expect(
shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"),
).toBe(false);
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false);
});
it("sends no cwd for settings snapshot loads and refreshes", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
mockClient.refreshProvidersSnapshot.mockResolvedValue({
acknowledged: true,
requestId: "settings-refresh",
});
const { result } = renderProvidersSnapshotHook();
await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
});
await act(async () => {
await result.current.refresh(["codex"]);
});
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
});
it("sends cwd for workspace snapshot loads and refreshes", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
mockClient.refreshProvidersSnapshot.mockResolvedValue({
acknowledged: true,
requestId: "workspace-refresh",
});
const { result } = renderProvidersSnapshotHook("/repo");
await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
});
await act(async () => {
await result.current.refresh(["codex"]);
});
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
cwd: "/repo",
providers: ["codex"],
});
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
});
it("refetches loading snapshot updates through the read path but ignores empty updates", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [])]))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
renderProvidersSnapshotHook("/repo");
await waitForSnapshotReads(1);
await emitProvidersSnapshotUpdate([]);
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
it.each([
{ name: "missing", entries: [] },
{ name: "loading", entries: [codexEntry("loading")] },
])("ensures a selected provider snapshot on selector open when it is $name", async ({
entries,
}) => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot
.mockResolvedValueOnce(providersSnapshot(entries))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
const { result } = renderProvidersSnapshotHook("/repo");
await waitForSnapshotEntries(result, entries);
await openSelectorForSelectedProvider(result);
await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
it("does not ensure a selected provider snapshot on selector open when the provider is ready with no models", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
const { result } = renderProvidersSnapshotHook("/repo");
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
await openSelectorForSelectedProvider(result);
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
});
@@ -6,7 +6,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { queryClient as singletonQueryClient } from "@/query/query-client"; import { queryClient as singletonQueryClient } from "@/query/query-client";
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null { export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
const trimmed = cwd?.trim(); const trimmed = cwd?.trim();
if (!trimmed) { if (!trimmed) {
return null; return null;
@@ -15,10 +15,33 @@ function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~"); return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
} }
export function shouldApplyProvidersSnapshotUpdate(
currentCwd?: string | null,
messageCwd?: string | null,
): boolean {
const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd);
const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd);
return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~");
}
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) { export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const; return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
} }
function providersSnapshotRequest(cwd?: string): { cwd?: string } {
return cwd ? { cwd } : {};
}
function refreshProvidersSnapshotRequest(
cwd: string | undefined,
providers: AgentProvider[] | undefined,
): { cwd?: string; providers?: AgentProvider[] } {
return {
...providersSnapshotRequest(cwd),
...(providers ? { providers } : {}),
};
}
interface UseProvidersSnapshotResult { interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined; entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean; isLoading: boolean;
@@ -27,7 +50,7 @@ interface UseProvidersSnapshotResult {
error: string | null; error: string | null;
supportsSnapshot: boolean; supportsSnapshot: boolean;
refresh: (providers?: AgentProvider[]) => Promise<void>; refresh: (providers?: AgentProvider[]) => Promise<void>;
refetchIfStale: () => void; refetchIfStale: (selectedProvider?: AgentProvider | null) => void;
} }
interface UseProvidersSnapshotOptions { interface UseProvidersSnapshotOptions {
@@ -62,7 +85,7 @@ export function useProvidersSnapshot(
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
} }
return client.getProvidersSnapshot({ cwd: normalizedCwd }); return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
}, },
}); });
@@ -71,7 +94,9 @@ export function useProvidersSnapshot(
if (!client) { if (!client) {
return; return;
} }
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers }); await client.refreshProvidersSnapshot(
refreshProvidersSnapshotRequest(normalizedCwd, providers),
);
}, },
}); });
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
@@ -85,8 +110,7 @@ export function useProvidersSnapshot(
if (message.type !== "providers_snapshot_update") { if (message.type !== "providers_snapshot_update") {
return; return;
} }
const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd); if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
if (messageCwdKey !== normalizedCwdKey) {
return; return;
} }
queryClient.setQueryData(queryKey, { queryClient.setQueryData(queryKey, {
@@ -94,11 +118,20 @@ export function useProvidersSnapshot(
generatedAt: message.payload.generatedAt, generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update", requestId: "providers_snapshot_update",
}); });
const shouldRefetch = message.payload.entries.some((entry) => entry.status === "loading");
if (shouldRefetch) {
void queryClient.invalidateQueries({
queryKey,
exact: true,
refetchType: "active",
});
}
}); });
}, [ }, [
client, client,
enabled, enabled,
isConnected, isConnected,
normalizedCwd,
normalizedCwdKey, normalizedCwdKey,
queryClient, queryClient,
queryKey, queryKey,
@@ -108,14 +141,36 @@ export function useProvidersSnapshot(
const refresh = useCallback( const refresh = useCallback(
async (providers?: AgentProvider[]) => { async (providers?: AgentProvider[]) => {
if (!client) {
return;
}
await refreshSnapshot(providers); await refreshSnapshot(providers);
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
queryClient.setQueryData(queryKey, snapshot);
}, },
[refreshSnapshot], [client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
); );
const refetchIfStale = useCallback(() => { const refetchIfStale = useCallback(
void queryClient.refetchQueries({ queryKey, type: "active", stale: true }); (selectedProvider?: AgentProvider | null) => {
}, [queryClient, queryKey]); if (!selectedProvider) {
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
return;
}
const selectedEntry = snapshotQuery.data?.entries.find(
(entry) => entry.provider === selectedProvider,
);
if (!selectedEntry || selectedEntry.status === "loading") {
void queryClient.refetchQueries({ queryKey, type: "active" });
return;
}
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
},
[queryClient, queryKey, snapshotQuery.data?.entries],
);
return { return {
entries: snapshotQuery.data?.entries ?? undefined, entries: snapshotQuery.data?.entries ?? undefined,
@@ -139,6 +194,6 @@ export function prefetchProvidersSnapshot(
void singletonQueryClient.prefetchQuery({ void singletonQueryClient.prefetchQuery({
queryKey, queryKey,
staleTime: 60_000, staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot({ cwd: normalizedCwd }), queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
}); });
} }
@@ -40,6 +40,16 @@ describe("use-settings", () => {
expect(result.theme).toBe("auto"); expect(result.theme).toBe("auto");
}); });
it("defaults release channel to stable when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("stable");
});
it("loads persisted built-in daemon management state", async () => { it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => { asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") { if (key === "@paseo:app-settings") {
@@ -58,7 +68,24 @@ describe("use-settings", () => {
theme: "light", theme: "light",
manageBuiltInDaemon: false, manageBuiltInDaemon: false,
sendBehavior: "interrupt", sendBehavior: "interrupt",
releaseChannel: "stable",
}); });
expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
}); });
it("loads persisted beta release channel", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
releaseChannel: "beta",
});
}
return null;
});
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("beta");
});
}); });
+10
View File
@@ -9,19 +9,23 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue"; export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
export interface AppSettings { export interface AppSettings {
theme: ThemeName | "auto"; theme: ThemeName | "auto";
manageBuiltInDaemon: boolean; manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior; sendBehavior: SendBehavior;
releaseChannel: ReleaseChannel;
} }
export const DEFAULT_APP_SETTINGS: AppSettings = { export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto", theme: "auto",
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
sendBehavior: "interrupt", sendBehavior: "interrupt",
releaseChannel: "stable",
}; };
export interface UseAppSettingsReturn { export interface UseAppSettingsReturn {
@@ -85,6 +89,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) { if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
parsed.theme = DEFAULT_APP_SETTINGS.theme; parsed.theme = DEFAULT_APP_SETTINGS.theme;
} }
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
}
return { ...DEFAULT_APP_SETTINGS, ...parsed }; return { ...DEFAULT_APP_SETTINGS, ...parsed };
} }
@@ -115,6 +122,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
if (typeof legacy.manageBuiltInDaemon === "boolean") { if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon; result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
} }
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
result.releaseChannel = legacy.releaseChannel;
}
return result; return result;
} }
@@ -4,7 +4,11 @@ import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model"; import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { export function useSidebarShortcutModel(input: {
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
}) {
const { projects, isInitialLoad } = input;
const collapsedProjectKeys = useSidebarCollapsedSectionsStore( const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys, (state) => state.collapsedProjectKeys,
); );
@@ -25,6 +29,10 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
); );
useEffect(() => { useEffect(() => {
if (isInitialLoad || projects.length === 0) {
return;
}
const collapsibleProjectKeys = new Set( const collapsibleProjectKeys = new Set(
projects projects
.filter((project) => !isSidebarProjectFlattened(project)) .filter((project) => !isSidebarProjectFlattened(project))
@@ -35,7 +43,7 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
setProjectCollapsed(key, false); setProjectCollapsed(key, false);
} }
} }
}, [collapsedProjectKeys, projects, setProjectCollapsed]); }, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
return { return {
collapsedProjectKeys, collapsedProjectKeys,
+18 -14
View File
@@ -7,6 +7,7 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
import { TerminalPane } from "@/components/terminal-pane"; import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { queryClient } from "@/query/query-client";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
@@ -30,21 +31,24 @@ function useTerminalPanelDescriptor(
const workspaceDirectory = workspaceAuthority.ok const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory ? workspaceAuthority.authority.workspaceDirectory
: null; : null;
const terminalsQuery = useQuery({ const terminalsQuery = useQuery(
queryKey: ["terminals", context.serverId, workspaceDirectory] as const, {
enabled: Boolean(client && workspaceDirectory), queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
queryFn: async (): Promise<ListTerminalsPayload> => { enabled: Boolean(client && workspaceDirectory),
if (!client || !workspaceDirectory) { queryFn: async (): Promise<ListTerminalsPayload> => {
throw new Error( if (!client || !workspaceDirectory) {
workspaceAuthority.ok throw new Error(
? "Workspace execution directory not found" workspaceAuthority.ok
: workspaceAuthority.message, ? "Workspace execution directory not found"
); : workspaceAuthority.message,
} );
return client.listTerminals(workspaceDirectory); }
return client.listTerminals(workspaceDirectory);
},
staleTime: 5_000,
}, },
staleTime: 5_000, queryClient,
}); );
const terminal = const terminal =
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null; terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
@@ -806,6 +806,9 @@ function DraftAgentScreenContent({
if (providerDefinitions.length === 0) { if (providerDefinitions.length === 0) {
return "No available providers on the selected host"; return "No available providers on the selected host";
} }
if (!composerState.selectedProvider) {
return "Select a model";
}
if (gitBlockingError) { if (gitBlockingError) {
return gitBlockingError; return gitBlockingError;
} }
@@ -846,6 +849,9 @@ function DraftAgentScreenContent({
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
"."; ".";
const provider = composerState.selectedProvider; const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const model = effectiveModelId || null; const model = effectiveModelId || null;
const thinkingOptionId = effectiveThinkingOptionId || null; const thinkingOptionId = effectiveThinkingOptionId || null;
const modeId = const modeId =
@@ -889,8 +895,12 @@ function DraftAgentScreenContent({
composerState.modeOptions.length > 0 && composerState.selectedMode !== "" composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode ? composerState.selectedMode
: undefined; : undefined;
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
provider: composerState.selectedProvider, provider,
cwd: resolvedWorkingDir, cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(effectiveModelId ? { model: effectiveModelId } : {}), ...(effectiveModelId ? { model: effectiveModelId } : {}),
@@ -326,6 +326,9 @@ export function NewWorkspaceScreen({
if (!composerState) { if (!composerState) {
throw new Error("Composer state is required"); throw new Error("Composer state is required");
} }
if (!composerState.selectedProvider) {
throw new Error("Select a model");
}
const initialPrompt = text.trim(); const initialPrompt = text.trim();
const encodedImages = await encodeImages(images); const encodedImages = await encodeImages(images);
+66 -38
View File
@@ -281,6 +281,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
} }
function DesktopAppUpdateRow() { function DesktopAppUpdateRow() {
const { settings, updateSettings } = useAppSettings();
const { const {
isDesktopApp, isDesktopApp,
statusText, statusText,
@@ -309,6 +310,13 @@ function DesktopAppUpdateRow() {
void checkForUpdates(); void checkForUpdates();
}, [checkForUpdates, isDesktopApp]); }, [checkForUpdates, isDesktopApp]);
const handleReleaseChannelChange = useCallback(
(releaseChannel: AppSettings["releaseChannel"]) => {
void updateSettings({ releaseChannel });
},
[updateSettings],
);
const handleInstallUpdate = useCallback(() => { const handleInstallUpdate = useCallback(() => {
if (!isDesktopApp) { if (!isDesktopApp) {
return; return;
@@ -316,7 +324,7 @@ function DesktopAppUpdateRow() {
void confirmDialog({ void confirmDialog({
title: "Install desktop update", title: "Install desktop update",
message: "This updates Paseo on this computer.", message: "This updates Paseo on this computer",
confirmLabel: "Install update", confirmLabel: "Install update",
cancelLabel: "Cancel", cancelLabel: "Cancel",
}) })
@@ -337,40 +345,59 @@ function DesktopAppUpdateRow() {
} }
return ( return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}> <>
<View style={settingsStyles.rowContent}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<Text style={settingsStyles.rowTitle}>App updates</Text> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowHint}>{statusText}</Text> <Text style={settingsStyles.rowTitle}>Release channel</Text>
{availableUpdate?.latestVersion ? (
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} Switch to Beta to get updates sooner and help shape them
</Text> </Text>
) : null} </View>
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null} <SegmentedControl
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm" size="sm"
onPress={handleCheckForUpdates} value={settings.releaseChannel}
disabled={isChecking || isInstalling} onValueChange={handleReleaseChannelChange}
> options={[
{isChecking ? "Checking..." : "Check"} { value: "stable", label: "Stable" },
</Button> { value: "beta", label: "Beta" },
<Button ]}
variant="default" />
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View> </View>
</View> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App updates</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View>
</View>
</>
); );
} }
@@ -858,9 +885,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
layout="desktop" layout="desktop"
/> />
<View style={desktopStyles.contentPane}> <View style={desktopStyles.contentPane}>
{detailHeader ? ( <ScreenHeader
<ScreenHeader borderless={!detailHeader}
left={ left={
detailHeader ? (
<> <>
<HeaderIconBadge> <HeaderIconBadge>
<detailHeader.Icon <detailHeader.Icon
@@ -873,10 +901,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
</ScreenTitle> </ScreenTitle>
{detailHeader.titleAccessory} {detailHeader.titleAccessory}
</> </>
} ) : null
leftStyle={desktopStyles.detailLeft} }
/> leftStyle={desktopStyles.detailLeft}
) : null} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom }} contentContainerStyle={{ paddingBottom: insets.bottom }}
@@ -114,7 +114,7 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
return ( return (
<View testID={`settings-host-page-${serverId}`}> <View testID={`settings-host-page-${serverId}`}>
<View style={[settingsStyles.card, styles.emptyCard]}> <View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Host not found.</Text> <Text style={styles.emptyText}>Host not found</Text>
</View> </View>
</View> </View>
); );
@@ -591,7 +591,7 @@ function PairDeviceRow() {
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair a device</Text> <Text style={settingsStyles.rowTitle}>Pair a device</Text>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
Scan a QR code or copy a link to connect your phone to this host. Scan a QR code or copy a link to connect your phone to this host
</Text> </Text>
</View> </View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -619,7 +619,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Remove host</Text> <Text style={settingsStyles.rowTitle}>Remove host</Text>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
Removes this host and its saved connections from this device. Removes this host and its saved connections from this device
</Text> </Text>
</View> </View>
<Button <Button
@@ -179,7 +179,7 @@ export function KeyboardShortcutsSection() {
return ( return (
<SettingsSection title="Shortcuts"> <SettingsSection title="Shortcuts">
<View style={[settingsStyles.card, styles.mobileCard]}> <View style={[settingsStyles.card, styles.mobileCard]}>
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop.</Text> <Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop</Text>
</View> </View>
</SettingsSection> </SettingsSection>
); );
@@ -7,9 +7,10 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { RotateCw } from "lucide-react-native";
export interface ProvidersSectionProps { export interface ProvidersSectionProps {
serverId: string; serverId: string;
@@ -33,13 +34,13 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
hitSlop={8} hitSlop={8}
style={settingsStyles.sectionHeaderLink} style={settingsStyles.sectionHeaderLink}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Refresh providers" accessibilityLabel={providerRefreshInFlight ? "Refreshing providers" : "Refresh providers"}
> >
<SpinningRefreshIcon {providerRefreshInFlight ? (
spinning={providerRefreshInFlight} <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
size={theme.iconSize.sm} ) : (
color={theme.colors.foregroundMuted} <RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
/> )}
</Pressable> </Pressable>
) : undefined; ) : undefined;
@@ -97,6 +97,9 @@ export function WorkspaceDraftAgentTab({
if (composerState.providerDefinitions.length === 0) { if (composerState.providerDefinitions.length === 0) {
return "No available providers on the selected host"; return "No available providers on the selected host";
} }
if (!composerState.selectedProvider) {
return "Select a model";
}
if (composerState.isModelLoading) { if (composerState.isModelLoading) {
return "Model defaults are still loading"; return "Model defaults are still loading";
} }
@@ -127,10 +130,14 @@ export function WorkspaceDraftAgentTab({
composerState.modeOptions.length > 0 && composerState.selectedMode !== "" composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode ? composerState.selectedMode
: null; : null;
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
return { return {
serverId, serverId,
id: tabId, id: tabId,
provider: composerState.selectedProvider, provider,
status: "running", status: "running",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -141,7 +148,7 @@ export function WorkspaceDraftAgentTab({
availableModes: [], availableModes: [],
pendingPermissions: [], pendingPermissions: [],
persistence: null, persistence: null,
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId }, runtimeInfo: { provider, sessionId: null, model, modeId },
title: "Agent", title: "Agent",
cwd: workspaceDirectory, cwd: workspaceDirectory,
model, model,
@@ -157,8 +164,12 @@ export function WorkspaceDraftAgentTab({
throw new Error("Host is not connected"); throw new Error("Host is not connected");
} }
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const config = buildWorkspaceDraftAgentConfig({ const config = buildWorkspaceDraftAgentConfig({
provider: composerState.selectedProvider, provider,
cwd: workspaceDirectory, cwd: workspaceDirectory,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode } ? { modeId: composerState.selectedMode }
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
describe("sidebar-collapsed-sections-store", () => {
beforeEach(() => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(),
});
});
it("tracks collapsed project keys as a Set", () => {
const store = useSidebarCollapsedSectionsStore.getState();
store.setProjectCollapsed("project-a", true);
store.toggleProjectCollapsed("project-b");
store.toggleProjectCollapsed("project-a");
expect(Array.from(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys)).toEqual([
"project-b",
]);
});
it("serializes collapsed project keys for preference storage", () => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(["project-a", "project-b"]),
});
const partialize = useSidebarCollapsedSectionsStore.persist.getOptions().partialize;
expect(partialize?.(useSidebarCollapsedSectionsStore.getState())).toEqual({
collapsedProjectKeys: ["project-a", "project-b"],
});
});
it("restores collapsed project keys from persisted preferences", () => {
const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge;
const restored = merge?.(
{
collapsedProjectKeys: ["project-a", "project-b", 42],
},
useSidebarCollapsedSectionsStore.getState(),
);
expect(Array.from(restored?.collapsedProjectKeys ?? [])).toEqual(["project-a", "project-b"]);
});
it("keeps the existing state object when persisted preferences do not change collapsed keys", () => {
const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge;
const currentState = useSidebarCollapsedSectionsStore.getState();
expect(merge?.({}, currentState)).toBe(currentState);
expect(merge?.({ collapsedProjectKeys: [] }, currentState)).toBe(currentState);
});
});
@@ -1,4 +1,6 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface SidebarCollapsedSectionsState { interface SidebarCollapsedSectionsState {
collapsedProjectKeys: Set<string>; collapsedProjectKeys: Set<string>;
@@ -6,26 +8,84 @@ interface SidebarCollapsedSectionsState {
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
} }
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>((set) => ({ type PersistedSidebarCollapsedSectionsState = {
collapsedProjectKeys: new Set(), collapsedProjectKeys?: string[];
toggleProjectCollapsed: (projectKey) => };
set((state) => {
const next = new Set(state.collapsedProjectKeys); function serializeCollapsedProjectKeys(keys: Set<string>): string[] {
if (next.has(projectKey)) { return Array.from(keys);
next.delete(projectKey); }
} else {
next.add(projectKey); function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
} if (!Array.isArray(value)) {
return { collapsedProjectKeys: next }; return new Set();
}
return new Set(value.filter((key): key is string => typeof key === "string"));
}
function areSetsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const key of left) {
if (!right.has(key)) {
return false;
}
}
return true;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
persist(
(set) => ({
collapsedProjectKeys: new Set(),
toggleProjectCollapsed: (projectKey) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return { collapsedProjectKeys: next };
}),
setProjectCollapsed: (projectKey, collapsed) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (collapsed) {
next.add(projectKey);
} else {
next.delete(projectKey);
}
return { collapsedProjectKeys: next };
}),
}), }),
setProjectCollapsed: (projectKey, collapsed) => {
set((state) => { name: "sidebar-collapsed-sections",
const next = new Set(state.collapsedProjectKeys); storage: createJSONStorage(() => AsyncStorage),
if (collapsed) { partialize: (state) => ({
next.add(projectKey); collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys),
} else { }),
next.delete(projectKey); merge: (persistedState, currentState) => {
} const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined;
return { collapsedProjectKeys: next }; if (!persisted?.collapsedProjectKeys) {
}), return currentState;
})); }
const collapsedProjectKeys = deserializeCollapsedProjectKeys(
persisted.collapsedProjectKeys,
);
if (areSetsEqual(currentState.collapsedProjectKeys, collapsedProjectKeys)) {
return currentState;
}
return {
...currentState,
collapsedProjectKeys,
};
},
},
),
);
+56 -48
View File
@@ -19,58 +19,66 @@ type RawWindowControlsPadding = {
type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar"; type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar";
// Module-level cache so hook remounts (e.g., on navigation) don't briefly
// fall back to the default `false` while the async fullscreen check resolves.
// Without this, in fullscreen the sidebar flashes with traffic-light padding
// on first frame and then snaps to 0 once the async read completes.
let cachedIsFullscreen = false;
const fullscreenSubscribers = new Set<(value: boolean) => void>();
let fullscreenSubscriptionStarted = false;
function setCachedFullscreen(value: boolean) {
if (cachedIsFullscreen === value) return;
cachedIsFullscreen = value;
for (const sub of fullscreenSubscribers) {
sub(value);
}
}
function startFullscreenSubscription() {
if (fullscreenSubscriptionStarted) return;
if (isNative || !getIsElectronRuntime()) return;
fullscreenSubscriptionStarted = true;
void (async () => {
const win = getDesktopWindow();
if (!win) return;
if (typeof win.isFullscreen === "function") {
try {
setCachedFullscreen(await win.isFullscreen());
} catch (error) {
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
}
}
if (typeof win.onResized !== "function") return;
try {
await win.onResized(async () => {
if (typeof win.isFullscreen !== "function") return;
try {
setCachedFullscreen(await win.isFullscreen());
} catch (error) {
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
}
});
} catch (error) {
console.warn("[DesktopWindow] Failed to subscribe to resize", error);
}
})();
}
function useRawWindowControlsPadding(): RawWindowControlsPadding { function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen);
useEffect(() => { useEffect(() => {
if (isNative || !getIsElectronRuntime()) return; startFullscreenSubscription();
// Sync to any value that resolved between render and effect.
let disposed = false; setIsFullscreen(cachedIsFullscreen);
let cleanup: (() => void) | undefined; fullscreenSubscribers.add(setIsFullscreen);
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = getDesktopWindow();
if (!win) return;
const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fullscreen);
if (typeof win.onResized !== "function") {
return;
}
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
void setup();
return () => { return () => {
disposed = true; fullscreenSubscribers.delete(setIsFullscreen);
runCleanup();
}; };
}, []); }, []);
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-rc.3", "@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.3", "@getpaseo/server": "0.1.60-beta.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js", "main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.60-rc.3", "@getpaseo/cli": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.3", "@getpaseo/server": "0.1.60-beta.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
+3 -2
View File
@@ -8,8 +8,9 @@ $RootDir = (Resolve-Path "$DesktopDir\..\..").Path
# Build the Electron main process # Build the Electron main process
npm run build:main npm run build:main
# Get a random available port for Metro # Prefer Metro's stable default port so dev browser storage keeps the same
$env:EXPO_PORT = (npx get-port-cli).Trim() # localhost origin across restarts. Fall back only when earlier ports are busy.
$env:EXPO_PORT = (npx get-port-cli 8081 8082 8083 8084 8085).Trim()
# Set EXPO_DEV_URL in the environment so Electron inherits it # Set EXPO_DEV_URL in the environment so Electron inherits it
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" $env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
+3 -2
View File
@@ -9,8 +9,9 @@ ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
# Build the Electron main process # Build the Electron main process
npm run build:main npm run build:main
# Get a random available port for Metro # Prefer Metro's stable default port so dev browser storage keeps the same
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port") # localhost origin across restarts. Fall back only when earlier ports are busy.
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085)
export EXPO_PORT export EXPO_PORT
# Allow any origin in dev so Electron on random localhost ports can reach # Allow any origin in dev so Electron on random localhost ports can reach
+18 -7
View File
@@ -11,7 +11,11 @@ import {
readManagedFileBase64, readManagedFileBase64,
writeAttachmentBase64, writeAttachmentBase64,
} from "../features/attachments.js"; } from "../features/attachments.js";
import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js"; import {
checkForAppUpdate,
downloadAndInstallUpdate,
type AppReleaseChannel,
} from "../features/auto-updater.js";
import { import {
installCli, installCli,
getCliInstallStatus, getCliInstallStatus,
@@ -65,6 +69,10 @@ type DesktopPairingOffer = {
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown; type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
function parseReleaseChannel(args: Record<string, unknown> | undefined): AppReleaseChannel {
return args?.releaseChannel === "beta" ? "beta" : "stable";
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Utilities // Utilities
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -483,15 +491,18 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
: ""; : "";
if (sessionId) closeLocalTransportSession(sessionId); if (sessionId) closeLocalTransportSession(sessionId);
}, },
check_app_update: async () => { check_app_update: async (args) => {
const currentVersion = await resolveCurrentUpdateVersion(); const currentVersion = await resolveCurrentUpdateVersion();
return checkForAppUpdate(currentVersion); return checkForAppUpdate({ currentVersion, releaseChannel: parseReleaseChannel(args) });
}, },
install_app_update: async () => { install_app_update: async (args) => {
const currentVersion = await resolveCurrentUpdateVersion(); const currentVersion = await resolveCurrentUpdateVersion();
return downloadAndInstallUpdate(currentVersion, async () => { return downloadAndInstallUpdate(
await stopDaemon(); { currentVersion, releaseChannel: parseReleaseChannel(args) },
}); async () => {
await stopDaemon();
},
);
}, },
get_local_daemon_version: () => getLocalDaemonVersion(), get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(), install_cli: () => installCli(),
+30 -5
View File
@@ -20,6 +20,8 @@ export type AppUpdateInstallResult = {
message: string; message: string;
}; };
export type AppReleaseChannel = "stable" | "beta";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// State // State
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -28,18 +30,29 @@ let cachedUpdateInfo: UpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null; let downloadedUpdateVersion: string | null = null;
let downloading = false; let downloading = false;
let autoUpdaterConfigured = false; let autoUpdaterConfigured = false;
let configuredReleaseChannel: AppReleaseChannel | null = null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Configuration // Configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function configureAutoUpdater(): void { function configureAutoUpdater(releaseChannel: AppReleaseChannel): void {
// Download updates in the background and only prompt once they are ready to install. // Download updates in the background and only prompt once they are ready to install.
autoUpdater.autoDownload = true; autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
// Suppress built-in dialogs; the renderer handles UI. // Suppress built-in dialogs; the renderer handles UI.
autoUpdater.autoRunAppAfterInstall = true; autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.allowPrerelease = releaseChannel === "beta";
autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest";
autoUpdater.allowDowngrade = false;
if (configuredReleaseChannel !== releaseChannel) {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
configuredReleaseChannel = releaseChannel;
}
if (autoUpdaterConfigured) { if (autoUpdaterConfigured) {
return; return;
@@ -109,7 +122,13 @@ function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
// Public API // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> { export async function checkForAppUpdate({
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
}): Promise<AppUpdateCheckResult> {
if (!app.isPackaged) { if (!app.isPackaged) {
return buildCheckResult({ return buildCheckResult({
currentVersion, currentVersion,
@@ -118,7 +137,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
}); });
} }
configureAutoUpdater(); configureAutoUpdater(releaseChannel);
const cachedVersion = cachedUpdateInfo?.version ?? null; const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) { if (cachedVersion && cachedVersion !== currentVersion) {
@@ -176,7 +195,13 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
} }
export async function downloadAndInstallUpdate( export async function downloadAndInstallUpdate(
currentVersion: string, {
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>, onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> { ): Promise<AppUpdateInstallResult> {
if (!app.isPackaged) { if (!app.isPackaged) {
@@ -195,7 +220,7 @@ export async function downloadAndInstallUpdate(
}; };
} }
configureAutoUpdater(); configureAutoUpdater(releaseChannel);
const readyVersion = cachedUpdateInfo.version; const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) { if (isReadyToInstallVersion(readyVersion)) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"main": "build/index.js", "main": "build/index.js",
"types": "build/index.d.ts", "types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-rc.3", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-rc.3", "@getpaseo/relay": "0.1.60-beta.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@mariozechner/pi-coding-agent": "^0.67.68", "@mariozechner/pi-coding-agent": "^0.67.68",
@@ -3,6 +3,7 @@ import pino from "pino";
import { DaemonClient } from "../src/server/test-utils/daemon-client.js"; import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js"; import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
import { withTimeout } from "../src/utils/promise-timeout.js";
type RoundTripResult = { type RoundTripResult = {
voiceAgentId: string; voiceAgentId: string;
@@ -10,10 +11,6 @@ type RoundTripResult = {
audioChunks: number; audioChunks: number;
}; };
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> { async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
@@ -103,13 +100,7 @@ async function runVoiceRoundTrip(params: {
} }
try { try {
await Promise.race([ await withTimeout(done, params.timeoutMs, "Timed out waiting for audio_output");
done,
(async () => {
await sleep(params.timeoutMs);
throw new Error("Timed out waiting for audio_output");
})(),
]);
} finally { } finally {
offStream(); offStream();
await client.setVoiceMode(false).catch(() => undefined); await client.setVoiceMode(false).catch(() => undefined);
@@ -16,6 +16,7 @@ import type {
import { buildVoiceAgentMcpServerConfig } from "../src/server/session.js"; import { buildVoiceAgentMcpServerConfig } from "../src/server/session.js";
import { createAgentMcpServer } from "../src/server/agent/mcp-server.js"; import { createAgentMcpServer } from "../src/server/agent/mcp-server.js";
import { createVoiceMcpSocketBridgeManager } from "../src/server/voice-mcp-bridge.js"; import { createVoiceMcpSocketBridgeManager } from "../src/server/voice-mcp-bridge.js";
import { withTimeout } from "../src/utils/promise-timeout.js";
type CliOptions = { type CliOptions = {
provider: AgentProvider; provider: AgentProvider;
@@ -54,17 +55,13 @@ async function streamWithTimeout(
timeoutMs: number, timeoutMs: number,
onEvent: (event: AgentStreamEvent) => Promise<void> | void, onEvent: (event: AgentStreamEvent) => Promise<void> | void,
): Promise<void> { ): Promise<void> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
const consume = (async () => { const consume = (async () => {
for await (const event of iterator) { for await (const event of iterator) {
await onEvent(event); await onEvent(event);
} }
})(); })();
await Promise.race([consume, timeout]); await withTimeout(consume, timeoutMs, `Timed out after ${timeoutMs}ms`);
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -6,6 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
import { DaemonClient } from "../src/server/test-utils/daemon-client.js"; import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js"; import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
import { withTimeout } from "../src/utils/promise-timeout.js";
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> { async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -151,18 +152,8 @@ async function main(): Promise<void> {
); );
} }
await Promise.race([ await withTimeout(firstAudio, 120000, "Timed out waiting for first audio_output");
firstAudio, await withTimeout(lastAudio, 120000, "Timed out waiting for final audio_output");
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for first audio_output")), 120000),
),
]);
await Promise.race([
lastAudio,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for final audio_output")), 120000),
),
]);
console.log("success", { audioChunkCount }); console.log("success", { audioChunkCount });
await client.setVoiceMode(false); await client.setVoiceMode(false);
@@ -2745,7 +2745,7 @@ export class AgentManager {
const client = this.clients.get(normalized.provider); const client = this.clients.get(normalized.provider);
if (client) { if (client) {
try { try {
const models = await client.listModels(); const models = await client.listModels({ cwd: normalized.cwd, force: false });
const defaultModel = models.find((model) => model.isDefault) ?? models[0]; const defaultModel = models.find((model) => model.isDefault) ?? models[0];
if (defaultModel) { if (defaultModel) {
normalized.model = defaultModel.id; normalized.model = defaultModel.id;
@@ -8,6 +8,7 @@ import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import pino from "pino"; import pino from "pino";
import { withTimeout } from "../../utils/promise-timeout.js";
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
@@ -23,26 +24,6 @@ type McpClient = {
close: () => Promise<void>; close: () => Promise<void>;
}; };
async function withTimeout<T>(options: {
promise: Promise<T>;
timeoutMs: number;
label: string;
}): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function waitForPathExists(options: { async function waitForPathExists(options: {
targetPath: string; targetPath: string;
timeoutMs: number; timeoutMs: number;
@@ -151,7 +132,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: { args: {
cwd: agentCwd, cwd: agentCwd,
title: "MCP e2e smoke", title: "MCP e2e smoke",
provider: "claude", provider: "claude/claude-test-model",
mode: "bypassPermissions", mode: "bypassPermissions",
initialPrompt, initialPrompt,
background: false, background: false,
@@ -242,7 +223,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: { args: {
cwd: agentCwd, cwd: agentCwd,
title: "Injected MCP", title: "Injected MCP",
provider: "claude", provider: "claude/claude-test-model",
mode: "bypassPermissions", mode: "bypassPermissions",
initialPrompt: "reply with done and stop", initialPrompt: "reply with done and stop",
background: true, background: true,
@@ -265,7 +246,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: { args: {
cwd: disabledAgentCwd, cwd: disabledAgentCwd,
title: "No injected MCP", title: "No injected MCP",
provider: "claude", provider: "claude/claude-test-model",
mode: "bypassPermissions", mode: "bypassPermissions",
initialPrompt: "reply with done and stop", initialPrompt: "reply with done and stop",
background: true, background: true,
@@ -362,7 +343,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: { args: {
cwd: repoRoot, cwd: repoRoot,
title: "MCP worktree setup terminals", title: "MCP worktree setup terminals",
provider: "claude", provider: "claude/claude-test-model",
mode: "bypassPermissions", mode: "bypassPermissions",
initialPrompt: "say done and stop", initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test", worktreeName: "mcp-worktree-setup-test",
@@ -1,4 +1,4 @@
import type { AgentSnapshotPayload } from "../messages.js"; import type { AgentListItemPayload, AgentSnapshotPayload } from "../messages.js";
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js"; import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
@@ -31,6 +31,17 @@ function normalizeThinkingOptionId(value: string | null | undefined): string | n
return normalized.length > 0 ? normalized : null; return normalized.length > 0 ? normalized : null;
} }
function normalizeLabels(labels: Record<string, unknown> | undefined): Record<string, string> {
if (!labels) {
return {};
}
return Object.fromEntries(
Object.entries(labels).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
export function resolveEffectiveThinkingOptionId(options: { export function resolveEffectiveThinkingOptionId(options: {
runtimeInfo?: AgentRuntimeInfo | null; runtimeInfo?: AgentRuntimeInfo | null;
configuredThinkingOptionId?: string | null; configuredThinkingOptionId?: string | null;
@@ -198,7 +209,29 @@ export function buildStoredAgentPayload(
attentionReason: record.attentionReason ?? null, attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null, attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null, archivedAt: record.archivedAt ?? null,
labels: record.labels, labels: normalizeLabels(record.labels),
};
}
export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListItemPayload {
return {
id: agent.id,
shortId: agent.id.slice(0, 7),
title: agent.title,
provider: agent.provider,
model: agent.runtimeInfo?.model ?? agent.model,
thinkingOptionId: agent.thinkingOptionId,
effectiveThinkingOptionId: agent.effectiveThinkingOptionId,
status: agent.status,
cwd: agent.cwd,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
lastUserMessageAt: agent.lastUserMessageAt,
archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp ?? null,
labels: agent.labels,
}; };
} }
@@ -490,11 +490,13 @@ export interface AgentSession {
} }
export interface ListModelsOptions { export interface ListModelsOptions {
cwd?: string; cwd: string;
force: boolean;
} }
export interface ListModesOptions { export interface ListModesOptions {
cwd?: string; cwd: string;
force: boolean;
} }
export interface AgentClient { export interface AgentClient {
@@ -509,8 +511,8 @@ export interface AgentClient {
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
): Promise<AgentSession>; ): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>; listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModes?(options?: ListModesOptions): Promise<AgentMode[]>; listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
/** /**
* Check if this provider is available (CLI binary is installed). * Check if this provider is available (CLI binary is installed).
@@ -121,7 +121,7 @@ describe("MCP parity end-to-end", () => {
const payload = await callToolStructured(topLevelClient, "create_agent", { const payload = await callToolStructured(topLevelClient, "create_agent", {
cwd, cwd,
title: "Parity agent", title: "Parity agent",
provider: "claude", provider: "claude/claude-test-model",
initialPrompt: "say done and stop", initialPrompt: "say done and stop",
mode: "bypassPermissions", mode: "bypassPermissions",
background: true, background: true,
@@ -133,7 +133,7 @@ describe("MCP parity end-to-end", () => {
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> { async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
const payload = await callToolStructured(agentScopedClient, "create_agent", { const payload = await callToolStructured(agentScopedClient, "create_agent", {
title: "Parity child", title: "Parity child",
provider: "claude", provider: "claude/claude-test-model",
initialPrompt: "say done and stop", initialPrompt: "say done and stop",
background: true, background: true,
...args, ...args,
@@ -207,7 +207,7 @@ describe("MCP parity end-to-end", () => {
const parentPayload = await callToolStructured(topLevelClient, "create_agent", { const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
cwd: parentAgentCwd, cwd: parentAgentCwd,
title: "MCP parity parent", title: "MCP parity parent",
provider: "claude", provider: "claude/claude-test-model",
initialPrompt: "say done and stop", initialPrompt: "say done and stop",
mode: "bypassPermissions", mode: "bypassPermissions",
background: true, background: true,
@@ -294,10 +294,10 @@ describe("MCP parity end-to-end", () => {
} }
}); });
test("create_agent accepts model param", async () => { test("create_agent accepts provider/model syntax", async () => {
let agentId: string | null = null; let agentId: string | null = null;
try { try {
agentId = await createTopLevelAgent({ model: "claude-test-model" }); agentId = await createTopLevelAgent({ provider: "claude/claude-test-model" });
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.config.model).toBe("claude-test-model"); expect(snapshot?.config.model).toBe("claude-test-model");
} finally { } finally {
File diff suppressed because it is too large Load Diff
+158 -37
View File
@@ -9,10 +9,16 @@ import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
import { import {
AgentPermissionRequestPayloadSchema, AgentPermissionRequestPayloadSchema,
AgentListItemPayloadSchema,
AgentPermissionResponseSchema, AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema, AgentSnapshotPayloadSchema,
} from "../messages.js"; } from "../messages.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js"; import type { AgentListItemPayload } from "../messages.js";
import {
buildStoredAgentPayload,
toAgentListItemPayload,
toAgentPayload,
} from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js"; import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js"; import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js"; import { ensureAgentLoaded } from "./agent-loading.js";
@@ -24,20 +30,21 @@ import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.j
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js"; import { captureTerminalLines } from "../../terminal/terminal.js";
import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScheduleService } from "../schedule/service.js"; import type { ScheduleService } from "../schedule/service.js";
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
import type { ProviderDefinition } from "./provider-registry.js"; import type { ProviderDefinition } from "./provider-registry.js";
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
import { import {
AgentModelSchema, AgentModelSchema,
AgentProviderEnum, AgentProviderEnum,
AgentStatusEnum, AgentStatusEnum,
ProviderSummarySchema, ProviderSummarySchema,
parseDurationString, parseDurationString,
resolveProviderAndModel, resolveRequiredProviderModel,
sanitizePermissionRequest, sanitizePermissionRequest,
sendPromptToAgent, sendPromptToAgent,
setupFinishNotification, setupFinishNotification,
@@ -125,6 +132,68 @@ function mapModeAcrossProviders(
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>; type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function parseTimestamp(value: string | null | undefined): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function resolveAgentListActivityTime(agent: AgentListItemPayload): number {
return Math.max(
parseTimestamp(agent.updatedAt),
parseTimestamp(agent.lastUserMessageAt),
parseTimestamp(agent.attentionTimestamp),
parseTimestamp(agent.archivedAt),
parseTimestamp(agent.createdAt),
);
}
function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number {
const attentionDelta =
Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false);
if (attentionDelta !== 0) {
return attentionDelta;
}
const statusOrder = {
running: 0,
initializing: 1,
idle: 2,
error: 3,
closed: 4,
} as Record<string, number>;
const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999);
if (statusDelta !== 0) {
return statusDelta;
}
return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a);
}
function resolveScheduleProviderAndModel(params: {
provider?: string;
defaultProvider: AgentProvider;
}): { provider: AgentProvider; model?: string } {
const providerInput = params.provider?.trim() || params.defaultProvider;
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return { provider: providerInput as AgentProvider };
}
const provider = providerInput.slice(0, slashIndex).trim();
const model = providerInput.slice(slashIndex + 1).trim();
if (!provider || !model) {
throw new Error("provider must be <provider> or <provider>/<model>");
}
return {
provider: provider as AgentProvider,
model,
};
}
function resolveChildAgentCwd(params: { function resolveChildAgentCwd(params: {
parentCwd: string; parentCwd: string;
requestedCwd?: string; requestedCwd?: string;
@@ -256,7 +325,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (callerAgent) { if (callerAgent) {
const hasProviderOverride = params?.provider !== undefined; const hasProviderOverride = params?.provider !== undefined;
const resolvedProviderModel = hasProviderOverride const resolvedProviderModel = hasProviderOverride
? resolveProviderAndModel({ ? resolveScheduleProviderAndModel({
provider: params?.provider, provider: params?.provider,
defaultProvider: callerAgent.provider, defaultProvider: callerAgent.provider,
}) })
@@ -306,21 +375,34 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}; };
} }
const resolvedProviderModel = resolveScheduleProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
});
return { return {
type: "new-agent" as const, type: "new-agent" as const,
config: (() => { config: {
const resolvedProviderModel = resolveProviderAndModel({ provider: resolvedProviderModel.provider,
provider: params?.provider, cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
defaultProvider: "claude", ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
}); },
return {
provider: resolvedProviderModel.provider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
};
})(),
}; };
}; };
const ProviderModelInputSchema = AgentProviderEnum.trim()
.refine((value) => value.includes("/"), {
message: "provider must be provider/model, for example codex/gpt-5.4",
})
.refine(
(value) => {
try {
resolveRequiredProviderModel(value);
return true;
} catch {
return false;
}
},
{ message: "provider must be provider/model, for example codex/gpt-5.4" },
);
const agentToAgentInputSchema = { const agentToAgentInputSchema = {
cwd: z cwd: z
.string() .string()
@@ -332,10 +414,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.min(1, "Title is required") .min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer") .max(60, "Title must be 60 characters or fewer")
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
provider: AgentProviderEnum.optional().describe( provider: ProviderModelInputSchema.describe(
"Optional agent implementation to spawn. Defaults to 'claude'.", "Required provider/model pair, for example codex/gpt-5.4.",
), ),
model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
thinking: z.string().optional().describe("Thinking option ID"), thinking: z.string().optional().describe("Thinking option ID"),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z initialPrompt: z
@@ -369,10 +450,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.min(1, "Title is required") .min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer") .max(60, "Title must be 60 characters or fewer")
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
provider: AgentProviderEnum.optional().describe( provider: ProviderModelInputSchema.describe(
"Optional agent implementation to spawn. Defaults to 'claude'.", "Required provider/model pair, for example codex/gpt-5.4.",
), ),
model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
thinking: z.string().optional().describe("Thinking option ID"), thinking: z.string().optional().describe("Thinking option ID"),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z initialPrompt: z
@@ -420,8 +500,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}; };
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema); const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema); const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
server.registerTool( server.registerTool(
@@ -471,7 +551,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{ {
title: "Create agent", title: "Create agent",
description: description:
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.", "Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Optionally run an initial prompt immediately or create a git worktree for the agent.",
inputSchema: createAgentInputSchema, inputSchema: createAgentInputSchema,
outputSchema: { outputSchema: {
agentId: z.string(), agentId: z.string(),
@@ -507,11 +587,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (callerAgentId) { if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args); const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
provider = callerArgs.provider ?? "claude"; const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider);
provider = resolvedProviderModel.provider;
model = resolvedProviderModel.model;
initialPrompt = callerArgs.initialPrompt; initialPrompt = callerArgs.initialPrompt;
background = callerArgs.background ?? false; background = callerArgs.background ?? false;
normalizedTitle = callerArgs.title.trim(); normalizedTitle = callerArgs.title.trim();
model = callerArgs.model;
thinking = callerArgs.thinking; thinking = callerArgs.thinking;
labels = callerArgs.labels; labels = callerArgs.labels;
notifyOnFinish = callerArgs.notifyOnFinish ?? false; notifyOnFinish = callerArgs.notifyOnFinish ?? false;
@@ -532,11 +613,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
} }
} else { } else {
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
provider = topLevelArgs.provider ?? "claude"; const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
provider = resolvedProviderModel.provider;
model = resolvedProviderModel.model;
initialPrompt = topLevelArgs.initialPrompt; initialPrompt = topLevelArgs.initialPrompt;
background = topLevelArgs.background ?? false; background = topLevelArgs.background ?? false;
normalizedTitle = topLevelArgs.title.trim(); normalizedTitle = topLevelArgs.title.trim();
model = topLevelArgs.model;
thinking = topLevelArgs.thinking; thinking = topLevelArgs.thinking;
labels = topLevelArgs.labels; labels = topLevelArgs.labels;
notifyOnFinish = topLevelArgs.notifyOnFinish ?? false; notifyOnFinish = topLevelArgs.notifyOnFinish ?? false;
@@ -928,15 +1010,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"list_agents", "list_agents",
{ {
title: "List agents", title: "List agents",
description: "List all live agents managed by the server.", description: "List recent agents as compact metadata.",
inputSchema: { inputSchema: {
includeArchived: z.boolean().optional().default(false), includeArchived: z.boolean().optional().default(false),
cwd: z.string().optional(),
sinceHours: z
.number()
.int()
.positive()
.max(24 * 30)
.optional()
.default(48),
statuses: z.array(AgentStatusEnum).optional(),
limit: z.number().int().positive().max(200).optional().default(50),
}, },
outputSchema: { outputSchema: {
agents: z.array(AgentSnapshotPayloadSchema), agents: z.array(AgentListItemPayloadSchema),
}, },
}, },
async ({ includeArchived }) => { async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => {
const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined;
const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd;
const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null;
const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000;
const liveSnapshots = agentManager.listAgents(); const liveSnapshots = agentManager.listAgents();
const liveAgents = await Promise.all( const liveAgents = await Promise.all(
liveSnapshots.map((snapshot) => liveSnapshots.map((snapshot) =>
@@ -949,10 +1045,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.filter((record) => !record.internal && !liveIds.has(record.id)) .filter((record) => !record.internal && !liveIds.has(record.id))
.filter((record) => includeArchived || !record.archivedAt) .filter((record) => includeArchived || !record.archivedAt)
.map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger)); .map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger));
const agents = [...liveAgents, ...storedAgents]
.map(toAgentListItemPayload)
.filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd))
.filter((agent) => !statusFilter || statusFilter.has(agent.status))
.filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs)
.sort(compareAgentListItems)
.slice(0, limit);
return { return {
content: [], content: [],
structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }), structuredContent: ensureValidJson({ agents }),
}; };
}, },
); );
@@ -1296,15 +1399,33 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const scheduleTarget = const scheduleTarget =
target === "self" target === "self"
? (() => { ? (() => {
if (!callerAgentId) { const callerAgent = resolveCallerAgent();
if (!callerAgentId || !callerAgent) {
throw new Error("target=self requires a caller agent"); throw new Error("target=self requires a caller agent");
} }
if (provider !== undefined || cwd !== undefined) { const trimmedCwd = cwd?.trim();
throw new Error("provider and cwd can only be used with target=new-agent"); if (trimmedCwd && expandUserPath(trimmedCwd) !== callerAgent.cwd) {
throw new Error("cwd can only differ from the caller agent when target=new-agent");
}
if (provider !== undefined) {
const resolved = resolveScheduleProviderAndModel({
provider,
defaultProvider: callerAgent.provider,
});
if (
resolved.provider !== callerAgent.provider ||
(resolved.model !== undefined && resolved.model !== callerAgent.config.model)
) {
throw new Error(
"provider can only differ from the caller agent when target=new-agent",
);
}
} }
return { type: "agent" as const, agentId: callerAgentId }; return { type: "agent" as const, agentId: callerAgentId };
})() })()
: resolveNewAgentScheduleTarget({ provider, cwd }); : (() => {
return resolveNewAgentScheduleTarget({ provider, cwd });
})();
const schedule = await scheduleService.create({ const schedule = await scheduleService.create({
prompt: prompt.trim(), prompt: prompt.trim(),
@@ -1498,7 +1619,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
throw new Error(`Provider ${provider} is not configured`); throw new Error(`Provider ${provider} is not configured`);
} }
const models = await definition.fetchModels(); const models = await definition.fetchModels({ cwd: resolveSnapshotCwd(), force: false });
return { return {
content: [], content: [],
structuredContent: ensureValidJson({ structuredContent: ensureValidJson({
+12 -29
View File
@@ -42,7 +42,7 @@ export const AgentSelectOptionSchema = z
label: z.string().nullish(), label: z.string().nullish(),
description: z.string().nullish(), description: z.string().nullish(),
isDefault: z.boolean().nullish(), isDefault: z.boolean().nullish(),
metadata: z.record(z.unknown()).nullish(), metadata: z.record(z.string(), z.unknown()).nullish(),
}) })
.passthrough(); .passthrough();
@@ -53,7 +53,7 @@ export const AgentModelSchema = z
label: z.string().nullish(), label: z.string().nullish(),
description: z.string().nullish(), description: z.string().nullish(),
isDefault: z.boolean().nullish(), isDefault: z.boolean().nullish(),
metadata: z.record(z.unknown()).nullish(), metadata: z.record(z.string(), z.unknown()).nullish(),
thinkingOptions: z.array(AgentSelectOptionSchema).nullish(), thinkingOptions: z.array(AgentSelectOptionSchema).nullish(),
defaultThinkingOptionId: z.string().nullish(), defaultThinkingOptionId: z.string().nullish(),
}) })
@@ -67,41 +67,24 @@ export interface ResolvedProviderModel {
model: string | undefined; model: string | undefined;
} }
export function resolveProviderAndModel(params: { export function resolveRequiredProviderModel(
provider?: string; providerValue: string,
model?: string; ): Required<ResolvedProviderModel> {
defaultProvider: AgentProvider; const providerInput = providerValue.trim();
}): ResolvedProviderModel {
const providerInput = params.provider?.trim() || params.defaultProvider;
const modelInput = params.model?.trim();
if (params.model !== undefined && !modelInput) {
throw new Error("model cannot be empty");
}
const slashIndex = providerInput.indexOf("/"); const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) { if (slashIndex <= 0 || slashIndex === providerInput.length - 1) {
return { throw new Error("provider must be provider/model, for example codex/gpt-5.4");
provider: providerInput as AgentProvider,
model: modelInput,
};
} }
const provider = providerInput.slice(0, slashIndex).trim(); const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim(); const model = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) { if (!provider || !model) {
throw new Error("provider must be <provider> or <provider>/<model>"); throw new Error("provider must be provider/model, for example codex/gpt-5.4");
}
if (modelInput && modelInput !== modelFromProvider) {
throw new Error(
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
);
} }
return { return {
provider: provider as AgentProvider, provider: provider as AgentProvider,
model: modelInput ?? modelFromProvider, model,
}; };
} }
@@ -1,4 +1,5 @@
import { buildProviderRegistry } from "./provider-registry.js"; import { buildProviderRegistry } from "./provider-registry.js";
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
import type { AgentProvider } from "./agent-sdk-types.js"; import type { AgentProvider } from "./agent-sdk-types.js";
import { expandTilde } from "../../utils/path.js"; import { expandTilde } from "../../utils/path.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -21,7 +22,8 @@ export async function resolveAgentModel(
try { try {
const providerRegistry = buildProviderRegistry(options.logger); const providerRegistry = buildProviderRegistry(options.logger);
const models = await providerRegistry[options.provider].fetchModels({ const models = await providerRegistry[options.provider].fetchModels({
cwd: options.cwd ? expandTilde(options.cwd) : undefined, cwd: resolveSnapshotCwd(options.cwd ? expandTilde(options.cwd) : undefined),
force: false,
}); });
const preferred = models.find((model) => model.isDefault) ?? models[0]; const preferred = models.find((model) => model.isDefault) ?? models[0];
return preferred?.id; return preferred?.id;
@@ -578,7 +578,10 @@ describe("buildProviderRegistry", () => {
}, },
}); });
const models = await registry.claude.fetchModels(); const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models.map((model) => model.id)).toEqual(["profile-fast"]); expect(models.map((model) => model.id)).toEqual(["profile-fast"]);
}); });
@@ -610,7 +613,10 @@ describe("buildProviderRegistry", () => {
}, },
}); });
const models = await registry.claude.fetchModels(); const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([ expect(models).toEqual([
{ {
@@ -645,7 +651,10 @@ describe("buildProviderRegistry", () => {
}, },
}); });
const models = await registry.claude.fetchModels(); const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([ expect(models).toEqual([
{ {
@@ -668,7 +677,10 @@ describe("buildProviderRegistry", () => {
]); ]);
const registry = buildProviderRegistry(logger); const registry = buildProviderRegistry(logger);
const models = await registry.claude.fetchModels(); const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([ expect(models).toEqual([
{ {
@@ -42,8 +42,8 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition { export interface ProviderDefinition extends AgentProviderDefinition {
createClient: (logger: Logger) => AgentClient; createClient: (logger: Logger) => AgentClient;
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>; fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>; fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
} }
export type BuildProviderRegistryOptions = { export type BuildProviderRegistryOptions = {
@@ -329,9 +329,9 @@ function createRegistryEntry(
const inner = resolved.createBaseClient(providerLogger); const inner = resolved.createBaseClient(providerLogger);
return inner.provider === provider ? inner : wrapClientProvider(provider, inner); return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
}, },
fetchModels: async (options?: ListModelsOptions) => fetchModels: async (options: ListModelsOptions) =>
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)), mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
fetchModes: async (options?: ListModesOptions) => { fetchModes: async (options: ListModesOptions) => {
const modes = modelClient.listModes const modes = modelClient.listModes
? await modelClient.listModes(options) ? await modelClient.listModes(options)
: resolved.definition.modes; : resolved.definition.modes;
@@ -1,3 +1,4 @@
import { homedir } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
@@ -7,6 +8,8 @@ import type {
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentProvider, AgentProvider,
ListModesOptions,
ListModelsOptions,
ProviderSnapshotEntry, ProviderSnapshotEntry,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js"; import type { ProviderDefinition } from "./provider-registry.js";
@@ -24,8 +27,8 @@ type MockProviderOptions = {
description?: string; description?: string;
defaultModeId?: string | null; defaultModeId?: string | null;
isAvailable?: () => Promise<boolean>; isAvailable?: () => Promise<boolean>;
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>; fetchModels?: (cwd: string, force: boolean) => Promise<AgentModelDefinition[]>;
fetchModes?: (cwd?: string) => Promise<AgentMode[]>; fetchModes?: (cwd: string, force: boolean) => Promise<AgentMode[]>;
}; };
type MockProviderHandle = { type MockProviderHandle = {
@@ -441,21 +444,22 @@ describe("ProviderSnapshotManager", () => {
manager.destroy(); manager.destroy();
}); });
test("refresh during an in-flight refresh is a no-op", async () => { test("explicit refresh bypasses an in-flight background warm-up", async () => {
const fetchModels = deferred<AgentModelDefinition[]>(); const initialFetchModels = deferred<AgentModelDefinition[]>();
const fetchModes = deferred<AgentMode[]>(); const explicitFetchModels = deferred<AgentModelDefinition[]>();
const { registry, handles } = createRegistry([ const { registry, handles } = createRegistry([
createMockProvider({ createMockProvider({
provider: "codex", provider: "codex",
fetchModels: async () => fetchModels.promise, fetchModels: vi
fetchModes: async () => fetchModes.promise, .fn<() => Promise<AgentModelDefinition[]>>()
.mockImplementationOnce(async () => initialFetchModels.promise)
.mockImplementationOnce(async () => explicitFetchModels.promise),
fetchModes: async () => [createMode("auto")],
}), }),
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
const changes: ProviderSnapshotEntry[][] = [];
manager.on("change", (entries) => changes.push(entries));
manager.refresh({ cwd: projectCwd }); manager.getSnapshot(projectCwd);
expect(manager.getSnapshot(projectCwd)).toEqual([ expect(manager.getSnapshot(projectCwd)).toEqual([
{ {
@@ -467,15 +471,18 @@ describe("ProviderSnapshotManager", () => {
}, },
]); ]);
manager.refresh({ cwd: projectCwd }); await vi.waitFor(() => {
manager.refresh({ cwd: projectCwd }); expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
manager.refresh({ cwd: projectCwd }); });
expect(changes).toHaveLength(1); const refreshPromise = manager.refresh({ cwd: projectCwd, providers: ["codex"] });
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
fetchModels.resolve([createModel("codex", "gpt-5.2")]); await vi.waitFor(() => {
fetchModes.resolve([createMode("auto")]); expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
});
explicitFetchModels.resolve([createModel("codex", "gpt-5.2")]);
await refreshPromise;
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
@@ -486,12 +493,170 @@ describe("ProviderSnapshotManager", () => {
}); });
}); });
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); initialFetchModels.resolve([createModel("codex", "stale-background-model")]);
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(2);
manager.destroy(); manager.destroy();
}); });
test("warmUpSnapshotForCwd awaits an in-flight loading provider without force", async () => {
const loadingFetchModels = deferred<AgentModelDefinition[]>();
const fetchModels = vi
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
.mockImplementation(async (_cwd, _force) => loadingFetchModels.promise);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd, force) => fetchModels(cwd, force),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(fetchModels).toHaveBeenCalledTimes(1);
});
const warmUpPromise = manager.warmUpSnapshotForCwd({
cwd: projectCwd,
providers: ["codex"],
});
await Promise.resolve();
expect(fetchModels).toHaveBeenCalledTimes(1);
expect(fetchModels).toHaveBeenCalledWith(projectCwd, false);
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]);
await warmUpPromise;
expect(fetchModels).toHaveBeenCalledTimes(1);
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
manager.destroy();
});
test("settings refresh refreshes only home cwd and invalidates matching provider elsewhere", async () => {
const fetchModels = vi
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd, force) => fetchModels(cwd, force),
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "claude",
fetchModels: async (cwd) => [createModel("claude", cwd)],
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectACwd);
manager.getSnapshot(projectBCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready");
});
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
expect(fetchModels.mock.calls.map(([cwd]) => cwd)).toEqual([
projectACwd,
projectBCwd,
homedir(),
]);
expect(fetchModels.mock.calls.map(([, force]) => force)).toEqual([false, false, true]);
const projectASnapshot = manager.getSnapshot(projectACwd);
expect(getProviderEntry(projectASnapshot, "codex")?.status).toBe("loading");
expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready");
manager.destroy();
});
test("settings refresh invalidation self-heals workspace snapshots through the next read without force", async () => {
const fetchModels = vi
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd, force) => fetchModels(cwd, force),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
expect(fetchModels.mock.calls.map(([cwd]) => cwd)).toEqual([projectCwd, homedir()]);
expect(fetchModels.mock.calls.map(([, force]) => force)).toEqual([false, true]);
const invalidatedSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(invalidatedSnapshot, "codex")).toMatchObject({
provider: "codex",
status: "loading",
});
await vi.waitFor(() => {
expect(fetchModels).toHaveBeenCalledTimes(3);
});
expect(fetchModels.mock.calls[2]).toEqual([projectCwd, false]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
manager.destroy();
});
test("refresh marks a slow provider as error after the timeout", async () => {
const fetchModels = deferred<AgentModelDefinition[]>();
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async () => fetchModels.promise,
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
refreshTimeoutMs: 5,
});
await manager.refresh({ cwd: projectCwd, providers: ["codex"] });
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
provider: "codex",
status: "error",
error: "Timed out refreshing codex after 5ms",
});
manager.destroy();
fetchModels.resolve([createModel("codex", "gpt-5.2")]);
});
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => { test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
let now = 1_000; let now = 1_000;
const fetchModels = vi const fetchModels = vi
@@ -799,6 +964,91 @@ describe("ProviderSnapshotManager", () => {
manager.destroy(); manager.destroy();
}); });
test("missing cwd resolves to home and shares the explicit home cache entry", async () => {
const seenCwds: string[] = [];
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => {
seenCwds.push(cwd);
return [createModel("codex", cwd)];
},
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot();
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(homedir()), "codex")?.status).toBe("ready");
});
manager.getSnapshot(" ");
expect(seenCwds).toEqual([homedir()]);
expect(getProviderEntry(manager.getSnapshot(), "codex")?.models?.[0]?.id).toBe(homedir());
manager.destroy();
});
test("cwd normalization resolves tilde relative paths and trailing slashes before provider calls", async () => {
const seenCwds: string[] = [];
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => {
seenCwds.push(cwd);
return [createModel("codex", cwd)];
},
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("~/paseo-provider-test/../paseo-provider-test/");
manager.getSnapshot("relative-provider-test/..");
await vi.waitFor(() => {
expect(seenCwds).toHaveLength(2);
});
expect(seenCwds).toEqual([resolve(homedir(), "paseo-provider-test"), resolve(".")]);
manager.destroy();
});
test("workspace refresh refreshes only the requested cwd with force true", async () => {
const fetchModels = vi
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd, force) => fetchModels(cwd, force),
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectACwd);
manager.getSnapshot(projectBCwd);
await vi.waitFor(() => {
expect(fetchModels).toHaveBeenCalledTimes(2);
});
await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] });
expect(fetchModels.mock.calls).toEqual([
[projectACwd, false],
[projectBCwd, false],
[projectACwd, true],
]);
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
projectBCwd,
);
manager.destroy();
});
test("snapshot includes user-defined providers from the registry", async () => { test("snapshot includes user-defined providers from the registry", async () => {
const { registry } = createRegistry([ const { registry } = createRegistry([
createMockProvider({ provider: "claude" }), createMockProvider({ provider: "claude" }),
@@ -911,14 +1161,16 @@ function createRegistry(handles: MockProviderHandle[]): {
function createMockProvider(options: MockProviderOptions): MockProviderHandle { function createMockProvider(options: MockProviderOptions): MockProviderHandle {
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true); const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
const fetchModels = vi.fn( const fetchModels = vi.fn(
async (listOptions?: { cwd?: string }) => async (listOptions: ListModelsOptions) =>
options.fetchModels?.(listOptions?.cwd) ?? [ options.fetchModels?.(listOptions.cwd, listOptions.force) ?? [
createModel(options.provider, `${options.provider}-default`), createModel(options.provider, `${options.provider}-default`),
], ],
); );
const fetchModes = vi.fn( const fetchModes = vi.fn(
async (listOptions?: { cwd?: string }) => async (listOptions: ListModesOptions) =>
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)], options.fetchModes?.(listOptions.cwd, listOptions.force) ?? [
createMode(`${options.provider}-mode`),
],
); );
const definition: ProviderDefinition = { const definition: ProviderDefinition = {
@@ -937,7 +1189,7 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
async resumeSession() { async resumeSession() {
throw new Error("not implemented"); throw new Error("not implemented");
}, },
async listModels() { async listModels(_options: ListModelsOptions) {
return []; return [];
}, },
async isAvailable() { async isAvailable() {
@@ -1,31 +1,43 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { homedir } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { withTimeout } from "../../utils/promise-timeout.js";
import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"; import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js"; import type { ProviderDefinition } from "./provider-registry.js";
const DEFAULT_CWD_KEY = "__default__";
const DEFAULT_SNAPSHOT_TTL_MS = 300_000; const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void; type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
type ProviderSnapshotManagerOptions = { type ProviderSnapshotManagerOptions = {
ttlMs?: number; ttlMs?: number;
refreshTimeoutMs?: number;
now?: () => number; now?: () => number;
}; };
type ProviderSnapshotRefreshOptions = { type ProviderSnapshotRefreshOptions = {
cwd?: string; cwd: string;
providers?: AgentProvider[]; providers?: AgentProvider[];
}; };
type ProviderLoadOptions = {
cwd: string;
providers: AgentProvider[];
force: boolean;
};
type ProviderLoad = {
promise: Promise<void>;
};
export class ProviderSnapshotManager { export class ProviderSnapshotManager {
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>(); private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly lastCheckedAts = new Map<string, number>(); private readonly lastCheckedAts = new Map<string, number>();
private readonly warmUps = new Map<string, Promise<void>>(); private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>();
private readonly events = new EventEmitter(); private readonly events = new EventEmitter();
private destroyed = false; private destroyed = false;
private readonly ttlMs: number; private readonly ttlMs: number;
private readonly refreshTimeoutMs: number;
private readonly now: () => number; private readonly now: () => number;
constructor( constructor(
@@ -34,37 +46,88 @@ export class ProviderSnapshotManager {
options: ProviderSnapshotManagerOptions = {}, options: ProviderSnapshotManagerOptions = {},
) { ) {
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS; this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
this.now = options.now ?? Date.now; this.now = options.now ?? Date.now;
} }
getSnapshot(cwd?: string): ProviderSnapshotEntry[] { getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
const cwdKey = normalizeCwdKey(cwd); const resolvedCwd = resolveSnapshotCwd(cwd);
const entries = this.snapshots.get(cwdKey); const entries = this.snapshots.get(resolvedCwd);
if (!entries) { if (!entries) {
const loadingEntries = this.resetSnapshotToLoading(cwdKey); const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
void this.warmUp(cwd); void this.warmUp(resolvedCwd);
return entriesToArray(loadingEntries); return entriesToArray(loadingEntries);
} }
if (this.shouldRevalidate(cwdKey)) { const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
void this.warmUp(cwd); if (missingProviders.length > 0) {
this.resetSnapshotToLoading(resolvedCwd, missingProviders);
void this.warmUp(resolvedCwd, missingProviders);
}
const providerLoads = this.providerLoads.get(resolvedCwd);
const loadingProviders = Array.from(entries.values())
.filter((entry) => entry.status === "loading" && !providerLoads?.has(entry.provider))
.map((entry) => entry.provider);
if (loadingProviders.length > 0) {
void this.warmUp(resolvedCwd, loadingProviders);
}
if (this.shouldRevalidate(resolvedCwd)) {
void this.warmUp(resolvedCwd);
} }
return entriesToArray(entries); return entriesToArray(entries);
} }
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> { async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
const { cwd } = options; const cwd = resolveSnapshotCwd(options.cwd);
const cwdKey = normalizeCwdKey(cwd); const providers = this.resolveRefreshProviders(options.providers);
const inFlight = this.warmUps.get(cwdKey); this.resetSnapshotToLoading(cwd, providers);
if (inFlight) { this.emitChange(cwd);
await inFlight; await this.refreshProviders(cwd, providers ?? this.getProviderIds());
if (!providers) {
this.lastCheckedAts.set(cwd, this.now());
}
}
async refreshSettingsSnapshot(
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
): Promise<void> {
const homeCwd = resolveSnapshotCwd();
const providers = this.resolveRefreshProviders(options.providers);
const providersToRefresh = providers ?? this.getProviderIds();
this.resetSnapshotToLoading(homeCwd, providers);
this.emitChange(homeCwd);
await this.refreshProviders(homeCwd, providersToRefresh);
if (!providers) {
this.lastCheckedAts.set(homeCwd, this.now());
}
this.invalidateNonHomeSnapshots({ homeCwd, providers });
}
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
const cwd = resolveSnapshotCwd(options.cwd);
const providers = this.resolveRefreshProviders(options.providers);
if (options.providers && providers?.length === 0) {
return; return;
} }
const providers = this.resolveRefreshProviders(options.providers);
this.resetSnapshotToLoading(cwdKey, providers); const snapshot = this.snapshots.get(cwd);
this.emitChange(cwdKey); if (!snapshot) {
this.resetSnapshotToLoading(cwd, providers);
} else if (providers) {
const missingProviders = providers.filter((provider) => !snapshot.has(provider));
if (missingProviders.length > 0) {
this.resetSnapshotToLoading(cwd, missingProviders);
}
}
await this.warmUp(cwd, providers); await this.warmUp(cwd, providers);
} }
async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> {
await this.refreshSnapshotForCwd(options);
}
on(event: "change", listener: ProviderSnapshotChangeListener): this { on(event: "change", listener: ProviderSnapshotChangeListener): this {
this.events.on(event, listener); this.events.on(event, listener);
return this; return this;
@@ -80,7 +143,7 @@ export class ProviderSnapshotManager {
this.events.removeAllListeners(); this.events.removeAllListeners();
this.snapshots.clear(); this.snapshots.clear();
this.lastCheckedAts.clear(); this.lastCheckedAts.clear();
this.warmUps.clear(); this.providerLoads.clear();
} }
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> { private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
@@ -98,49 +161,87 @@ export class ProviderSnapshotManager {
return entries; return entries;
} }
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> { private async warmUp(cwd: string, providers?: AgentProvider[]): Promise<void> {
const cwdKey = normalizeCwdKey(cwd);
const inFlight = this.warmUps.get(cwdKey);
if (inFlight) {
return inFlight;
}
const providersToRefresh = providers ?? this.getProviderIds(); const providersToRefresh = providers ?? this.getProviderIds();
const warmUpPromise = Promise.allSettled( await this.loadProviders({
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)), cwd,
).then(() => { providers: providersToRefresh,
if (!providers) { force: false,
this.lastCheckedAts.set(cwdKey, this.now());
}
}); });
if (!providers) {
this.warmUps.set(cwdKey, warmUpPromise); this.lastCheckedAts.set(cwd, this.now());
try {
await warmUpPromise;
} finally {
if (this.warmUps.get(cwdKey) === warmUpPromise) {
this.warmUps.delete(cwdKey);
}
} }
} }
private async refreshProvider( private async refreshProviders(cwd: string, providers: AgentProvider[]): Promise<void> {
cwdKey: string, await this.loadProviders({ cwd, providers, force: true });
provider: AgentProvider, }
cwd?: string,
): Promise<void> { private async loadProviders(options: ProviderLoadOptions): Promise<void> {
const definition = this.providerRegistry[provider]; await Promise.allSettled(
options.providers.map((provider) => this.loadProvider({ ...options, provider })),
);
}
private loadProvider(options: ProviderLoadOptions & { provider: AgentProvider }): Promise<void> {
const definition = this.providerRegistry[options.provider];
if (!definition) { if (!definition) {
return; return Promise.resolve();
} }
const snapshot = this.getOrCreateSnapshot(cwdKey); const existingLoad = this.getProviderLoad(options.cwd, options.provider);
if (existingLoad && !options.force) {
return existingLoad.promise;
}
const load: ProviderLoad = {
promise: Promise.resolve(),
};
this.setProviderLoad(options.cwd, options.provider, load);
load.promise = Promise.resolve()
.then(() =>
this.refreshProvider({
cwd: options.cwd,
provider: options.provider,
definition,
load,
force: options.force,
}),
)
.finally(() => {
const providerLoads = this.providerLoads.get(options.cwd);
if (providerLoads?.get(options.provider) === load) {
providerLoads.delete(options.provider);
}
if (providerLoads?.size === 0) {
this.providerLoads.delete(options.cwd);
}
});
return load.promise;
}
private async refreshProvider(options: {
cwd: string;
provider: AgentProvider;
definition: ProviderDefinition;
load: ProviderLoad;
force: boolean;
}): Promise<void> {
const { cwd, provider, definition, load, force } = options;
const snapshot = this.getOrCreateSnapshot(options.cwd);
try { try {
const client = definition.createClient(this.logger); const client = definition.createClient(this.logger);
const available = await client.isAvailable(); const available = await withTimeout(
client.isAvailable(),
this.refreshTimeoutMs,
`Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`,
);
if (!available) { if (!available) {
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
return;
}
snapshot.set(provider, { snapshot.set(provider, {
provider, provider,
status: "unavailable", status: "unavailable",
@@ -148,15 +249,22 @@ export class ProviderSnapshotManager {
description: definition.description, description: definition.description,
defaultModeId: definition.defaultModeId, defaultModeId: definition.defaultModeId,
}); });
this.emitChange(cwdKey); this.emitChange(cwd);
return; return;
} }
const [models, modes] = await Promise.all([ const [models, modes] = await withTimeout(
definition.fetchModels({ cwd }), Promise.all([
definition.fetchModes({ cwd }), definition.fetchModels({ cwd, force }),
]); definition.fetchModes({ cwd, force }),
]),
this.refreshTimeoutMs,
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
);
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
return;
}
snapshot.set(provider, { snapshot.set(provider, {
provider, provider,
status: "ready", status: "ready",
@@ -167,8 +275,11 @@ export class ProviderSnapshotManager {
description: definition.description, description: definition.description,
defaultModeId: definition.defaultModeId, defaultModeId: definition.defaultModeId,
}); });
this.emitChange(cwdKey); this.emitChange(cwd);
} catch (error) { } catch (error) {
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
return;
}
snapshot.set(provider, { snapshot.set(provider, {
provider, provider,
status: "error", status: "error",
@@ -177,14 +288,32 @@ export class ProviderSnapshotManager {
description: definition.description, description: definition.description,
defaultModeId: definition.defaultModeId, defaultModeId: definition.defaultModeId,
}); });
this.logger.warn( this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
{ err: error, provider, cwd: cwdKey }, this.emitChange(cwd);
"Failed to refresh provider snapshot",
);
this.emitChange(cwdKey);
} }
} }
private getProviderLoad(cwdKey: string, provider: AgentProvider): ProviderLoad | undefined {
return this.providerLoads.get(cwdKey)?.get(provider);
}
private setProviderLoad(cwdKey: string, provider: AgentProvider, load: ProviderLoad): void {
let providerLoads = this.providerLoads.get(cwdKey);
if (!providerLoads) {
providerLoads = new Map<AgentProvider, ProviderLoad>();
this.providerLoads.set(cwdKey, providerLoads);
}
providerLoads.set(provider, load);
}
private isCurrentProviderLoad(
cwdKey: string,
provider: AgentProvider,
load: ProviderLoad,
): boolean {
return this.providerLoads.get(cwdKey)?.get(provider) === load;
}
private emitChange(cwdKey: string): void { private emitChange(cwdKey: string): void {
if (this.destroyed) { if (this.destroyed) {
return; return;
@@ -193,11 +322,11 @@ export class ProviderSnapshotManager {
if (!snapshot) { if (!snapshot) {
return; return;
} }
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey)); this.events.emit("change", entriesToArray(snapshot), cwdKey);
} }
private shouldRevalidate(cwdKey: string): boolean { private shouldRevalidate(cwdKey: string): boolean {
if (this.warmUps.has(cwdKey)) { if (this.providerLoads.has(cwdKey)) {
return false; return false;
} }
const lastCheckedAt = this.lastCheckedAts.get(cwdKey); const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
@@ -259,23 +388,52 @@ export class ProviderSnapshotManager {
const providerIds = new Set(this.getProviderIds()); const providerIds = new Set(this.getProviderIds());
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider)); return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
} }
private invalidateNonHomeSnapshots(options: {
homeCwd: string;
providers?: AgentProvider[];
}): void {
for (const cwd of Array.from(this.snapshots.keys())) {
if (cwd === options.homeCwd) {
continue;
}
if (!options.providers) {
this.resetSnapshotToLoading(cwd);
this.lastCheckedAts.delete(cwd);
this.providerLoads.delete(cwd);
this.emitChange(cwd);
continue;
}
const snapshot = this.snapshots.get(cwd);
if (!snapshot) {
continue;
}
let changed = false;
for (const provider of options.providers) {
changed = snapshot.has(provider) || changed;
this.providerLoads.get(cwd)?.delete(provider);
}
this.resetSnapshotToLoading(cwd, options.providers);
if (this.providerLoads.get(cwd)?.size === 0) {
this.providerLoads.delete(cwd);
}
if (changed) {
this.emitChange(cwd);
}
}
}
} }
function normalizeCwdKey(cwd?: string): string { export function resolveSnapshotCwd(cwd?: string | null): string {
if (!cwd) { const trimmed = cwd?.trim();
return DEFAULT_CWD_KEY;
}
const trimmed = cwd.trim();
if (!trimmed) { if (!trimmed) {
return DEFAULT_CWD_KEY; return homedir();
} }
const expanded =
return resolve(trimmed); trimmed === "~" || trimmed.startsWith("~/") ? `${homedir()}${trimmed.slice(1)}` : trimmed;
} return resolve(expanded);
function denormalizeCwdKey(cwdKey: string): string | undefined {
return cwdKey === DEFAULT_CWD_KEY ? undefined : cwdKey;
} }
function entriesToArray( function entriesToArray(
@@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import { import {
ACPAgentClient, ACPAgentClient,
ACPAgentSession, ACPAgentSession,
type SpawnedACPProcess,
type SessionStateResponse, type SessionStateResponse,
deriveModelDefinitionsFromACP, deriveModelDefinitionsFromACP,
deriveModesFromACP, deriveModesFromACP,
@@ -218,7 +219,7 @@ describe("deriveModelDefinitionsFromACP", () => {
describe("ACPAgentClient modelTransformer", () => { describe("ACPAgentClient modelTransformer", () => {
test("applies modelTransformer after deriving ACP models", async () => { test("applies modelTransformer after deriving ACP models", async () => {
class TestACPAgentClient extends ACPAgentClient { class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<any> { protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return { return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: { connection: {
@@ -237,7 +238,7 @@ describe("ACPAgentClient modelTransformer", () => {
}), }),
}, },
initialize: { agentCapabilities: {} }, initialize: { agentCapabilities: {} },
}; } as unknown as SpawnedACPProcess;
} }
protected override async closeProbe(): Promise<void> {} protected override async closeProbe(): Promise<void> {}
@@ -250,7 +251,7 @@ describe("ACPAgentClient modelTransformer", () => {
modelTransformer: transformPiModels, modelTransformer: transformPiModels,
}); });
await expect(client.listModels()).resolves.toEqual([ await expect(client.listModels({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual([
{ {
provider: "pi", provider: "pi",
id: "openrouter/openai/gpt-4.1-mini", id: "openrouter/openai/gpt-4.1-mini",
@@ -266,7 +267,7 @@ describe("ACPAgentClient modelTransformer", () => {
describe("ACPAgentClient sessionResponseTransformer", () => { describe("ACPAgentClient sessionResponseTransformer", () => {
class TestACPAgentClient extends ACPAgentClient { class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<any> { protected override async spawnProcess(): Promise<SpawnedACPProcess> {
const response: SessionStateResponse = { const response: SessionStateResponse = {
sessionId: "session-1", sessionId: "session-1",
modes: { modes: {
@@ -283,7 +284,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
newSession: vi.fn().mockResolvedValue(response), newSession: vi.fn().mockResolvedValue(response),
}, },
initialize: { agentCapabilities: {} }, initialize: { agentCapabilities: {} },
}; } as unknown as SpawnedACPProcess;
} }
protected override async closeProbe(): Promise<void> {} protected override async closeProbe(): Promise<void> {}
@@ -304,7 +305,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
}), }),
}); });
await expect(client.listModes()).resolves.toEqual([ await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([
{ {
id: "review", id: "review",
label: "Review", label: "Review",
@@ -315,9 +316,44 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
}); });
describe("ACPAgentClient listModes", () => { describe("ACPAgentClient listModes", () => {
test("passes the requested cwd to list model and mode probes", async () => {
const newSession = vi.fn().mockResolvedValue({ modes: null, models: null, configOptions: [] });
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: { newSession },
initialize: { agentCapabilities: {} },
} as unknown as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
}
const client = new TestACPAgentClient({
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["test-acp"],
defaultModes: [],
});
await client.listModels({ cwd: "/tmp/acp-model-cwd", force: false });
await client.listModes({ cwd: "/tmp/acp-mode-cwd", force: false });
expect(newSession).toHaveBeenNthCalledWith(1, {
cwd: "/tmp/acp-model-cwd",
mcpServers: [],
});
expect(newSession).toHaveBeenNthCalledWith(2, {
cwd: "/tmp/acp-mode-cwd",
mcpServers: [],
});
});
test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => { test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
class TestACPAgentClient extends ACPAgentClient { class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<any> { protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return { return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: { connection: {
@@ -340,7 +376,7 @@ describe("ACPAgentClient listModes", () => {
}), }),
}, },
initialize: { agentCapabilities: {} }, initialize: { agentCapabilities: {} },
}; } as unknown as SpawnedACPProcess;
} }
protected override async closeProbe(): Promise<void> {} protected override async closeProbe(): Promise<void> {}
@@ -353,7 +389,7 @@ describe("ACPAgentClient listModes", () => {
defaultModes: [], defaultModes: [],
}); });
await expect(client.listModes()).resolves.toEqual([]); await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([]);
}); });
}); });
@@ -152,7 +152,7 @@ type ACPAgentSessionOptions = {
initialCommandsWaitTimeoutMs?: number; initialCommandsWaitTimeoutMs?: number;
}; };
type SpawnedACPProcess = { export type SpawnedACPProcess = {
child: ChildProcessWithoutNullStreams; child: ChildProcessWithoutNullStreams;
connection: ClientSideConnection; connection: ClientSideConnection;
initialize: InitializeResponse; initialize: InitializeResponse;
@@ -394,8 +394,8 @@ export class ACPAgentClient implements AgentClient {
return session; return session;
} }
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> { async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
const cwd = options?.cwd ?? process.cwd(); const { cwd } = options;
const probe = await this.spawnProcess(PROBE_ENV); const probe = await this.spawnProcess(PROBE_ENV);
try { try {
const response = await probe.connection.newSession({ const response = await probe.connection.newSession({
@@ -414,8 +414,8 @@ export class ACPAgentClient implements AgentClient {
} }
} }
async listModes(options?: ListModesOptions): Promise<AgentMode[]> { async listModes(options: ListModesOptions): Promise<AgentMode[]> {
const cwd = options?.cwd ?? process.cwd(); const { cwd } = options;
const probe = await this.spawnProcess(PROBE_ENV); const probe = await this.spawnProcess(PROBE_ENV);
try { try {
const response = await probe.connection.newSession({ const response = await probe.connection.newSession({
@@ -7,6 +7,7 @@ import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -40,21 +41,7 @@ async function nextStreamEvent(
timeoutMs: number, timeoutMs: number,
label: string, label: string,
): Promise<IteratorResult<AgentStreamEvent>> { ): Promise<IteratorResult<AgentStreamEvent>> {
let timer: ReturnType<typeof setTimeout> | null = null; return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
try {
return await Promise.race([
stream.next(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timed out waiting for ${label}`));
}, timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
} }
async function collectUntilTerminal( async function collectUntilTerminal(
@@ -337,7 +337,7 @@ describe("ClaudeAgentClient.listModels", () => {
test("returns hardcoded claude models", async () => { test("returns hardcoded claude models", async () => {
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
const models = await client.listModels(); const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
expect(models.map((m) => m.id)).toEqual([ expect(models.map((m) => m.id)).toEqual([
"claude-opus-4-7[1m]", "claude-opus-4-7[1m]",
@@ -74,6 +74,7 @@ import type {
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js"; import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js"; import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -1107,7 +1108,8 @@ export class ClaudeAgentClient implements AgentClient {
}); });
} }
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> { async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
// Claude exposes a static catalog here; cwd/force are intentionally irrelevant.
return getClaudeModels(); return getClaudeModels();
} }
@@ -1157,7 +1159,7 @@ export class ClaudeAgentClient implements AgentClient {
if (available) { if (available) {
try { try {
const models = await this.listModels(); const models = await this.listModels({ cwd: os.homedir(), force: false });
modelsValue = String(models.length); modelsValue = String(models.length);
} catch (error) { } catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
@@ -2097,12 +2099,7 @@ class ClaudeAgentSession implements AgentSession {
const startedAt = Date.now(); const startedAt = Date.now();
this.logger.trace({ label }, "Claude query operation wait start"); this.logger.trace({ label }, "Claude query operation wait start");
try { try {
await Promise.race([ await withTimeout(promise, 3_000, "timeout");
promise,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("timeout")), 3_000);
}),
]);
this.logger.trace( this.logger.trace(
{ label, durationMs: Date.now() - startedAt }, { label, durationMs: Date.now() - startedAt },
"Claude query operation settled", "Claude query operation settled",
@@ -222,21 +222,26 @@ describe("Codex app-server provider (e2e)", () => {
"lists models and runs a simple prompt", "lists models and runs a simple prompt",
async () => { async () => {
const client = new CodexAppServerAgentClient(createTestLogger()); const client = new CodexAppServerAgentClient(createTestLogger());
const models = await client.listModels(); const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-"));
const models = await client.listModels({ cwd, force: false });
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true); expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
const session = await client.createSession({ const session = await client.createSession({
provider: "codex", provider: "codex",
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")), cwd,
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true); try {
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
const result = await session.run("Say hello in one sentence."); const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0); expect(result.finalText.length).toBeGreaterThan(0);
await session.close(); } finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
}, },
30000, 30000,
); );
@@ -21,7 +21,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
process.on("uncaughtException", onUncaught); process.on("uncaughtException", onUncaught);
try { try {
await expect(client.listModels()).rejects.toThrow(); await expect(client.listModels({ cwd: "/tmp/codex-models", force: false })).rejects.toThrow();
// Drain microtask queue to ensure no deferred uncaught errors // Drain microtask queue to ensure no deferred uncaught errors
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
expect(uncaughtErrors).toHaveLength(0); expect(uncaughtErrors).toHaveLength(0);
@@ -27,6 +27,7 @@ import type {
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { homedir } from "node:os";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -4137,7 +4138,8 @@ export class CodexAppServerAgentClient implements AgentClient {
} }
} }
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> { 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(); const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger); const client = new CodexAppServerClient(child, this.logger);
@@ -4247,7 +4249,7 @@ export class CodexAppServerAgentClient implements AgentClient {
entries.push({ label: "Models", value: "Not checked" }); entries.push({ label: "Models", value: "Not checked" });
} else { } else {
try { try {
const models = await this.listModels(); const models = await this.listModels({ cwd: homedir(), force: false });
entries.push({ label: "Models", value: String(models.length) }); entries.push({ label: "Models", value: String(models.length) });
} catch (error) { } catch (error) {
entries.push({ entries.push({
@@ -1,4 +1,5 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import { homedir } from "node:os";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
@@ -69,7 +70,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
if (available) { if (available) {
try { try {
const models = await this.listModels(); const models = await this.listModels({ cwd: homedir(), force: false });
modelsValue = String(models.length); modelsValue = String(models.length);
} catch (error) { } catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
@@ -81,7 +82,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
if (!modelsValue.startsWith("Error -")) { if (!modelsValue.startsWith("Error -")) {
try { try {
await this.listModes(); await this.listModes({ cwd: homedir(), force: false });
} catch (error) { } catch (error) {
status = formatDiagnosticStatus(available, { status = formatDiagnosticStatus(available, {
source: "mode fetch", source: "mode fetch",
@@ -19,7 +19,7 @@ describe("MockLoadTestAgentClient", () => {
test("default model is a five minute foreground stream", async () => { test("default model is a five minute foreground stream", async () => {
const client = new MockLoadTestAgentClient(); const client = new MockLoadTestAgentClient();
const models = await client.listModels(); const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
expect(models[0]).toMatchObject({ expect(models[0]).toMatchObject({
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
@@ -20,6 +20,7 @@ import type {
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
ListModesOptions,
ListModelsOptions, ListModelsOptions,
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
PersistedAgentDescriptor, PersistedAgentDescriptor,
@@ -197,11 +198,11 @@ export class MockLoadTestAgentClient implements AgentClient {
}); });
} }
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> { async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return MODELS; return MODELS;
} }
async listModes(): Promise<AgentMode[]> { async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes; return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
} }
@@ -50,11 +50,14 @@ describe("OpenCodeAgentClient.listModels timeout", () => {
} as never); } as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }), acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never); } as never);
const client = new OpenCodeAgentClient(createTestLogger()); const client = new OpenCodeAgentClient(createTestLogger());
const modelsPromise = client.listModels(); const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(15_000);
@@ -66,4 +69,28 @@ describe("OpenCodeAgentClient.listModels timeout", () => {
}, },
]); ]);
}); });
test("passes explicit refresh force through server acquisition", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
} as never);
const acquire = vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
});
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
const client = new OpenCodeAgentClient(createTestLogger());
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
expect(acquire).toHaveBeenCalledWith({ force: true });
});
}); });
@@ -66,7 +66,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
} as never); } as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }), acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never); } as never);
const client = new OpenCodeAgentClient(createTestLogger()); const client = new OpenCodeAgentClient(createTestLogger());
@@ -103,7 +103,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
logger.info("beforeAll: Starting model selection"); logger.info("beforeAll: Starting model selection");
const client = new OpenCodeAgentClient(logger); const client = new OpenCodeAgentClient(logger);
const models = await client.listModels(); const models = await client.listModels({ cwd: os.homedir(), force: false });
logger.info( logger.info(
{ modelCount: models.length, elapsed: Date.now() - startTime }, { modelCount: models.length, elapsed: Date.now() - startTime },
@@ -182,7 +182,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
test("listModels returns models with required fields", async () => { test("listModels returns models with required fields", async () => {
const client = new OpenCodeAgentClient(logger); const client = new OpenCodeAgentClient(logger);
const models = await client.listModels(); const models = await client.listModels({ cwd: os.homedir(), force: false });
// HARD ASSERT: Returns an array // HARD ASSERT: Returns an array
expect(Array.isArray(models)).toBe(true); expect(Array.isArray(models)).toBe(true);
@@ -1,4 +1,5 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { homedir } from "node:os";
import { import {
createOpencodeClient, createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage, type AssistantMessage as OpenCodeAssistantMessage,
@@ -45,6 +46,7 @@ import {
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { import {
@@ -700,9 +702,10 @@ export const __openCodeInternals = {
export class OpenCodeServerManager { export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null; private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false; private static exitHandlerRegistered = false;
private server: ChildProcess | null = null; private currentServer: OpenCodeServerGeneration | null = null;
private port: number | null = null; private retiredServers = new Set<OpenCodeServerGeneration>();
private startPromise: Promise<{ port: number; url: string }> | null = null; private startPromise: Promise<OpenCodeServerGeneration> | null = null;
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string; private readonly runtimeSettingsKey: string;
@@ -741,9 +744,7 @@ export class OpenCodeServerManager {
const cleanup = () => { const cleanup = () => {
const instance = OpenCodeServerManager.instance; const instance = OpenCodeServerManager.instance;
if (instance?.server && !instance.server.killed) { void instance?.shutdown();
instance.server.kill("SIGTERM");
}
}; };
process.on("exit", cleanup); process.on("exit", cleanup);
@@ -752,35 +753,99 @@ export class OpenCodeServerManager {
} }
async ensureRunning(): Promise<{ port: number; url: string }> { async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquire({ force: false });
acquisition.release();
return acquisition.server;
}
async acquire(options: { force: boolean }): Promise<{
server: { port: number; url: string };
release: () => void;
}> {
const server = options.force
? await this.getForcedRefreshServer()
: await this.getCurrentServer();
server.refCount += 1;
let released = false;
return {
server: { port: server.port, url: server.url },
release: () => {
if (released) {
return;
}
released = true;
server.refCount -= 1;
this.cleanupRetiredServers();
},
};
}
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
if (this.forcedRefreshPromise) {
return this.forcedRefreshPromise;
}
this.forcedRefreshPromise = Promise.resolve()
.then(async () => {
await this.rotateCurrentServer();
return this.getCurrentServer();
})
.finally(() => {
this.forcedRefreshPromise = null;
});
return this.forcedRefreshPromise;
}
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
if (this.startPromise) { if (this.startPromise) {
return this.startPromise; return this.startPromise;
} }
if (this.server && this.port && !this.server.killed) { if (this.currentServer && !this.currentServer.process.killed) {
return { port: this.port, url: `http://127.0.0.1:${this.port}` }; return this.currentServer;
} }
this.startPromise = this.startServer(); this.startPromise = this.startServer();
try { try {
const result = await this.startPromise; const result = await this.startPromise;
if (!result.retired) {
this.currentServer = result;
}
return result; return result;
} finally { } finally {
this.startPromise = null; this.startPromise = null;
} }
} }
private async startServer(): Promise<{ port: number; url: string }> { private async rotateCurrentServer(): Promise<void> {
this.port = await findAvailablePort(); const existing = this.currentServer;
const url = `http://127.0.0.1:${this.port}`; if (existing) {
existing.retired = true;
this.retiredServers.add(existing);
this.currentServer = null;
this.cleanupRetiredServers();
}
if (this.startPromise) {
const pending = await this.startPromise;
pending.retired = true;
this.retiredServers.add(pending);
this.currentServer = null;
this.cleanupRetiredServers();
}
}
private async startServer(): Promise<OpenCodeServerGeneration> {
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await resolveProviderCommandPrefix( const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command, this.runtimeSettings?.command,
resolveOpenCodeBinary, resolveOpenCodeBinary,
); );
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.server = spawnProcess( const serverProcess = spawnProcess(
launchPrefix.command, launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(this.port)], [...launchPrefix.args, "serve", "--port", String(port)],
{ {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings), env: applyProviderEnv(process.env, this.runtimeSettings),
@@ -794,54 +859,94 @@ export class OpenCodeServerManager {
} }
}, 30_000); }, 30_000);
this.server.stdout?.on("data", (data: Buffer) => { serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString(); const output = data.toString();
if (output.includes("listening on") && !started) { if (output.includes("listening on") && !started) {
started = true; started = true;
clearTimeout(timeout); clearTimeout(timeout);
resolve({ port: this.port!, url }); resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
});
} }
}); });
this.server.stderr?.on("data", (data: Buffer) => { serverProcess.stderr?.on("data", (data: Buffer) => {
this.logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr"); this.logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr");
}); });
this.server.on("error", (error) => { serverProcess.on("error", (error) => {
clearTimeout(timeout); clearTimeout(timeout);
reject(error); reject(error);
}); });
this.server.on("exit", (code) => { serverProcess.on("exit", (code) => {
if (!started) { if (!started) {
clearTimeout(timeout); clearTimeout(timeout);
reject(new Error(`OpenCode server exited with code ${code}`)); reject(new Error(`OpenCode server exited with code ${code}`));
} }
this.server = null; if (this.currentServer?.process === serverProcess) {
this.port = null; this.currentServer = null;
}
for (const retired of Array.from(this.retiredServers)) {
if (retired.process === serverProcess) {
this.retiredServers.delete(retired);
}
}
}); });
}); });
} }
async shutdown(): Promise<void> { async shutdown(): Promise<void> {
if (this.server && !this.server.killed) { const servers = [
this.server.kill("SIGTERM"); ...(this.currentServer ? [this.currentServer] : []),
await new Promise<void>((resolve) => { ...Array.from(this.retiredServers),
const timeout = setTimeout(() => { ];
this.server?.kill("SIGKILL"); for (const server of servers) {
resolve(); await this.killServer(server);
}, 5000);
this.server?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
} }
this.server = null; this.currentServer = null;
this.port = null; this.retiredServers.clear();
}
private cleanupRetiredServers(): void {
for (const server of Array.from(this.retiredServers)) {
if (server.refCount === 0) {
this.retiredServers.delete(server);
void this.killServer(server);
}
}
}
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
if (server.process.killed) {
return;
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
server.process.kill("SIGKILL");
resolve();
}, 5000);
server.process.on("exit", () => {
clearTimeout(timeout);
resolve();
});
server.process.kill("SIGTERM");
});
} }
} }
type OpenCodeServerGeneration = {
process: ChildProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
};
export class OpenCodeAgentClient implements AgentClient { export class OpenCodeAgentClient implements AgentClient {
readonly provider: "opencode" = "opencode"; readonly provider: "opencode" = "opencode";
readonly capabilities = OPENCODE_CAPABILITIES; readonly capabilities = OPENCODE_CAPABILITIES;
@@ -862,40 +967,43 @@ export class OpenCodeAgentClient implements AgentClient {
_launchContext?: AgentLaunchContext, _launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config); const openCodeConfig = this.assertConfig(config);
const { url } = await this.serverManager.ensureRunning(); const acquisition = await this.serverManager.acquire({ force: false });
const { url } = acquisition.server;
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: url, baseUrl: url,
directory: openCodeConfig.cwd, directory: openCodeConfig.cwd,
}); });
// Set a timeout for session creation to fail fast try {
const timeoutPromise = new Promise<never>((_, reject) => { const response = await withTimeout(
setTimeout(() => reject(new Error("OpenCode session.create timed out after 10s")), 10_000); client.session.create({ directory: openCodeConfig.cwd }),
}); 10_000,
"OpenCode session.create timed out after 10s",
);
const response = await Promise.race([ if (response.error) {
client.session.create({ directory: openCodeConfig.cwd }), throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`);
timeoutPromise, }
]);
if (response.error) { const session = response.data;
throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`); if (!session) {
throw new Error("OpenCode session creation returned no data");
}
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
return new OpenCodeAgentSession(
openCodeConfig,
client,
session.id,
this.logger,
new Map(this.modelContextWindows),
acquisition.release,
);
} catch (error) {
acquisition.release();
throw error;
} }
const session = response.data;
if (!session) {
throw new Error("OpenCode session creation returned no data");
}
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
return new OpenCodeAgentSession(
openCodeConfig,
client,
session.id,
this.logger,
new Map(this.modelContextWindows),
);
} }
async resumeSession( async resumeSession(
@@ -914,119 +1022,125 @@ export class OpenCodeAgentClient implements AgentClient {
...overrides, ...overrides,
}; };
const openCodeConfig = this.assertConfig(config); const openCodeConfig = this.assertConfig(config);
const { url } = await this.serverManager.ensureRunning(); const acquisition = await this.serverManager.acquire({ force: false });
const { url } = acquisition.server;
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: url, baseUrl: url,
directory: openCodeConfig.cwd, directory: openCodeConfig.cwd,
}); });
await this.populateModelContextWindowCache(client, openCodeConfig.cwd); try {
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
return new OpenCodeAgentSession( return new OpenCodeAgentSession(
openCodeConfig, openCodeConfig,
client, client,
handle.sessionId, handle.sessionId,
this.logger, this.logger,
new Map(this.modelContextWindows), new Map(this.modelContextWindows),
); acquisition.release,
);
} catch (error) {
acquisition.release();
throw error;
}
} }
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> { async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
const { url } = await this.serverManager.ensureRunning(); const acquisition = await this.serverManager.acquire({ force: options.force });
const { url } = acquisition.server;
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: url, baseUrl: url,
directory: options?.cwd ?? process.cwd(), directory: options.cwd,
}); });
// Background model discovery can be legitimately slow while OpenCode refreshes try {
// provider state, so allow longer than turn execution paths. // Background model discovery can be legitimately slow while OpenCode refreshes
const timeoutPromise = new Promise<never>((_, reject) => { // provider state, so allow longer than turn execution paths.
setTimeout( const response = await withTimeout(
() => client.provider.list({ directory: options.cwd }),
reject(
new Error(
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
),
),
OPENCODE_PROVIDER_LIST_TIMEOUT_MS, OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
); );
});
const response = await Promise.race([ if (response.error) {
client.provider.list({ directory: options?.cwd ?? process.cwd() }), throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
timeoutPromise,
]);
if (response.error) {
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
}
const providers = response.data;
if (!providers) {
return [];
}
// Only include models from connected providers (ones that are actually available)
const connectedProviderIds = new Set(providers.connected);
// Fail fast if no providers are connected
if (connectedProviderIds.size === 0) {
throw new Error(
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
);
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) {
// Skip providers that aren't connected/configured
if (!connectedProviderIds.has(provider.id)) {
continue;
} }
for (const [modelId, model] of Object.entries(provider.models)) { const providers = response.data;
const definition = buildOpenCodeModelDefinition(provider, modelId, model); if (!providers) {
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model); return [];
if (contextWindowMaxTokens !== undefined) { }
this.modelContextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId), // Only include models from connected providers (ones that are actually available)
contextWindowMaxTokens, const connectedProviderIds = new Set(providers.connected);
);
// Fail fast if no providers are connected
if (connectedProviderIds.size === 0) {
throw new Error(
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
);
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) {
// Skip providers that aren't connected/configured
if (!connectedProviderIds.has(provider.id)) {
continue;
} }
models.push(definition);
}
}
return models; for (const [modelId, model] of Object.entries(provider.models)) {
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
if (contextWindowMaxTokens !== undefined) {
this.modelContextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId),
contextWindowMaxTokens,
);
}
models.push(definition);
}
}
return models;
} finally {
acquisition.release();
}
} }
async listModes(options?: ListModesOptions): Promise<AgentMode[]> { async listModes(options: ListModesOptions): Promise<AgentMode[]> {
const { url } = await this.serverManager.ensureRunning(); const acquisition = await this.serverManager.acquire({ force: options.force });
const directory = options?.cwd ?? process.cwd(); const { url } = acquisition.server;
const directory = options.cwd;
const client = createOpencodeClient({ baseUrl: url, directory }); const client = createOpencodeClient({ baseUrl: url, directory });
const timeoutPromise = new Promise<never>((_, reject) => { try {
setTimeout(() => reject(new Error("OpenCode app.agents timed out after 10s")), 10_000); const response = await withTimeout(
}); client.app.agents({ directory }),
10_000,
"OpenCode app.agents timed out after 10s",
);
const response = await Promise.race([client.app.agents({ directory }), timeoutPromise]); if (response.error || !response.data) {
return DEFAULT_MODES;
}
if (response.error || !response.data) { const discovered = response.data
return DEFAULT_MODES; .filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
description:
typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim()
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
} finally {
acquisition.release();
} }
const discovered = response.data
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
description:
typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim()
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
} }
async listPersistedAgents( async listPersistedAgents(
@@ -1061,7 +1175,7 @@ export class OpenCodeAgentClient implements AgentClient {
if (available) { if (available) {
try { try {
const models = await this.listModels(); const models = await this.listModels({ cwd: homedir(), force: false });
modelsValue = String(models.length); modelsValue = String(models.length);
} catch (error) { } catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
@@ -1073,7 +1187,7 @@ export class OpenCodeAgentClient implements AgentClient {
if (!modelsValue.startsWith("Error -")) { if (!modelsValue.startsWith("Error -")) {
try { try {
await this.listModes(); await this.listModes({ cwd: homedir(), force: false });
} catch (error) { } catch (error) {
status = formatDiagnosticStatus(available, { status = formatDiagnosticStatus(available, {
source: "mode fetch", source: "mode fetch",
@@ -1671,12 +1785,14 @@ class OpenCodeAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>(); private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined; private selectedModelContextWindowMaxTokens: number | undefined;
private releaseServer: (() => void) | null;
constructor( constructor(
config: OpenCodeAgentConfig, config: OpenCodeAgentConfig,
client: OpencodeClient, client: OpencodeClient,
sessionId: string, sessionId: string,
logger: Logger, logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(), modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void,
) { ) {
this.config = config; this.config = config;
this.client = client; this.client = client;
@@ -1684,6 +1800,7 @@ class OpenCodeAgentSession implements AgentSession {
this.logger = logger; this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey; this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId); this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.releaseServer = releaseServer ?? null;
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens( this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
config.model, config.model,
); );
@@ -2278,15 +2395,20 @@ class OpenCodeAgentSession implements AgentSession {
} }
async close(): Promise<void> { async close(): Promise<void> {
this.abortController?.abort(); try {
await reconcileOpenCodeSessionClose({ this.abortController?.abort();
client: this.client, await reconcileOpenCodeSessionClose({
sessionId: this.sessionId, client: this.client,
directory: this.config.cwd, sessionId: this.sessionId,
logger: this.logger, directory: this.config.cwd,
}); logger: this.logger,
this.subscribers.clear(); });
this.activeForegroundTurnId = null; this.subscribers.clear();
this.activeForegroundTurnId = null;
} finally {
this.releaseServer?.();
this.releaseServer = null;
}
} }
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null { private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
@@ -0,0 +1,174 @@
import { EventEmitter } from "node:events";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeServerManager } from "./opencode-agent.js";
type FakeServerProcess = EventEmitter & {
killed: boolean;
kill: ReturnType<typeof vi.fn>;
};
type FakeGeneration = {
process: FakeServerProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
};
describe("OpenCodeServerManager generations", () => {
test("rotation creates a new current server without killing a referenced old server", async () => {
const manager = createTestManager();
const first = createGeneration(4101);
const second = createGeneration(4102);
stubGenerations(manager, [first, second]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
expect(first.process.kill).not.toHaveBeenCalled();
expect(second.process.kill).not.toHaveBeenCalled();
newAcquisition.release();
oldAcquisition.release();
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
});
test("new acquisitions after rotation use the new server", async () => {
const manager = createTestManager();
const first = createGeneration(4201);
const second = createGeneration(4202);
stubGenerations(manager, [first, second]);
const oldAcquisition = await manager.acquire({ force: false });
const rotatedAcquisition = await manager.acquire({ force: true });
rotatedAcquisition.release();
const nextAcquisition = await manager.acquire({ force: false });
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
expect(first.process.kill).not.toHaveBeenCalled();
nextAcquisition.release();
oldAcquisition.release();
});
test("concurrent forced acquisitions share one fresh generation", async () => {
const manager = createTestManager();
const first = createGeneration(4251);
const second = createGeneration(4252);
const third = createGeneration(4253);
const startServer = stubGenerations(manager, [first, second, third]);
const initialAcquisition = await manager.acquire({ force: false });
initialAcquisition.release();
const [modelsAcquisition, modesAcquisition] = await Promise.all([
manager.acquire({ force: true }),
manager.acquire({ force: true }),
]);
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
expect(modesAcquisition.server.url).toBe("http://127.0.0.1:4252");
expect(startServer).toHaveBeenCalledTimes(2);
modesAcquisition.release();
modelsAcquisition.release();
});
test("release is idempotent", async () => {
const manager = createTestManager();
const first = createGeneration(4301);
const second = createGeneration(4302);
stubGenerations(manager, [first, second]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
newAcquisition.release();
oldAcquisition.release();
oldAcquisition.release();
expect(first.refCount).toBe(0);
expect(first.process.kill).toHaveBeenCalledTimes(1);
});
test("shutdown kills current and retired servers", async () => {
const manager = createTestManager();
const first = createGeneration(4401);
const second = createGeneration(4402);
stubGenerations(manager, [first, second]);
await manager.acquire({ force: false });
await manager.acquire({ force: true });
await manager.shutdown();
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
expect(second.process.kill).toHaveBeenCalledWith("SIGTERM");
});
test("repeated rotations leave zero unreferenced retired servers", async () => {
const manager = createTestManager();
const first = createGeneration(4501);
const second = createGeneration(4502);
const third = createGeneration(4503);
stubGenerations(manager, [first, second, third]);
const firstAcquisition = await manager.acquire({ force: false });
const secondAcquisition = await manager.acquire({ force: true });
secondAcquisition.release();
const thirdAcquisition = await manager.acquire({ force: true });
thirdAcquisition.release();
firstAcquisition.release();
const retiredServers = (manager as unknown as { retiredServers: Set<FakeGeneration> })
.retiredServers;
expect(Array.from(retiredServers).filter((server) => server.refCount === 0)).toHaveLength(0);
expect(first.process.kill).toHaveBeenCalledTimes(1);
expect(second.process.kill).toHaveBeenCalledTimes(1);
});
});
function createTestManager(): OpenCodeServerManager {
const ManagerConstructor = OpenCodeServerManager as unknown as {
new (logger: ReturnType<typeof createTestLogger>): OpenCodeServerManager;
};
return new ManagerConstructor(createTestLogger());
}
function stubGenerations(
manager: OpenCodeServerManager,
generations: FakeGeneration[],
): ReturnType<typeof vi.fn> {
const startServer = vi.fn(async () => {
const generation = generations.shift();
if (!generation) {
throw new Error("No fake OpenCode server generation available");
}
return generation;
});
(manager as unknown as { startServer: typeof startServer }).startServer = startServer;
return startServer;
}
function createGeneration(port: number): FakeGeneration {
const process = new EventEmitter() as FakeServerProcess;
process.killed = false;
process.kill = vi.fn((signal?: NodeJS.Signals) => {
process.killed = true;
process.emit("exit", signal ?? "SIGTERM");
return true;
});
return {
process,
port,
url: `http://127.0.0.1:${port}`,
refCount: 0,
retired: false,
};
}
@@ -45,6 +45,7 @@ import type {
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
AgentUsage, AgentUsage,
ListModesOptions,
ListModelsOptions, ListModelsOptions,
ToolCallDetail, ToolCallDetail,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
@@ -1448,7 +1449,8 @@ export class PiDirectAgentClient implements AgentClient {
return new PiDirectAgentSession(session, this.getModelRegistry(), mergedConfig); return new PiDirectAgentSession(session, this.getModelRegistry(), mergedConfig);
} }
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> { async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
// Pi Direct uses an in-process global registry; cwd/force are intentionally irrelevant.
const models = this.getModelRegistry() const models = this.getModelRegistry()
.getAll() .getAll()
.map((model) => ({ .map((model) => ({
@@ -1467,7 +1469,7 @@ export class PiDirectAgentClient implements AgentClient {
return transformPiModels(models); return transformPiModels(models);
} }
async listModes(): Promise<AgentMode[]> { async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
return []; return [];
} }
@@ -1504,7 +1506,7 @@ export class PiDirectAgentClient implements AgentClient {
if (available) { if (available) {
try { try {
const models = await this.listModels(); const models = await this.listModels({ cwd: homedir(), force: false });
modelsValue = String(models.length); modelsValue = String(models.length);
} catch (error) { } catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
@@ -13,6 +13,7 @@ import { tmpdir } from "os";
import path from "path"; import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { deriveWorktreeProjectHash } from "../../utils/worktree.js"; import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
@@ -21,26 +22,6 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
} }
async function withTimeout<T>(options: {
promise: Promise<T>;
timeoutMs: number;
label: string;
}): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
function findTimelineToolCall( function findTimelineToolCall(
messages: SessionOutboundMessage[], messages: SessionOutboundMessage[],
agentId: string, agentId: string,
@@ -451,15 +451,20 @@ describe(
"PiDirectAgentClient.listModels returns non-empty Pi model definitions", "PiDirectAgentClient.listModels returns non-empty Pi model definitions",
async () => { async () => {
const client = createPiClient(); const client = createPiClient();
const models = await client.listModels(); const cwd = tmpCwd("pi-list-models-");
try {
const models = await client.listModels({ cwd, force: false });
expect(models.length).toBeGreaterThan(0); expect(models.length).toBeGreaterThan(0);
for (const model of models) { for (const model of models) {
expect(model.provider).toBe("pi"); expect(model.provider).toBe("pi");
expect(typeof model.id).toBe("string"); expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0); expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string"); expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0); expect(model.label.length).toBeGreaterThan(0);
}
} finally {
rmSync(cwd, { recursive: true, force: true });
} }
}, },
PI_TEST_TIMEOUT_MS, PI_TEST_TIMEOUT_MS,
+14
View File
@@ -20,3 +20,17 @@ export function resolvePathFromBase(baseCwd: string, requestedPath: string): str
} }
return resolve(baseCwd, trimmed); return resolve(baseCwd, trimmed);
} }
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
normalizedBase = normalizedBase.toLowerCase();
normalizedCandidate = normalizedCandidate.toLowerCase();
}
return (
normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/")
);
}
+204 -1
View File
@@ -1,12 +1,21 @@
import { execSync } from "child_process"; import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync } from "fs"; import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os"; import { homedir, tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import pino from "pino"; import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { CheckoutPrStatusSchema } from "../shared/messages.js"; import { CheckoutPrStatusSchema } from "../shared/messages.js";
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js"; import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
import type {
AgentClient,
AgentMode,
AgentModelDefinition,
ListModesOptions,
ListModelsOptions,
} from "./agent/agent-sdk-types.js";
import type { ProviderDefinition } from "./agent/provider-registry.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
const checkoutGitMocks = vi.hoisted(() => ({ const checkoutGitMocks = vi.hoisted(() => ({
checkoutResolvedBranch: vi.fn(), checkoutResolvedBranch: vi.fn(),
@@ -36,6 +45,31 @@ const paseoWorktreeServiceMocks = vi.hoisted(() => ({
createPaseoWorktree: vi.fn(), createPaseoWorktree: vi.fn(),
})); }));
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
const TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
vi.mock("../utils/checkout-git.js", async (importOriginal) => { vi.mock("../utils/checkout-git.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/checkout-git.js")>(); const actual = await importOriginal<typeof import("../utils/checkout-git.js")>();
return { return {
@@ -113,6 +147,7 @@ function createSessionForTest(options?: {
scriptRuntimeStore?: unknown; scriptRuntimeStore?: unknown;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null; getDaemonTcpHost?: () => string | null;
providerSnapshotManager?: ProviderSnapshotManager;
messages?: unknown[]; messages?: unknown[];
}): Session { }): Session {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
@@ -165,6 +200,7 @@ function createSessionForTest(options?: {
stt: null, stt: null,
tts: null, tts: null,
terminalManager: (options?.terminalManager ?? null) as any, terminalManager: (options?.terminalManager ?? null) as any,
providerSnapshotManager: options?.providerSnapshotManager,
scriptRouteStore: options?.scriptRouteStore as any, scriptRouteStore: options?.scriptRouteStore as any,
scriptRuntimeStore: options?.scriptRuntimeStore as any, scriptRuntimeStore: options?.scriptRuntimeStore as any,
getDaemonTcpPort: options?.getDaemonTcpPort, getDaemonTcpPort: options?.getDaemonTcpPort,
@@ -207,6 +243,20 @@ function createWorkspaceGitSnapshot(
}; };
} }
function createProviderSnapshotManagerStub(): ProviderSnapshotManager {
const stub = {
getSnapshot: vi.fn(() => []),
refreshSnapshotForCwd: vi.fn(async () => {}),
refreshSettingsSnapshot: vi.fn(async () => {}),
warmUpSnapshotForCwd: vi.fn(async () => {}),
on: vi.fn(),
off: vi.fn(),
};
stub.on.mockImplementation(() => stub);
stub.off.mockImplementation(() => stub);
return stub as unknown as ProviderSnapshotManager;
}
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -243,6 +293,159 @@ describe("session PR status payload normalization", () => {
}); });
}); });
describe("session provider refresh cwd routing", () => {
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
const providerSnapshotManager = createProviderSnapshotManagerStub();
const session = createSessionForTest({ providerSnapshotManager });
await session.handleMessage({
type: "refresh_providers_snapshot_request",
providers: ["codex"],
requestId: "refresh-settings",
});
expect(providerSnapshotManager.refreshSettingsSnapshot).toHaveBeenCalledWith({
providers: ["codex"],
});
expect(providerSnapshotManager.refreshSnapshotForCwd).not.toHaveBeenCalled();
});
test("routes cwd provider snapshot refreshes through workspace refresh", async () => {
const providerSnapshotManager = createProviderSnapshotManagerStub();
const session = createSessionForTest({ providerSnapshotManager });
await session.handleMessage({
type: "refresh_providers_snapshot_request",
cwd: "/tmp/workspace-refresh",
providers: ["codex"],
requestId: "refresh-workspace",
});
expect(providerSnapshotManager.refreshSnapshotForCwd).toHaveBeenCalledWith({
cwd: "/tmp/workspace-refresh",
providers: ["codex"],
});
expect(providerSnapshotManager.refreshSettingsSnapshot).not.toHaveBeenCalled();
});
test("normalizes legacy model and mode list requests without cwd to home", async () => {
const messages: unknown[] = [];
const session = createSessionForTest({ messages });
const fetchModels = vi.fn(async () => []);
const fetchModes = vi.fn(async () => []);
(session as unknown as { providerRegistry: unknown }).providerRegistry = {
codex: {
fetchModels,
fetchModes,
},
};
await session.handleMessage({
type: "list_provider_models_request",
provider: "codex",
requestId: "models-home",
});
await session.handleMessage({
type: "list_provider_modes_request",
provider: "codex",
requestId: "modes-home",
});
expect(fetchModels).toHaveBeenCalledWith({ cwd: homedir(), force: false });
expect(fetchModes).toHaveBeenCalledWith({ cwd: homedir(), force: false });
});
test("legacy model list request without cwd awaits loading snapshot without forced discovery", async () => {
const messages: unknown[] = [];
const models = deferred<AgentModelDefinition[]>();
const fetchModels = vi.fn(
async (options: ListModelsOptions): Promise<AgentModelDefinition[]> => {
expect(options.cwd).toBe(homedir());
return models.promise;
},
);
const fetchModes = vi.fn(async (_options: ListModesOptions): Promise<AgentMode[]> => []);
const providerDefinition: ProviderDefinition = {
id: "codex",
label: "Codex",
description: "Codex test provider",
defaultModeId: null,
modes: [],
createClient: () =>
({
provider: "codex",
capabilities: TEST_CAPABILITIES,
async createSession() {
throw new Error("not implemented");
},
async resumeSession() {
throw new Error("not implemented");
},
async listModels(options: ListModelsOptions) {
return fetchModels(options);
},
async isAvailable() {
return true;
},
}) satisfies AgentClient,
fetchModels,
fetchModes,
};
const providerSnapshotManager = new ProviderSnapshotManager(
{ codex: providerDefinition },
pino({ level: "silent" }),
);
const session = createSessionForTest({ messages, providerSnapshotManager });
providerSnapshotManager.getSnapshot();
await vi.waitFor(() => {
expect(fetchModels).toHaveBeenCalledTimes(1);
});
const responsePromise = session.handleMessage({
type: "list_provider_models_request",
provider: "codex",
requestId: "models-loading-home",
});
await Promise.resolve();
expect(fetchModels).toHaveBeenCalledTimes(1);
expect(fetchModels).toHaveBeenCalledWith({ cwd: homedir(), force: false });
expect(fetchModels).not.toHaveBeenCalledWith({ cwd: homedir(), force: true });
models.resolve([
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
]);
await responsePromise;
expect(fetchModels).toHaveBeenCalledTimes(1);
expect(fetchModels).not.toHaveBeenCalledWith({ cwd: homedir(), force: true });
expect(messages).toContainEqual({
type: "list_provider_models_response",
payload: {
provider: "codex",
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
],
error: null,
fetchedAt: expect.any(String),
requestId: "models-loading-home",
},
});
providerSnapshotManager.destroy();
});
});
describe("session checkout merge handling", () => { describe("session checkout merge handling", () => {
test("uses workspace git service snapshot for merge-to-base preflight", async () => { test("uses workspace git service snapshot for merge-to-base preflight", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
+93 -20
View File
@@ -86,7 +86,7 @@ import type {
ProviderOverride, ProviderOverride,
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import { AgentManager } from "./agent/agent-manager.js"; import { AgentManager } from "./agent/agent-manager.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { ProviderSnapshotManager, resolveSnapshotCwd } from "./agent/provider-snapshot-manager.js";
import type { import type {
AgentTimelineCursor, AgentTimelineCursor,
AgentTimelineFetchDirection, AgentTimelineFetchDirection,
@@ -234,7 +234,7 @@ const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean { function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
if (!appVersion) return false; if (!appVersion) return false;
// Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45" // Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45"
const base = appVersion.replace(/-.*$/, ""); const base = appVersion.replace(/-.*$/, "");
const parts = base.split(".").map(Number); const parts = base.split(".").map(Number);
const minParts = minVersion.split(".").map(Number); const minParts = minVersion.split(".").map(Number);
@@ -805,7 +805,7 @@ export class Session {
); );
} }
if (this.providerSnapshotManager) { if (this.providerSnapshotManager) {
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => { const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => {
// COMPAT(providersSnapshot): keep provider visibility gating for older clients. // COMPAT(providersSnapshot): keep provider visibility gating for older clients.
const visibleEntries = entries.filter((entry) => const visibleEntries = entries.filter((entry) =>
this.isProviderVisibleToClient(entry.provider), this.isProviderVisibleToClient(entry.provider),
@@ -3071,13 +3071,16 @@ export class Session {
private async handleListProviderModelsRequest( private async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>, msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
): Promise<void> { ): Promise<void> {
const cwd = msg.cwd ? expandTilde(msg.cwd) : undefined; const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
const fetchedAt = new Date().toISOString(); const fetchedAt = new Date().toISOString();
const manager = this.providerSnapshotManager; const manager = this.providerSnapshotManager;
if (!manager) { if (!manager) {
try { try {
const models = await this.providerRegistry[msg.provider].fetchModels({ cwd }); const models = await this.providerRegistry[msg.provider].fetchModels({
cwd,
force: false,
});
this.emit({ this.emit({
type: "list_provider_models_response", type: "list_provider_models_response",
payload: { payload: {
@@ -3106,16 +3109,7 @@ export class Session {
return; return;
} }
const findEntry = () => const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
manager.getSnapshot(cwd).find((candidate) => candidate.provider === msg.provider);
let entry = findEntry();
if (!entry || entry.status === "loading") {
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
// a resolved answer rather than a loading placeholder.
await manager.refresh({ cwd, providers: [msg.provider] });
entry = findEntry();
}
if (!entry) { if (!entry) {
this.emit({ this.emit({
@@ -3164,9 +3158,60 @@ export class Session {
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>, msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
): Promise<void> { ): Promise<void> {
const fetchedAt = new Date().toISOString(); const fetchedAt = new Date().toISOString();
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
const manager = this.providerSnapshotManager;
if (manager) {
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
if (!entry) {
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: `Unknown provider: ${msg.provider}`,
fetchedAt,
requestId: msg.requestId,
},
});
return;
}
if (entry.status === "ready") {
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
modes: entry.modes ?? [],
error: null,
fetchedAt: entry.fetchedAt ?? fetchedAt,
requestId: msg.requestId,
},
});
return;
}
const errorMessage =
entry.status === "error"
? (entry.error ?? `Failed to list modes for ${msg.provider}`)
: `Provider ${msg.provider} is not available`;
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: errorMessage,
fetchedAt,
requestId: msg.requestId,
},
});
return;
}
try { try {
const modes = await this.providerRegistry[msg.provider].fetchModes({ const modes = await this.providerRegistry[msg.provider].fetchModes({
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, cwd,
force: false,
}); });
this.emit({ this.emit({
type: "list_provider_modes_response", type: "list_provider_modes_response",
@@ -3195,6 +3240,28 @@ export class Session {
} }
} }
private async getProviderSnapshotEntryForRead(
cwd: string,
provider: AgentProvider,
): Promise<ProviderSnapshotEntry | undefined> {
const manager = this.providerSnapshotManager;
if (!manager) {
return undefined;
}
const findEntry = () =>
manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider);
let entry = findEntry();
if (!entry || entry.status === "loading") {
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
// a resolved answer rather than a loading placeholder.
await manager.warmUpSnapshotForCwd({ cwd, providers: [provider] });
entry = findEntry();
}
return entry;
}
private buildDraftAgentSessionConfig(draftConfig: { private buildDraftAgentSessionConfig(draftConfig: {
provider: AgentProvider; provider: AgentProvider;
cwd: string; cwd: string;
@@ -3301,10 +3368,16 @@ export class Session {
private async handleRefreshProvidersSnapshotRequest( private async handleRefreshProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>, msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> { ): Promise<void> {
await this.providerSnapshotManager?.refresh({ if (msg.cwd) {
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, await this.providerSnapshotManager?.refreshSnapshotForCwd({
providers: msg.providers, cwd: expandTilde(msg.cwd),
}); providers: msg.providers,
});
} else {
await this.providerSnapshotManager?.refreshSettingsSnapshot({
providers: msg.providers,
});
}
this.emit({ this.emit({
type: "refresh_providers_snapshot_response", type: "refresh_providers_snapshot_response",
payload: { payload: {
@@ -906,7 +906,7 @@ class FakeAgentClient implements AgentClient {
); );
} }
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> { async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
if (this.provider === "claude") { if (this.provider === "claude") {
return [ return [
{ provider: this.provider, id: "haiku", label: "Haiku", isDefault: true }, { provider: this.provider, id: "haiku", label: "Haiku", isDefault: true },
@@ -9,6 +9,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/in
import { OpenAITTS } from "./speech/providers/openai/tts.js"; import { OpenAITTS } from "./speech/providers/openai/tts.js";
import { OpenAISTT } from "./speech/providers/openai/stt.js"; import { OpenAISTT } from "./speech/providers/openai/stt.js";
import { STTManager } from "./agent/stt-manager.js"; import { STTManager } from "./agent/stt-manager.js";
import { withTimeout } from "../utils/promise-timeout.js";
const openaiApiKey = process.env.OPENAI_API_KEY ?? null; const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey); const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
@@ -71,24 +72,6 @@ function waitForSignal<T>(
}); });
} }
async function withTimeout<T>(label: string, timeoutMs: number, task: Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timed out during ${label} after ${timeoutMs}ms`));
}, timeoutMs);
task.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
async function streamToBuffer(stream: Readable): Promise<Buffer> { async function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
@@ -148,20 +131,20 @@ describe("voice roundtrip e2e", () => {
path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`), path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`),
); );
const voiceAgent = await withTimeout( const voiceAgent = await withTimeout(
"createVoiceTargetAgent",
30000,
ctx.client.createAgent({ ctx.client.createAgent({
config: { config: {
...getVoiceRoundtripConfig(targetProvider), ...getVoiceRoundtripConfig(targetProvider),
cwd: voiceCwd, cwd: voiceCwd,
}, },
}), }),
30000,
"Timed out during createVoiceTargetAgent after 30000ms",
); );
const voiceAgentId = voiceAgent.id; const voiceAgentId = voiceAgent.id;
const voiceMode = await withTimeout( const voiceMode = await withTimeout(
"setVoiceMode",
15000,
ctx.client.setVoiceMode(true, voiceAgentId), ctx.client.setVoiceMode(true, voiceAgentId),
15000,
"Timed out during setVoiceMode after 15000ms",
); );
expect(voiceMode.accepted).toBe(true); expect(voiceMode.accepted).toBe(true);
expect(voiceMode.enabled).toBe(true); expect(voiceMode.enabled).toBe(true);
@@ -194,14 +177,14 @@ describe("voice roundtrip e2e", () => {
}); });
const inputSpeech = await withTimeout( const inputSpeech = await withTimeout(
"synthesizeInputAudio",
30000,
ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."), ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."),
30000,
"Timed out during synthesizeInputAudio after 30000ms",
); );
const inputPcm = await withTimeout( const inputPcm = await withTimeout(
"collectInputAudio",
15000,
streamToBuffer(inputSpeech.stream), streamToBuffer(inputSpeech.stream),
15000,
"Timed out during collectInputAudio after 15000ms",
); );
const outputAudio = await (async () => { const outputAudio = await (async () => {
try { try {
@@ -281,16 +264,24 @@ describe("voice roundtrip e2e", () => {
); );
const isLast = offset + chunkBytes >= inputPcm.length; const isLast = offset + chunkBytes >= inputPcm.length;
await withTimeout( await withTimeout(
"sendVoiceAudioChunk",
5000,
ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast), ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast),
5000,
"Timed out during sendVoiceAudioChunk after 5000ms",
); );
} }
const transcript = await withTimeout("waitForTranscription", 35000, transcriptPromise); const transcript = await withTimeout(
transcriptPromise,
35000,
"Timed out during waitForTranscription after 35000ms",
);
if (transcript.text.trim().length === 0) { if (transcript.text.trim().length === 0) {
throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`); throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`);
} }
return await withTimeout("waitForAudioOutput", 95000, outputAudioPromise); return await withTimeout(
outputAudioPromise,
95000,
"Timed out during waitForAudioOutput after 95000ms",
);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`, `${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`,
@@ -311,11 +302,11 @@ describe("voice roundtrip e2e", () => {
? "audio/wav" ? "audio/wav"
: `audio/${outputAudio.format}`; : `audio/${outputAudio.format}`;
const transcription = await withTimeout( const transcription = await withTimeout(
"transcribeOutputAudio",
60000,
sttOutput.transcribe(outputRaw, outputFormat, { sttOutput.transcribe(outputRaw, outputFormat, {
label: "voice-roundtrip-output", label: "voice-roundtrip-output",
}), }),
60000,
"Timed out during transcribeOutputAudio after 60000ms",
); );
const normalized = transcription.text.trim().toLowerCase(); const normalized = transcription.text.trim().toLowerCase();
+26 -4
View File
@@ -397,7 +397,7 @@ const ToolCallBasePayloadSchema = z
callId: z.string(), callId: z.string(),
name: z.string(), name: z.string(),
detail: ToolCallDetailPayloadSchema, detail: ToolCallDetailPayloadSchema,
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.string(), z.unknown()).optional(),
}) })
.strict(); .strict();
@@ -534,7 +534,7 @@ const AgentPersistenceHandleSchema: z.ZodType<AgentPersistenceHandle | null> = z
provider: AgentProviderSchema, provider: AgentProviderSchema,
sessionId: z.string(), sessionId: z.string(),
nativeHandle: z.string().optional(), nativeHandle: z.string().optional(),
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.string(), z.unknown()).optional(),
}) })
.nullable(); .nullable();
@@ -544,7 +544,7 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
model: z.string().nullable().optional(), model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(),
modeId: z.string().nullable().optional(), modeId: z.string().nullable().optional(),
extra: z.record(z.unknown()).optional(), extra: z.record(z.string(), z.unknown()).optional(),
}); });
export const AgentSnapshotPayloadSchema = z.object({ export const AgentSnapshotPayloadSchema = z.object({
@@ -568,7 +568,7 @@ export const AgentSnapshotPayloadSchema = z.object({
lastUsage: AgentUsageSchema.optional(), lastUsage: AgentUsageSchema.optional(),
lastError: z.string().optional(), lastError: z.string().optional(),
title: z.string().nullable(), title: z.string().nullable(),
labels: z.record(z.string()).default({}), labels: z.record(z.string(), z.string()).default({}),
requiresAttention: z.boolean().optional(), requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(), attentionTimestamp: z.string().nullable().optional(),
@@ -577,6 +577,28 @@ export const AgentSnapshotPayloadSchema = z.object({
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>; export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
export const AgentListItemPayloadSchema = z.object({
id: z.string(),
shortId: z.string(),
title: z.string().nullable(),
provider: AgentProviderSchema,
model: z.string().nullable(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
status: AgentStatusSchema,
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
archivedAt: z.string().nullable().optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
});
export type AgentListItemPayload = z.infer<typeof AgentListItemPayloadSchema>;
export type AgentStreamEventPayload = z.infer<typeof AgentStreamEventPayloadSchema>; export type AgentStreamEventPayload = z.infer<typeof AgentStreamEventPayloadSchema>;
// ============================================================================ // ============================================================================
@@ -615,6 +615,41 @@ const x = 1;
expect(diff.diff).toContain("feature.txt"); expect(diff.diff).toContain("feature.txt");
}); });
it("does not include dirty working tree changes in base mode", async () => {
writeFileSync(join(repoDir, "file.txt"), "dirty\n");
writeFileSync(join(repoDir, "untracked.txt"), "untracked\n");
const diff = await getCheckoutDiff(repoDir, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expect(diff.diff).toBe("");
expect(diff.structured).toEqual([]);
});
it("shows committed branch changes without dirty working tree changes in base mode", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "dirty\n");
writeFileSync(join(repoDir, "untracked.txt"), "untracked\n");
const diff = await getCheckoutDiff(repoDir, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expect(diff.diff).toContain("feature.txt");
expect(diff.diff).not.toContain("file.txt");
expect(diff.diff).not.toContain("untracked.txt");
expect(diff.structured?.map((file) => file.path)).toEqual(["feature.txt"]);
});
it("warms shortstat cache in the background without blocking listing callers", async () => { it("warms shortstat cache in the background without blocking listing callers", async () => {
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
@@ -1524,6 +1559,36 @@ const x = 1;
expect(baseDiff.diff).not.toContain("file.txt"); expect(baseDiff.diff).not.toContain("file.txt");
}); });
it("excludes dirty working tree changes from Paseo worktree base diffs", async () => {
const worktree = await createLegacyWorktreeForTest({
branchName: "feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "dirty-feature",
paseoHome,
});
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: worktree.worktreePath });
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
cwd: worktree.worktreePath,
});
writeFileSync(join(worktree.worktreePath, "file.txt"), "dirty\n");
writeFileSync(join(worktree.worktreePath, "untracked.txt"), "untracked\n");
const baseDiff = await getCheckoutDiff(
worktree.worktreePath,
{ mode: "base", includeStructured: true },
{ paseoHome },
);
expect(baseDiff.diff).toContain("feature.txt");
expect(baseDiff.diff).not.toContain("file.txt");
expect(baseDiff.diff).not.toContain("untracked.txt");
expect(baseDiff.structured?.map((file) => file.path)).toEqual(["feature.txt"]);
});
it("resolves the repository default branch from origin HEAD", async () => { it("resolves the repository default branch from origin HEAD", async () => {
execSync("git checkout -b develop", { cwd: repoDir }); execSync("git checkout -b develop", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir }); execSync("git checkout main", { cwd: repoDir });
+51 -29
View File
@@ -103,6 +103,16 @@ type CheckoutFileChange = {
isUntracked?: boolean; isUntracked?: boolean;
}; };
type CheckoutDiffRefs = {
baseRef: string;
targetRef?: string;
includeUntracked: boolean;
};
function getCheckoutDiffRefArgs(refs: CheckoutDiffRefs): string[] {
return [refs.baseRef, ...(refs.targetRef ? [refs.targetRef] : [])];
}
function normalizeBranchSuggestionName(raw: string): string | null { function normalizeBranchSuggestionName(raw: string): string | null {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) { if (!trimmed) {
@@ -358,7 +368,7 @@ export async function checkoutResolvedBranch(
async function listCheckoutFileChanges( async function listCheckoutFileChanges(
cwd: string, cwd: string,
ref: string, refs: CheckoutDiffRefs,
ignoreWhitespace = false, ignoreWhitespace = false,
): Promise<CheckoutFileChange[]> { ): Promise<CheckoutFileChange[]> {
const changes: CheckoutFileChange[] = []; const changes: CheckoutFileChange[] = [];
@@ -366,7 +376,7 @@ async function listCheckoutFileChanges(
const { stdout: nameStatusOut } = await runGitCommand( const { stdout: nameStatusOut } = await runGitCommand(
buildGitDiffArgs({ buildGitDiffArgs({
ignoreWhitespace, ignoreWhitespace,
extra: ["--name-status", ref], extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)],
}), }),
{ cwd, env: READ_ONLY_GIT_ENV }, { cwd, env: READ_ONLY_GIT_ENV },
); );
@@ -405,24 +415,26 @@ async function listCheckoutFileChanges(
}); });
} }
const { stdout: untrackedOut } = await runGitCommand( if (refs.includeUntracked) {
["ls-files", "--others", "--exclude-standard"], const { stdout: untrackedOut } = await runGitCommand(
{ ["ls-files", "--others", "--exclude-standard"],
cwd, {
env: READ_ONLY_GIT_ENV, cwd,
}, env: READ_ONLY_GIT_ENV,
); },
for (const file of untrackedOut );
.split("\n") for (const file of untrackedOut
.map((l) => l.trim()) .split("\n")
.filter(Boolean)) { .map((l) => l.trim())
changes.push({ .filter(Boolean)) {
path: file, changes.push({
status: "U", path: file,
isNew: true, status: "U",
isDeleted: false, isNew: true,
isUntracked: true, isDeleted: false,
}); isUntracked: true,
});
}
} }
// Deduplicate by path (prefer tracked status over untracked marker if both appear). // Deduplicate by path (prefer tracked status over untracked marker if both appear).
@@ -495,13 +507,13 @@ const TRACKED_MAX_CHANGED_LINES = 40_000;
async function getTrackedNumstatByPath( async function getTrackedNumstatByPath(
cwd: string, cwd: string,
ref: string, refs: CheckoutDiffRefs,
ignoreWhitespace = false, ignoreWhitespace = false,
): Promise<Map<string, FileStat>> { ): Promise<Map<string, FileStat>> {
const result = await runGitCommand( const result = await runGitCommand(
buildGitDiffArgs({ buildGitDiffArgs({
ignoreWhitespace, ignoreWhitespace,
extra: ["--numstat", ref], extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)],
}), }),
{ {
cwd, cwd,
@@ -1507,10 +1519,10 @@ export async function getCheckoutDiff(
): Promise<CheckoutDiffResult> { ): Promise<CheckoutDiffResult> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
let refForDiff: string; let refsForDiff: CheckoutDiffRefs;
if (compare.mode === "uncommitted") { if (compare.mode === "uncommitted") {
refForDiff = "HEAD"; refsForDiff = { baseRef: "HEAD", includeUntracked: true };
} else { } else {
const configured = await getConfiguredBaseRefForCwd(cwd, context); const configured = await getConfiguredBaseRefForCwd(cwd, context);
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd)); const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd));
@@ -1522,11 +1534,15 @@ export async function getCheckoutDiff(
} }
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef; refsForDiff = {
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
targetRef: "HEAD",
includeUntracked: false,
};
} }
const ignoreWhitespace = compare.ignoreWhitespace === true; const ignoreWhitespace = compare.ignoreWhitespace === true;
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace); const changes = await listCheckoutFileChanges(cwd, refsForDiff, ignoreWhitespace);
changes.sort((a, b) => { changes.sort((a, b) => {
if (a.path === b.path) return 0; if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1; return a.path < b.path ? -1 : 1;
@@ -1557,7 +1573,7 @@ export async function getCheckoutDiff(
const trackedNumstatByPath = const trackedNumstatByPath =
trackedChanges.length > 0 trackedChanges.length > 0
? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace) ? await getTrackedNumstatByPath(cwd, refsForDiff, ignoreWhitespace)
: new Map<string, FileStat>(); : new Map<string, FileStat>();
const trackedDiffPaths: string[] = []; const trackedDiffPaths: string[] = [];
const trackedPlaceholderByPath = new Map< const trackedPlaceholderByPath = new Map<
@@ -1584,7 +1600,7 @@ export async function getCheckoutDiff(
const trackedDiffResult = await runGitCommand( const trackedDiffResult = await runGitCommand(
buildGitDiffArgs({ buildGitDiffArgs({
ignoreWhitespace, ignoreWhitespace,
extra: [refForDiff, "--", ...trackedDiffPaths], extra: [...getCheckoutDiffRefArgs(refsForDiff), "--", ...trackedDiffPaths],
}), }),
{ {
cwd, cwd,
@@ -1621,7 +1637,13 @@ export async function getCheckoutDiff(
return null; return null;
} }
const refPath = change.oldPath ?? change.path; const refPath = change.oldPath ?? change.path;
return readGitFileContentAtRef(cwd, refForDiff, refPath); return readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath);
},
getNewFileContent: async (file) => {
if (!refsForDiff.targetRef) {
return null;
}
return readGitFileContentAtRef(cwd, refsForDiff.targetRef, file.path);
}, },
}) })
: []; : [];
@@ -0,0 +1,45 @@
type TimeoutOptions<T> = {
promise: Promise<T>;
timeoutMs: number;
label: string;
};
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
export function withTimeout<T>(options: TimeoutOptions<T>): Promise<T>;
export function withTimeout<T>(
promiseOrOptions: Promise<T> | TimeoutOptions<T>,
timeoutMs?: number,
message?: string,
): Promise<T> {
const options =
typeof timeoutMs === "number"
? { promise: promiseOrOptions as Promise<T>, timeoutMs, message }
: resolveTimeoutOptions(promiseOrOptions as TimeoutOptions<T>);
if (typeof options.timeoutMs !== "number" || !options.message) {
return Promise.reject(new Error("Timeout duration and message are required"));
}
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(options.message)), options.timeoutMs);
});
return Promise.race([options.promise, timeoutPromise]).finally(() => {
if (timeout) {
clearTimeout(timeout);
}
});
}
function resolveTimeoutOptions<T>(options: TimeoutOptions<T>): {
promise: Promise<T>;
timeoutMs: number;
message: string;
} {
return {
promise: options.promise,
timeoutMs: options.timeoutMs,
message: `Timed out after ${options.timeoutMs}ms (${options.label})`,
};
}
+2 -2
View File
@@ -1089,7 +1089,7 @@ describe("slugify", () => {
}); });
it("keeps very long names within the hostname label length limit", () => { it("keeps very long names within the hostname label length limit", () => {
const result = slugify("Release Candidate ".repeat(12)); const result = slugify("Beta Build ".repeat(12));
expect(result.length).toBeLessThanOrEqual(63); expect(result.length).toBeLessThanOrEqual(63);
expectValidHostnameLabel(result); expectValidHostnameLabel(result);
@@ -1107,7 +1107,7 @@ describe("slugify", () => {
"feature/cool stuff", "feature/cool stuff",
" Café Launch ", " Café Launch ",
"__bar__", "__bar__",
"Release Candidate ".repeat(12), "Beta Build ".repeat(12),
"release***candidate", "release***candidate",
]; ];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.60-rc.3", "version": "0.1.60-beta.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -1
View File
@@ -38,8 +38,9 @@ const entries = [
["RELEASE_BASE_VERSION", info.baseVersion], ["RELEASE_BASE_VERSION", info.baseVersion],
["RELEASE_PRERELEASE", info.prerelease ?? ""], ["RELEASE_PRERELEASE", info.prerelease ?? ""],
["IS_PRERELEASE", info.isPrerelease ? "true" : "false"], ["IS_PRERELEASE", info.isPrerelease ? "true" : "false"],
["IS_RELEASE_CANDIDATE", info.isReleaseCandidate ? "true" : "false"], ["IS_BETA", info.isBeta ? "true" : "false"],
["RELEASE_TYPE", info.releaseType], ["RELEASE_TYPE", info.releaseType],
["RELEASE_CHANNEL", info.releaseChannel],
["DESKTOP_VERSION", info.version], ["DESKTOP_VERSION", info.version],
["IS_SMOKE_TAG", info.isSmokeTag ? "true" : "false"], ["IS_SMOKE_TAG", info.isSmokeTag ? "true" : "false"],
]; ];
+30 -23
View File
@@ -14,7 +14,7 @@ export function parseReleaseVersion(version) {
const match = trimmed.match(versionPattern); const match = trimmed.match(versionPattern);
if (!match?.groups) { if (!match?.groups) {
throw new Error( throw new Error(
`Unsupported release version "${version}". Expected semver like 0.1.41 or 0.1.41-rc.1.`, `Unsupported release version "${version}". Expected semver like 0.1.41 or 0.1.41-beta.1.`,
); );
} }
@@ -22,14 +22,20 @@ export function parseReleaseVersion(version) {
const minor = Number.parseInt(match.groups.minor, 10); const minor = Number.parseInt(match.groups.minor, 10);
const patch = Number.parseInt(match.groups.patch, 10); const patch = Number.parseInt(match.groups.patch, 10);
const prerelease = match.groups.prerelease ?? null; const prerelease = match.groups.prerelease ?? null;
const rcMatch = prerelease?.match(/^rc\.(?<rc>\d+)$/) ?? null; const betaMatch = prerelease?.match(/^beta\.(?<beta>\d+)$/) ?? null;
const rcNumber = rcMatch?.groups?.rc ? Number.parseInt(rcMatch.groups.rc, 10) : null; const betaNumber = betaMatch?.groups?.beta ? Number.parseInt(betaMatch.groups.beta, 10) : null;
if (prerelease !== null && betaNumber === null) {
throw new Error(
`Unsupported release version "${version}". Expected beta prerelease versions like 0.1.41-beta.1.`,
);
}
assertInteger(major, "major version"); assertInteger(major, "major version");
assertInteger(minor, "minor version"); assertInteger(minor, "minor version");
assertInteger(patch, "patch version"); assertInteger(patch, "patch version");
if (rcNumber !== null) { if (betaNumber !== null) {
assertInteger(rcNumber, "release candidate number"); assertInteger(betaNumber, "beta number");
} }
return { return {
@@ -40,8 +46,8 @@ export function parseReleaseVersion(version) {
prerelease, prerelease,
baseVersion: `${major}.${minor}.${patch}`, baseVersion: `${major}.${minor}.${patch}`,
isPrerelease: prerelease !== null, isPrerelease: prerelease !== null,
isReleaseCandidate: rcNumber !== null, isBeta: betaNumber !== null,
rcNumber, betaNumber,
}; };
} }
@@ -57,7 +63,7 @@ export function normalizeReleaseTag(rawTag) {
const match = trimmed.match(sourceTagPattern); const match = trimmed.match(sourceTagPattern);
if (!match?.groups?.version) { if (!match?.groups?.version) {
throw new Error( throw new Error(
`Unsupported release tag "${rawTag}". Expected vX.Y.Z, vX.Y.Z-rc.N, desktop-v..., or android-v...`, `Unsupported release tag "${rawTag}". Expected vX.Y.Z, vX.Y.Z-beta.N, desktop-v..., or android-v...`,
); );
} }
return `v${match.groups.version}`; return `v${match.groups.version}`;
@@ -73,9 +79,10 @@ export function getReleaseInfoFromSourceTag(sourceTag) {
baseVersion: parsed.baseVersion, baseVersion: parsed.baseVersion,
prerelease: parsed.prerelease, prerelease: parsed.prerelease,
isPrerelease: parsed.isPrerelease, isPrerelease: parsed.isPrerelease,
isReleaseCandidate: parsed.isReleaseCandidate, isBeta: parsed.isBeta,
rcNumber: parsed.rcNumber, betaNumber: parsed.betaNumber,
releaseType: parsed.isPrerelease ? "prerelease" : "release", releaseType: parsed.isPrerelease ? "prerelease" : "release",
releaseChannel: parsed.isBeta ? "beta" : "latest",
isSmokeTag: sourceTag.includes("gha-smoke"), isSmokeTag: sourceTag.includes("gha-smoke"),
}; };
} }
@@ -110,54 +117,54 @@ export function computeNextReleaseVersion(currentVersion, mode) {
}); });
} }
if (mode === "rc-patch" || mode === "rc-minor" || mode === "rc-major") { if (mode === "beta-patch" || mode === "beta-minor" || mode === "beta-major") {
if (parsed.isPrerelease) { if (parsed.isPrerelease) {
throw new Error( throw new Error(
`Cannot start a new RC line from prerelease version ${currentVersion}. Use rc-next or promote.`, `Cannot start a new beta line from prerelease version ${currentVersion}. Use beta-next or promote.`,
); );
} }
if (mode === "rc-patch") { if (mode === "beta-patch") {
return formatReleaseVersion({ return formatReleaseVersion({
major: parsed.major, major: parsed.major,
minor: parsed.minor, minor: parsed.minor,
patch: parsed.patch + 1, patch: parsed.patch + 1,
prerelease: "rc.1", prerelease: "beta.1",
}); });
} }
if (mode === "rc-minor") { if (mode === "beta-minor") {
return formatReleaseVersion({ return formatReleaseVersion({
major: parsed.major, major: parsed.major,
minor: parsed.minor + 1, minor: parsed.minor + 1,
patch: 0, patch: 0,
prerelease: "rc.1", prerelease: "beta.1",
}); });
} }
return formatReleaseVersion({ return formatReleaseVersion({
major: parsed.major + 1, major: parsed.major + 1,
minor: 0, minor: 0,
patch: 0, patch: 0,
prerelease: "rc.1", prerelease: "beta.1",
}); });
} }
if (mode === "rc-next") { if (mode === "beta-next") {
if (!parsed.isReleaseCandidate || parsed.rcNumber === null) { if (!parsed.isBeta || parsed.betaNumber === null) {
throw new Error( throw new Error(
`Cannot advance RC number from ${currentVersion}. Expected a version like 0.1.41-rc.1.`, `Cannot advance beta number from ${currentVersion}. Expected a version like 0.1.41-beta.1.`,
); );
} }
return formatReleaseVersion({ return formatReleaseVersion({
major: parsed.major, major: parsed.major,
minor: parsed.minor, minor: parsed.minor,
patch: parsed.patch, patch: parsed.patch,
prerelease: `rc.${parsed.rcNumber + 1}`, prerelease: `beta.${parsed.betaNumber + 1}`,
}); });
} }
if (mode === "promote") { if (mode === "promote") {
if (!parsed.isReleaseCandidate) { if (!parsed.isBeta) {
throw new Error( throw new Error(
`Cannot promote ${currentVersion}. Expected a release candidate version like 0.1.41-rc.1.`, `Cannot promote ${currentVersion}. Expected a beta version like 0.1.41-beta.1.`,
); );
} }
return parsed.baseVersion; return parsed.baseVersion;

Some files were not shown because too many files have changed in this diff Show More