mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
13 Commits
v0.1.60-rc
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00870740ef | ||
|
|
ebf8a21c08 | ||
|
|
515ba62be6 | ||
|
|
32a20cbeea | ||
|
|
9877936d13 | ||
|
|
1a473ed7f3 | ||
|
|
3832053736 | ||
|
|
b01e274cce | ||
|
|
92e701be8f | ||
|
|
c2c990e763 | ||
|
|
76b9f89f68 | ||
|
|
970bf828b3 | ||
|
|
dfc814147a |
4
.github/workflows/deploy-app.yml
vendored
4
.github/workflows/deploy-app.yml
vendored
@@ -4,9 +4,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-rc.*'
|
||||
- '!v*-beta.*'
|
||||
- 'app-v*'
|
||||
- '!app-v*-rc.*'
|
||||
- '!app-v*-beta.*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
18
.github/workflows/desktop-release.yml
vendored
18
.github/workflows/desktop-release.yml
vendored
@@ -66,7 +66,9 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--generate-notes \
|
||||
$prerelease_flag
|
||||
$prerelease_flag || {
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
}
|
||||
fi
|
||||
|
||||
publish-macos:
|
||||
@@ -145,6 +147,7 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
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
|
||||
with:
|
||||
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
|
||||
|
||||
finalize-mac-manifest:
|
||||
@@ -227,8 +230,9 @@ jobs:
|
||||
return out;
|
||||
}
|
||||
|
||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
|
||||
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
|
||||
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
|
||||
|
||||
const arm64 = parseManifest(arm64Text);
|
||||
const x64 = parseManifest(x64Text);
|
||||
@@ -243,7 +247,7 @@ jobs:
|
||||
};
|
||||
|
||||
const output = toYaml(merged);
|
||||
fs.writeFileSync('latest-mac.yml', output);
|
||||
fs.writeFileSync(manifestName, output);
|
||||
console.log('Merged manifest:\n' + output);
|
||||
NODE
|
||||
|
||||
@@ -251,7 +255,7 @@ jobs:
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
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:
|
||||
needs: [create-release]
|
||||
@@ -316,6 +320,7 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
@@ -391,6 +396,7 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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)
|
||||
- `.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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Release candidate flow
|
||||
## Beta flow
|
||||
|
||||
```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 ...
|
||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
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`
|
||||
- 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
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- 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 beta tag
|
||||
- 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
|
||||
- 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
|
||||
|
||||
- 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`.
|
||||
|
||||
## 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
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
|
||||
# RC
|
||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
||||
# Beta
|
||||
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.
|
||||
|
||||
- `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-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
|
||||
@@ -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
|
||||
- `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
|
||||
- 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
|
||||
|
||||
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-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.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` is for **final stable releases only**.
|
||||
- Do not add or edit changelog entries while iterating on RCs.
|
||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||
- 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
|
||||
|
||||
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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**:
|
||||
|
||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||
- **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.
|
||||
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-wa+PE3X1UP/5Efvr73DnKTvgXYsLBjNLuN+HiRM6tGI=";
|
||||
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
|
||||
|
||||
# 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).
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -37303,16 +37303,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-rc.3",
|
||||
"@getpaseo/highlight": "0.1.60-rc.3",
|
||||
"@getpaseo/server": "0.1.60-rc.3",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -37456,11 +37456,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.60-rc.3",
|
||||
"@getpaseo/server": "0.1.60-rc.3",
|
||||
"@getpaseo/relay": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37501,11 +37501,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.60-rc.3",
|
||||
"@getpaseo/server": "0.1.60-rc.3",
|
||||
"@getpaseo/cli": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -37539,7 +37539,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -37740,7 +37740,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37766,7 +37766,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -37782,14 +37782,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.60-rc.3",
|
||||
"@getpaseo/relay": "0.1.60-rc.3",
|
||||
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||
"@getpaseo/relay": "0.1.60-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-coding-agent": "^0.67.68",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
@@ -38543,7 +38543,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -47,19 +47,19 @@
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||
"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:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
|
||||
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"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:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && 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: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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-rc.3",
|
||||
"@getpaseo/highlight": "0.1.60-rc.3",
|
||||
"@getpaseo/server": "0.1.60-rc.3",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -92,7 +92,7 @@ type ControlledAgentStatusBarProps = {
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
selectedProvider: AgentProvider;
|
||||
selectedProvider: AgentProvider | null;
|
||||
onSelectProvider: (provider: AgentProvider) => void;
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
@@ -255,7 +255,8 @@ function ControlledStatusBar({
|
||||
: undefined;
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||
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 =
|
||||
Boolean(providerOptions?.length) ||
|
||||
@@ -620,7 +621,9 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Agent preferences"
|
||||
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}>
|
||||
{displayModel}
|
||||
</Text>
|
||||
@@ -662,7 +665,12 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
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>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -872,15 +880,15 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
const snapshotSelectedEntry = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
return null;
|
||||
}
|
||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||
return entry?.models ?? null;
|
||||
return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
|
||||
}, [snapshotEntries, agent?.provider]);
|
||||
|
||||
const models = snapshotModels;
|
||||
const models = snapshotSelectedEntry?.models ?? null;
|
||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = agent?.provider
|
||||
@@ -891,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && snapshotModels) {
|
||||
map.set(agent.provider, snapshotModels);
|
||||
if (agent?.provider && models) {
|
||||
map.set(agent.provider, models);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, snapshotModels]);
|
||||
}, [agent?.provider, models]);
|
||||
|
||||
const displayMode =
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
@@ -1039,8 +1047,8 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
isModelLoading={snapshotIsLoading}
|
||||
onModelSelectorOpen={refetchSnapshotIfStale}
|
||||
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||
onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1096,6 +1104,7 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||
const hasSelectedProvider = selectedProvider !== null;
|
||||
|
||||
if (platformIsWeb) {
|
||||
return (
|
||||
@@ -1103,7 +1112,7 @@ export function DraftAgentStatusBar({
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
@@ -1119,20 +1128,22 @@ export function DraftAgentStatusBar({
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{selectedProvider ? (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1145,10 +1156,10 @@ export function DraftAgentStatusBar({
|
||||
return (
|
||||
<>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
provider={selectedProvider ?? ""}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={mappedModeOptions}
|
||||
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
|
||||
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (!selectedModel) {
|
||||
if (!hasSelectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
|
||||
}
|
||||
const model = models.find((entry) => entry.id === selectedModel);
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
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">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
|
||||
@@ -8,18 +8,8 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
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 { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -34,6 +24,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -312,48 +303,6 @@ export function FileExplorerPane({
|
||||
const handleRefresh = useCallback(() => {
|
||||
void 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";
|
||||
|
||||
@@ -563,11 +512,15 @@ export function FileExplorerPane({
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
|
||||
@@ -185,7 +185,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel(projects);
|
||||
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
|
||||
@@ -118,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||||
|
||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||||
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 = `
|
||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||
0% {
|
||||
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
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}
|
||||
</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 { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
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 { Fonts } from "@/constants/theme";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refresh([provider as AgentProvider]);
|
||||
void fetchDiagnostic({ keepCurrent: true });
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||
});
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
|
||||
refreshInFlight ? sheetStyles.disabled : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||
accessibilityLabel={
|
||||
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
|
||||
}
|
||||
>
|
||||
<SpinningRefreshIcon
|
||||
spinning={refreshInFlight}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
{refreshInFlight ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
10
packages/app/src/components/ui/loading-spinner.tsx
Normal file
10
packages/app/src/components/ui/loading-spinner.tsx
Normal file
@@ -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,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const { theme } = useUnistyles();
|
||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]} testID={testID}>
|
||||
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignItems: "stretch",
|
||||
maxWidth: "100%",
|
||||
gap: 4,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
containerSm: {
|
||||
padding: 2,
|
||||
},
|
||||
containerMd: {
|
||||
padding: 3,
|
||||
},
|
||||
segment: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentSm: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
segmentMd: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
},
|
||||
segmentSelected: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
segmentHover: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
@@ -135,10 +143,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
labelSm: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
labelMd: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
labelSelected: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const encodedImages = await encodeImages(wirePayload.images);
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type DesktopReleaseChannel = "stable" | "beta";
|
||||
|
||||
export interface LocalDaemonUpdateResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
|
||||
return parseLocalDaemonVersionResult(result);
|
||||
}
|
||||
|
||||
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("check_app_update");
|
||||
export async function checkDesktopAppUpdate({
|
||||
releaseChannel,
|
||||
}: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateCheckResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
|
||||
if (!isRecord(result)) {
|
||||
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> {
|
||||
const result = await invokeDesktopCommand<unknown>("install_app_update");
|
||||
export async function installDesktopAppUpdate({
|
||||
releaseChannel,
|
||||
}: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateInstallResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
|
||||
if (!isRecord(result)) {
|
||||
throw new Error("Unexpected response while installing desktop update.");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type DesktopAppUpdateCheckResult,
|
||||
type DesktopAppUpdateInstallResult,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
export type DesktopAppUpdateStatus =
|
||||
| "idle"
|
||||
@@ -83,6 +84,8 @@ function formatStatusText(input: {
|
||||
|
||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
const isDesktopApp = shouldShowDesktopUpdateSection();
|
||||
const { settings } = useAppSettings();
|
||||
const releaseChannel = settings.releaseChannel;
|
||||
const requestVersionRef = useRef(0);
|
||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
||||
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await checkDesktopAppUpdate();
|
||||
const result = await checkDesktopAppUpdate({ releaseChannel });
|
||||
if (requestVersion !== requestVersionRef.current) {
|
||||
return result;
|
||||
}
|
||||
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[isDesktopApp],
|
||||
[isDesktopApp, releaseChannel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
void checkForUpdates({ silent: true });
|
||||
}, [checkForUpdates, isDesktopApp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp || status !== "pending") {
|
||||
return undefined;
|
||||
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await installDesktopAppUpdate();
|
||||
const result = await installDesktopAppUpdate({ releaseChannel });
|
||||
setLastCheckedAt(Date.now());
|
||||
|
||||
if (result.installed) {
|
||||
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(message);
|
||||
return null;
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
}, [isDesktopApp, releaseChannel]);
|
||||
|
||||
return {
|
||||
isDesktopApp,
|
||||
|
||||
209
packages/app/src/hooks/use-agent-form-state.live.test.ts
Normal file
209
packages/app/src/hooks/use-agent-form-state.live.test.ts
Normal file
@@ -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", () => {
|
||||
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", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
@@ -374,7 +511,7 @@ describe("useAgentFormState", () => {
|
||||
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(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
@@ -399,7 +536,121 @@ describe("useAgentFormState", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||
// Internal form state
|
||||
interface FormState {
|
||||
serverId: string | null;
|
||||
provider: AgentProvider;
|
||||
provider: AgentProvider | null;
|
||||
modeId: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
@@ -68,7 +68,7 @@ export type UseAgentFormStateResult = {
|
||||
selectedServerId: string | null;
|
||||
setSelectedServerId: (value: string | null) => void;
|
||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||
selectedProvider: AgentProvider;
|
||||
selectedProvider: AgentProvider | null;
|
||||
setProviderFromUser: (provider: AgentProvider) => void;
|
||||
selectedMode: string;
|
||||
setModeFromUser: (modeId: string) => void;
|
||||
@@ -97,9 +97,6 @@ export type UseAgentFormStateResult = {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER: AgentProvider = "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
|
||||
|
||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized) {
|
||||
@@ -160,6 +157,18 @@ function resolveThinkingOptionId(args: {
|
||||
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.
|
||||
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
||||
@@ -177,8 +186,6 @@ function resolveFormState(
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
|
||||
|
||||
// 1. Resolve provider first (other fields depend on it)
|
||||
if (!userModified.provider) {
|
||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||
@@ -188,22 +195,33 @@ function resolveFormState(
|
||||
allowedProviderMap.has(preferences.provider as AgentProvider)
|
||||
) {
|
||||
result.provider = preferences.provider as AgentProvider;
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
} else if (
|
||||
result.provider &&
|
||||
allowedProviderMap.size > 0 &&
|
||||
!allowedProviderMap.has(result.provider)
|
||||
) {
|
||||
result.provider = null;
|
||||
}
|
||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
} else if (
|
||||
result.provider &&
|
||||
allowedProviderMap.size > 0 &&
|
||||
!allowedProviderMap.has(result.provider)
|
||||
) {
|
||||
result.provider = null;
|
||||
}
|
||||
|
||||
const providerDef = allowedProviderMap.get(result.provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
||||
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||
const providerPrefs = result.provider
|
||||
? preferences?.providerPreferences?.[result.provider]
|
||||
: undefined;
|
||||
|
||||
// 2. Resolve modeId (depends on provider)
|
||||
if (!userModified.modeId) {
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
|
||||
if (
|
||||
if (!result.provider) {
|
||||
result.modeId = "";
|
||||
} else if (
|
||||
typeof initialValues?.modeId === "string" &&
|
||||
initialValues.modeId.length > 0 &&
|
||||
validModeIds.includes(initialValues.modeId)
|
||||
@@ -223,7 +241,9 @@ function resolveFormState(
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||
|
||||
if (initialModel) {
|
||||
if (!result.provider) {
|
||||
result.model = "";
|
||||
} else if (initialModel) {
|
||||
if (!availableModels || isValidModel(initialModel)) {
|
||||
result.model = initialModel;
|
||||
} else {
|
||||
@@ -246,7 +266,9 @@ function resolveFormState(
|
||||
? initialValues.thinkingOptionId.trim()
|
||||
: "";
|
||||
|
||||
if (!userModified.thinkingOptionId) {
|
||||
if (!result.provider) {
|
||||
result.thinkingOptionId = "";
|
||||
} else if (!userModified.thinkingOptionId) {
|
||||
const effectiveModelId = result.model.trim();
|
||||
const preferredThinking = effectiveModelId
|
||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||
@@ -262,7 +284,7 @@ function resolveFormState(
|
||||
}
|
||||
|
||||
// Validate thinking option once model metadata is available.
|
||||
if (availableModels) {
|
||||
if (result.provider && availableModels) {
|
||||
result.thinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels,
|
||||
modelId: result.model,
|
||||
@@ -312,6 +334,37 @@ function combineInitialValues(
|
||||
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 {
|
||||
const {
|
||||
initialServerId = null,
|
||||
@@ -335,8 +388,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
// Form state
|
||||
const [formState, setFormState] = useState<FormState>(() => ({
|
||||
serverId: initialServerId,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
|
||||
provider: null,
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
@@ -348,12 +401,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
// Track if we've done initial resolution (to avoid flickering)
|
||||
const hasResolvedRef = useRef(false);
|
||||
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||
|
||||
// Reset user modifications when form becomes invisible
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setUserModified(INITIAL_USER_MODIFIED);
|
||||
hasResolvedRef.current = false;
|
||||
hydrationPreferencesRef.current = null;
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
@@ -371,25 +426,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||
),
|
||||
() => buildProviderDefinitionMap(snapshotProviderDefinitions),
|
||||
[snapshotProviderDefinitions],
|
||||
);
|
||||
const snapshotResolvableProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries,
|
||||
providerDefinitions: snapshotProviderDefinitions,
|
||||
statuses: RESOLVABLE_PROVIDER_STATUSES,
|
||||
}),
|
||||
[snapshotEntries, snapshotProviderDefinitions],
|
||||
);
|
||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||
if (!snapshotEntries?.length) {
|
||||
return snapshotProviderDefinitionMap;
|
||||
}
|
||||
const readyProviders = new Set(
|
||||
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
|
||||
);
|
||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions
|
||||
.filter((definition) => readyProviders.has(definition.id))
|
||||
.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
|
||||
return buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries,
|
||||
providerDefinitions: snapshotProviderDefinitions,
|
||||
statuses: SELECTABLE_PROVIDER_STATUSES,
|
||||
});
|
||||
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||
const snapshotAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
@@ -398,13 +453,17 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
return map;
|
||||
}, [snapshotEntries]);
|
||||
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],
|
||||
);
|
||||
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||
const snapshotSelectedProviderModes =
|
||||
snapshotSelectedEntry?.modes ??
|
||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
||||
(formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ??
|
||||
[];
|
||||
const providerDefinitions = snapshotProviderDefinitions;
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
@@ -412,7 +471,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading;
|
||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
@@ -425,19 +484,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for preferences to load before first resolution, unless explicit URL overrides exist.
|
||||
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
|
||||
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasResolvedRef.current) {
|
||||
hydrationPreferencesRef.current = preferences;
|
||||
}
|
||||
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||
|
||||
const resolved = resolveFormState(
|
||||
combinedInitialValues,
|
||||
preferences,
|
||||
hydrationPreferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formStateRef.current,
|
||||
validServerIds,
|
||||
selectableProviderDefinitionMap,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
@@ -462,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
availableModels,
|
||||
userModified,
|
||||
validServerIds,
|
||||
selectableProviderDefinitionMap,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
]);
|
||||
|
||||
// Auto-select the first online host when:
|
||||
@@ -571,15 +634,38 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||
void updatePreferences({ provider });
|
||||
void updatePreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
updates: {
|
||||
model: nextModelId || undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback((modeId: string) => {
|
||||
setFormState((prev) => ({ ...prev, modeId }));
|
||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||
}, []);
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
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(
|
||||
(modelId: string) => {
|
||||
@@ -598,14 +684,44 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
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) => {
|
||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||
}, []);
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
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) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
@@ -625,16 +741,21 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const refetchProviderModelsIfStale = useCallback(() => {
|
||||
refetchSnapshotIfStale();
|
||||
refetchSnapshotIfStale(formStateRef.current.provider);
|
||||
}, [refetchSnapshotIfStale]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
if (!formState.provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = formState.provider;
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const modelId = resolvedModel?.id ?? formState.model;
|
||||
await updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: formState.provider,
|
||||
provider,
|
||||
updates: {
|
||||
model: modelId || undefined,
|
||||
mode: formState.modeId || undefined,
|
||||
@@ -657,11 +778,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
updatePreferences,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const agentDefinition = formState.provider
|
||||
? providerDefinitionMap.get(formState.provider)
|
||||
: undefined;
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = snapshotIsLoading;
|
||||
const isModelLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||
const modelError = snapshotError;
|
||||
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
@@ -738,7 +861,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
export type CreateAgentInitialValues = FormInitialValues;
|
||||
|
||||
export const __private__ = {
|
||||
buildProviderDefinitionMap,
|
||||
buildProviderDefinitionMapForStatuses,
|
||||
combineInitialValues,
|
||||
mergeSelectedComposerPreferences,
|
||||
resolveDefaultModel,
|
||||
resolveFormState,
|
||||
resolveThinkingOptionId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type AttachmentUpdater =
|
||||
| ComposerAttachment[]
|
||||
@@ -113,7 +114,7 @@ function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
}
|
||||
|
||||
function buildDraftComposerCommandConfig(input: {
|
||||
provider: DraftAgentStatusBarProps["selectedProvider"];
|
||||
provider: AgentProvider | null;
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
@@ -122,7 +123,7 @@ function buildDraftComposerCommandConfig(input: {
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!cwd) {
|
||||
if (!input.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type DraftFeatureConfig = Pick<
|
||||
|
||||
export function useDraftAgentFeatures(input: {
|
||||
serverId: string | null | undefined;
|
||||
provider: AgentProvider;
|
||||
provider: AgentProvider | null;
|
||||
cwd: string | null | undefined;
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
@@ -28,30 +28,31 @@ export function useDraftAgentFeatures(input: {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const normalizedProvider = provider ?? null;
|
||||
const persistedFeatureValues = useMemo(
|
||||
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
|
||||
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
||||
[preferences.providerPreferences, provider],
|
||||
);
|
||||
|
||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||
if (!normalizedCwd) {
|
||||
if (!normalizedProvider || !normalizedCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
provider: normalizedProvider,
|
||||
cwd: normalizedCwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
|
||||
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
|
||||
|
||||
const featuresQuery = useQuery({
|
||||
queryKey: [
|
||||
"providerFeatures",
|
||||
serverId ?? null,
|
||||
provider,
|
||||
normalizedProvider,
|
||||
normalizedCwd || null,
|
||||
modeId ?? null,
|
||||
modelId ?? null,
|
||||
@@ -106,6 +107,9 @@ export function useDraftAgentFeatures(input: {
|
||||
|
||||
return { ...current, [featureId]: value };
|
||||
});
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
void updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
|
||||
277
packages/app/src/hooks/use-providers-snapshot.test.ts
Normal file
277
packages/app/src/hooks/use-providers-snapshot.test.ts
Normal file
@@ -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 { 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();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
@@ -15,10 +15,33 @@ function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||
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) {
|
||||
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 {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
@@ -27,7 +50,7 @@ interface UseProvidersSnapshotResult {
|
||||
error: string | null;
|
||||
supportsSnapshot: boolean;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
refetchIfStale: () => void;
|
||||
refetchIfStale: (selectedProvider?: AgentProvider | null) => void;
|
||||
}
|
||||
|
||||
interface UseProvidersSnapshotOptions {
|
||||
@@ -62,7 +85,7 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers });
|
||||
await client.refreshProvidersSnapshot(
|
||||
refreshProvidersSnapshotRequest(normalizedCwd, providers),
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||
@@ -85,8 +110,7 @@ export function useProvidersSnapshot(
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd);
|
||||
if (messageCwdKey !== normalizedCwdKey) {
|
||||
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, {
|
||||
@@ -94,11 +118,20 @@ export function useProvidersSnapshot(
|
||||
generatedAt: message.payload.generatedAt,
|
||||
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,
|
||||
enabled,
|
||||
isConnected,
|
||||
normalizedCwd,
|
||||
normalizedCwdKey,
|
||||
queryClient,
|
||||
queryKey,
|
||||
@@ -108,14 +141,36 @@ export function useProvidersSnapshot(
|
||||
|
||||
const refresh = useCallback(
|
||||
async (providers?: AgentProvider[]) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await refreshSnapshot(providers);
|
||||
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||
queryClient.setQueryData(queryKey, snapshot);
|
||||
},
|
||||
[refreshSnapshot],
|
||||
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
|
||||
);
|
||||
|
||||
const refetchIfStale = useCallback(() => {
|
||||
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
|
||||
}, [queryClient, queryKey]);
|
||||
const refetchIfStale = useCallback(
|
||||
(selectedProvider?: AgentProvider | null) => {
|
||||
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 {
|
||||
entries: snapshotQuery.data?.entries ?? undefined,
|
||||
@@ -139,6 +194,6 @@ export function prefetchProvidersSnapshot(
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
@@ -58,7 +68,24 @@ describe("use-settings", () => {
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
sendBehavior: "interrupt",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,19 +9,23 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
manageBuiltInDaemon: boolean;
|
||||
sendBehavior: SendBehavior;
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
releaseChannel: "stable",
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
@@ -85,6 +89,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
if (parsed.theme && !VALID_THEMES.has(parsed.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 };
|
||||
}
|
||||
|
||||
@@ -115,6 +122,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
|
||||
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
||||
}
|
||||
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
|
||||
result.releaseChannel = legacy.releaseChannel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
|
||||
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(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
@@ -25,6 +29,10 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoad || projects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collapsibleProjectKeys = new Set(
|
||||
projects
|
||||
.filter((project) => !isSidebarProjectFlattened(project))
|
||||
@@ -35,7 +43,7 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
setProjectCollapsed(key, false);
|
||||
}
|
||||
}
|
||||
}, [collapsedProjectKeys, projects, setProjectCollapsed]);
|
||||
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
|
||||
|
||||
return {
|
||||
collapsedProjectKeys,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
@@ -30,21 +31,24 @@ function useTerminalPanelDescriptor(
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||
enabled: Boolean(client && workspaceDirectory),
|
||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error(
|
||||
workspaceAuthority.ok
|
||||
? "Workspace execution directory not found"
|
||||
: workspaceAuthority.message,
|
||||
);
|
||||
}
|
||||
return client.listTerminals(workspaceDirectory);
|
||||
const terminalsQuery = useQuery(
|
||||
{
|
||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||
enabled: Boolean(client && workspaceDirectory),
|
||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error(
|
||||
workspaceAuthority.ok
|
||||
? "Workspace execution directory not found"
|
||||
: workspaceAuthority.message,
|
||||
);
|
||||
}
|
||||
return client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: 5_000,
|
||||
},
|
||||
staleTime: 5_000,
|
||||
});
|
||||
queryClient,
|
||||
);
|
||||
const terminal =
|
||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||
|
||||
|
||||
@@ -806,6 +806,9 @@ function DraftAgentScreenContent({
|
||||
if (providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
return "Select a model";
|
||||
}
|
||||
if (gitBlockingError) {
|
||||
return gitBlockingError;
|
||||
}
|
||||
@@ -846,6 +849,9 @@ function DraftAgentScreenContent({
|
||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||
".";
|
||||
const provider = composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
const model = effectiveModelId || null;
|
||||
const thinkingOptionId = effectiveThinkingOptionId || null;
|
||||
const modeId =
|
||||
@@ -889,8 +895,12 @@ function DraftAgentScreenContent({
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: undefined;
|
||||
const provider = composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
const config: AgentSessionConfig = {
|
||||
provider: composerState.selectedProvider,
|
||||
provider,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
||||
|
||||
@@ -326,6 +326,9 @@ export function NewWorkspaceScreen({
|
||||
if (!composerState) {
|
||||
throw new Error("Composer state is required");
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
|
||||
const initialPrompt = text.trim();
|
||||
const encodedImages = await encodeImages(images);
|
||||
|
||||
@@ -281,6 +281,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
|
||||
}
|
||||
|
||||
function DesktopAppUpdateRow() {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const {
|
||||
isDesktopApp,
|
||||
statusText,
|
||||
@@ -309,6 +310,13 @@ function DesktopAppUpdateRow() {
|
||||
void checkForUpdates();
|
||||
}, [checkForUpdates, isDesktopApp]);
|
||||
|
||||
const handleReleaseChannelChange = useCallback(
|
||||
(releaseChannel: AppSettings["releaseChannel"]) => {
|
||||
void updateSettings({ releaseChannel });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleInstallUpdate = useCallback(() => {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
@@ -316,7 +324,7 @@ function DesktopAppUpdateRow() {
|
||||
|
||||
void confirmDialog({
|
||||
title: "Install desktop update",
|
||||
message: "This updates Paseo on this computer.",
|
||||
message: "This updates Paseo on this computer",
|
||||
confirmLabel: "Install update",
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
@@ -337,40 +345,59 @@ function DesktopAppUpdateRow() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Release channel</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
||||
Switch to Beta to get updates sooner and help shape them
|
||||
</Text>
|
||||
) : null}
|
||||
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.aboutUpdateActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
</View>
|
||||
<SegmentedControl
|
||||
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>
|
||||
value={settings.releaseChannel}
|
||||
onValueChange={handleReleaseChannelChange}
|
||||
options={[
|
||||
{ value: "stable", label: "Stable" },
|
||||
{ value: "beta", label: "Beta" },
|
||||
]}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
<View style={desktopStyles.contentPane}>
|
||||
{detailHeader ? (
|
||||
<ScreenHeader
|
||||
left={
|
||||
<ScreenHeader
|
||||
borderless={!detailHeader}
|
||||
left={
|
||||
detailHeader ? (
|
||||
<>
|
||||
<HeaderIconBadge>
|
||||
<detailHeader.Icon
|
||||
@@ -873,10 +901,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
</ScreenTitle>
|
||||
{detailHeader.titleAccessory}
|
||||
</>
|
||||
}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
/>
|
||||
) : null}
|
||||
) : null
|
||||
}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
/>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
||||
return (
|
||||
<View testID={`settings-host-page-${serverId}`}>
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Host not found.</Text>
|
||||
<Text style={styles.emptyText}>Host not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -591,7 +591,7 @@ function PairDeviceRow() {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Pair a device</Text>
|
||||
<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>
|
||||
</View>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -619,7 +619,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Remove host</Text>
|
||||
<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>
|
||||
</View>
|
||||
<Button
|
||||
|
||||
@@ -179,7 +179,7 @@ export function KeyboardShortcutsSection() {
|
||||
return (
|
||||
<SettingsSection title="Shortcuts">
|
||||
<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>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
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 { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
|
||||
export interface ProvidersSectionProps {
|
||||
serverId: string;
|
||||
@@ -33,13 +34,13 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
hitSlop={8}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh providers"
|
||||
accessibilityLabel={providerRefreshInFlight ? "Refreshing providers" : "Refresh providers"}
|
||||
>
|
||||
<SpinningRefreshIcon
|
||||
spinning={providerRefreshInFlight}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
{providerRefreshInFlight ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : undefined;
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ export function WorkspaceDraftAgentTab({
|
||||
if (composerState.providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
return "Select a model";
|
||||
}
|
||||
if (composerState.isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
@@ -127,10 +130,14 @@ export function WorkspaceDraftAgentTab({
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: null;
|
||||
const provider = composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
return {
|
||||
serverId,
|
||||
id: tabId,
|
||||
provider: composerState.selectedProvider,
|
||||
provider,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -141,7 +148,7 @@ export function WorkspaceDraftAgentTab({
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
||||
runtimeInfo: { provider, sessionId: null, model, modeId },
|
||||
title: "Agent",
|
||||
cwd: workspaceDirectory,
|
||||
model,
|
||||
@@ -157,8 +164,12 @@ export function WorkspaceDraftAgentTab({
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
|
||||
const provider = composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider: composerState.selectedProvider,
|
||||
provider,
|
||||
cwd: workspaceDirectory,
|
||||
...(composerState.modeOptions.length > 0 && 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 { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
interface SidebarCollapsedSectionsState {
|
||||
collapsedProjectKeys: Set<string>;
|
||||
@@ -6,26 +8,84 @@ interface SidebarCollapsedSectionsState {
|
||||
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>((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 };
|
||||
type PersistedSidebarCollapsedSectionsState = {
|
||||
collapsedProjectKeys?: string[];
|
||||
};
|
||||
|
||||
function serializeCollapsedProjectKeys(keys: Set<string>): string[] {
|
||||
return Array.from(keys);
|
||||
}
|
||||
|
||||
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
|
||||
if (!Array.isArray(value)) {
|
||||
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) => {
|
||||
const next = new Set(state.collapsedProjectKeys);
|
||||
if (collapsed) {
|
||||
next.add(projectKey);
|
||||
} else {
|
||||
next.delete(projectKey);
|
||||
}
|
||||
return { collapsedProjectKeys: next };
|
||||
}),
|
||||
}));
|
||||
{
|
||||
name: "sidebar-collapsed-sections",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys),
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined;
|
||||
if (!persisted?.collapsedProjectKeys) {
|
||||
return currentState;
|
||||
}
|
||||
const collapsedProjectKeys = deserializeCollapsedProjectKeys(
|
||||
persisted.collapsedProjectKeys,
|
||||
);
|
||||
if (areSetsEqual(currentState.collapsedProjectKeys, collapsedProjectKeys)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
collapsedProjectKeys,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,58 +19,66 @@ type RawWindowControlsPadding = {
|
||||
|
||||
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 {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative || !getIsElectronRuntime()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
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();
|
||||
|
||||
startFullscreenSubscription();
|
||||
// Sync to any value that resolved between render and effect.
|
||||
setIsFullscreen(cachedIsFullscreen);
|
||||
fullscreenSubscribers.add(setIsFullscreen);
|
||||
return () => {
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
fullscreenSubscribers.delete(setIsFullscreen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.60-rc.3",
|
||||
"@getpaseo/server": "0.1.60-rc.3",
|
||||
"@getpaseo/relay": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -13,8 +13,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.60-rc.3",
|
||||
"@getpaseo/server": "0.1.60-rc.3",
|
||||
"@getpaseo/cli": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -8,8 +8,9 @@ $RootDir = (Resolve-Path "$DesktopDir\..\..").Path
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
|
||||
# Get a random available port for Metro
|
||||
$env:EXPO_PORT = (npx get-port-cli).Trim()
|
||||
# Prefer Metro's stable default port so dev browser storage keeps the same
|
||||
# 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
|
||||
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
||||
|
||||
@@ -9,8 +9,9 @@ ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
|
||||
# Get a random available port for Metro
|
||||
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
|
||||
# Prefer Metro's stable default port so dev browser storage keeps the same
|
||||
# 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
|
||||
|
||||
# Allow any origin in dev so Electron on random localhost ports can reach
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
readManagedFileBase64,
|
||||
writeAttachmentBase64,
|
||||
} from "../features/attachments.js";
|
||||
import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js";
|
||||
import {
|
||||
checkForAppUpdate,
|
||||
downloadAndInstallUpdate,
|
||||
type AppReleaseChannel,
|
||||
} from "../features/auto-updater.js";
|
||||
import {
|
||||
installCli,
|
||||
getCliInstallStatus,
|
||||
@@ -65,6 +69,10 @@ type DesktopPairingOffer = {
|
||||
|
||||
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
|
||||
function parseReleaseChannel(args: Record<string, unknown> | undefined): AppReleaseChannel {
|
||||
return args?.releaseChannel === "beta" ? "beta" : "stable";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -483,15 +491,18 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
: "";
|
||||
if (sessionId) closeLocalTransportSession(sessionId);
|
||||
},
|
||||
check_app_update: async () => {
|
||||
check_app_update: async (args) => {
|
||||
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();
|
||||
return downloadAndInstallUpdate(currentVersion, async () => {
|
||||
await stopDaemon();
|
||||
});
|
||||
return downloadAndInstallUpdate(
|
||||
{ currentVersion, releaseChannel: parseReleaseChannel(args) },
|
||||
async () => {
|
||||
await stopDaemon();
|
||||
},
|
||||
);
|
||||
},
|
||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||
install_cli: () => installCli(),
|
||||
|
||||
@@ -20,6 +20,8 @@ export type AppUpdateInstallResult = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AppReleaseChannel = "stable" | "beta";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -28,18 +30,29 @@ let cachedUpdateInfo: UpdateInfo | null = null;
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
let downloading = false;
|
||||
let autoUpdaterConfigured = false;
|
||||
let configuredReleaseChannel: AppReleaseChannel | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function configureAutoUpdater(): void {
|
||||
function configureAutoUpdater(releaseChannel: AppReleaseChannel): void {
|
||||
// Download updates in the background and only prompt once they are ready to install.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Suppress built-in dialogs; the renderer handles UI.
|
||||
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) {
|
||||
return;
|
||||
@@ -109,7 +122,13 @@ function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
||||
// 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) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
@@ -118,7 +137,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
|
||||
});
|
||||
}
|
||||
|
||||
configureAutoUpdater();
|
||||
configureAutoUpdater(releaseChannel);
|
||||
|
||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
||||
@@ -176,7 +195,13 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
|
||||
}
|
||||
|
||||
export async function downloadAndInstallUpdate(
|
||||
currentVersion: string,
|
||||
{
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
}: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
},
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<AppUpdateInstallResult> {
|
||||
if (!app.isPackaged) {
|
||||
@@ -195,7 +220,7 @@ export async function downloadAndInstallUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
configureAutoUpdater();
|
||||
configureAutoUpdater(releaseChannel);
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -60,8 +60,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.60-rc.3",
|
||||
"@getpaseo/relay": "0.1.60-rc.3",
|
||||
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||
"@getpaseo/relay": "0.1.60-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@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 { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
||||
import { withTimeout } from "../src/utils/promise-timeout.js";
|
||||
|
||||
type RoundTripResult = {
|
||||
voiceAgentId: string;
|
||||
@@ -10,10 +11,6 @@ type RoundTripResult = {
|
||||
audioChunks: number;
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
@@ -103,13 +100,7 @@ async function runVoiceRoundTrip(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
done,
|
||||
(async () => {
|
||||
await sleep(params.timeoutMs);
|
||||
throw new Error("Timed out waiting for audio_output");
|
||||
})(),
|
||||
]);
|
||||
await withTimeout(done, params.timeoutMs, "Timed out waiting for audio_output");
|
||||
} finally {
|
||||
offStream();
|
||||
await client.setVoiceMode(false).catch(() => undefined);
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import { buildVoiceAgentMcpServerConfig } from "../src/server/session.js";
|
||||
import { createAgentMcpServer } from "../src/server/agent/mcp-server.js";
|
||||
import { createVoiceMcpSocketBridgeManager } from "../src/server/voice-mcp-bridge.js";
|
||||
import { withTimeout } from "../src/utils/promise-timeout.js";
|
||||
|
||||
type CliOptions = {
|
||||
provider: AgentProvider;
|
||||
@@ -54,17 +55,13 @@ async function streamWithTimeout(
|
||||
timeoutMs: number,
|
||||
onEvent: (event: AgentStreamEvent) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
const consume = (async () => {
|
||||
for await (const event of iterator) {
|
||||
await onEvent(event);
|
||||
}
|
||||
})();
|
||||
|
||||
await Promise.race([consume, timeout]);
|
||||
await withTimeout(consume, timeoutMs, `Timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import pino from "pino";
|
||||
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../src/server/test-utils/daemon-client.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> {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -151,18 +152,8 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
firstAudio,
|
||||
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),
|
||||
),
|
||||
]);
|
||||
await withTimeout(firstAudio, 120000, "Timed out waiting for first audio_output");
|
||||
await withTimeout(lastAudio, 120000, "Timed out waiting for final audio_output");
|
||||
|
||||
console.log("success", { audioChunkCount });
|
||||
await client.setVoiceMode(false);
|
||||
|
||||
@@ -2745,7 +2745,7 @@ export class AgentManager {
|
||||
const client = this.clients.get(normalized.provider);
|
||||
if (client) {
|
||||
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];
|
||||
if (defaultModel) {
|
||||
normalized.model = defaultModel.id;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import pino from "pino";
|
||||
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
|
||||
@@ -23,26 +24,6 @@ type McpClient = {
|
||||
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: {
|
||||
targetPath: string;
|
||||
timeoutMs: number;
|
||||
@@ -151,7 +132,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
args: {
|
||||
cwd: agentCwd,
|
||||
title: "MCP e2e smoke",
|
||||
provider: "claude",
|
||||
provider: "claude/claude-test-model",
|
||||
mode: "bypassPermissions",
|
||||
initialPrompt,
|
||||
background: false,
|
||||
@@ -242,7 +223,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
args: {
|
||||
cwd: agentCwd,
|
||||
title: "Injected MCP",
|
||||
provider: "claude",
|
||||
provider: "claude/claude-test-model",
|
||||
mode: "bypassPermissions",
|
||||
initialPrompt: "reply with done and stop",
|
||||
background: true,
|
||||
@@ -265,7 +246,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
args: {
|
||||
cwd: disabledAgentCwd,
|
||||
title: "No injected MCP",
|
||||
provider: "claude",
|
||||
provider: "claude/claude-test-model",
|
||||
mode: "bypassPermissions",
|
||||
initialPrompt: "reply with done and stop",
|
||||
background: true,
|
||||
@@ -362,7 +343,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
args: {
|
||||
cwd: repoRoot,
|
||||
title: "MCP worktree setup terminals",
|
||||
provider: "claude",
|
||||
provider: "claude/claude-test-model",
|
||||
mode: "bypassPermissions",
|
||||
initialPrompt: "say done and stop",
|
||||
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 {
|
||||
AgentCapabilityFlags,
|
||||
@@ -31,6 +31,17 @@ function normalizeThinkingOptionId(value: string | null | undefined): string | n
|
||||
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: {
|
||||
runtimeInfo?: AgentRuntimeInfo | null;
|
||||
configuredThinkingOptionId?: string | null;
|
||||
@@ -198,7 +209,29 @@ export function buildStoredAgentPayload(
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? 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 {
|
||||
cwd?: string;
|
||||
cwd: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface ListModesOptions {
|
||||
cwd?: string;
|
||||
cwd: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface AgentClient {
|
||||
@@ -509,8 +511,8 @@ export interface AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listModes?(options?: ListModesOptions): Promise<AgentMode[]>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
/**
|
||||
* 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", {
|
||||
cwd,
|
||||
title: "Parity agent",
|
||||
provider: "claude",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "say done and stop",
|
||||
mode: "bypassPermissions",
|
||||
background: true,
|
||||
@@ -133,7 +133,7 @@ describe("MCP parity end-to-end", () => {
|
||||
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
|
||||
const payload = await callToolStructured(agentScopedClient, "create_agent", {
|
||||
title: "Parity child",
|
||||
provider: "claude",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "say done and stop",
|
||||
background: true,
|
||||
...args,
|
||||
@@ -207,7 +207,7 @@ describe("MCP parity end-to-end", () => {
|
||||
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
|
||||
cwd: parentAgentCwd,
|
||||
title: "MCP parity parent",
|
||||
provider: "claude",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "say done and stop",
|
||||
mode: "bypassPermissions",
|
||||
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;
|
||||
try {
|
||||
agentId = await createTopLevelAgent({ model: "claude-test-model" });
|
||||
agentId = await createTopLevelAgent({ provider: "claude/claude-test-model" });
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(snapshot?.config.model).toBe("claude-test-model");
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,16 @@ import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
|
||||
import {
|
||||
AgentPermissionRequestPayloadSchema,
|
||||
AgentListItemPayloadSchema,
|
||||
AgentPermissionResponseSchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
} 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 type { AgentStorage } from "./agent-storage.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 { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.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 { captureTerminalLines } from "../../terminal/terminal.js";
|
||||
import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||
import {
|
||||
AgentModelSchema,
|
||||
AgentProviderEnum,
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
resolveRequiredProviderModel,
|
||||
sanitizePermissionRequest,
|
||||
sendPromptToAgent,
|
||||
setupFinishNotification,
|
||||
@@ -125,6 +132,68 @@ function mapModeAcrossProviders(
|
||||
|
||||
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: {
|
||||
parentCwd: string;
|
||||
requestedCwd?: string;
|
||||
@@ -256,7 +325,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (callerAgent) {
|
||||
const hasProviderOverride = params?.provider !== undefined;
|
||||
const resolvedProviderModel = hasProviderOverride
|
||||
? resolveProviderAndModel({
|
||||
? resolveScheduleProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
})
|
||||
@@ -306,21 +375,34 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedProviderModel = resolveScheduleProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: (() => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
};
|
||||
})(),
|
||||
config: {
|
||||
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 = {
|
||||
cwd: z
|
||||
.string()
|
||||
@@ -332,10 +414,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.min(1, "Title is required")
|
||||
.max(60, "Title must be 60 characters or fewer")
|
||||
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'.",
|
||||
provider: ProviderModelInputSchema.describe(
|
||||
"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"),
|
||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||
initialPrompt: z
|
||||
@@ -369,10 +450,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.min(1, "Title is required")
|
||||
.max(60, "Title must be 60 characters or fewer")
|
||||
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'.",
|
||||
provider: ProviderModelInputSchema.describe(
|
||||
"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"),
|
||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||
initialPrompt: z
|
||||
@@ -420,8 +500,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
};
|
||||
|
||||
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
|
||||
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema);
|
||||
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema);
|
||||
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
|
||||
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
|
||||
|
||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||
server.registerTool(
|
||||
@@ -471,7 +551,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
{
|
||||
title: "Create agent",
|
||||
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,
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
@@ -507,11 +587,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
|
||||
if (callerAgentId) {
|
||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||
provider = callerArgs.provider ?? "claude";
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider);
|
||||
provider = resolvedProviderModel.provider;
|
||||
model = resolvedProviderModel.model;
|
||||
initialPrompt = callerArgs.initialPrompt;
|
||||
background = callerArgs.background ?? false;
|
||||
normalizedTitle = callerArgs.title.trim();
|
||||
model = callerArgs.model;
|
||||
thinking = callerArgs.thinking;
|
||||
labels = callerArgs.labels;
|
||||
notifyOnFinish = callerArgs.notifyOnFinish ?? false;
|
||||
@@ -532,11 +613,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
}
|
||||
} else {
|
||||
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||
provider = topLevelArgs.provider ?? "claude";
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||
provider = resolvedProviderModel.provider;
|
||||
model = resolvedProviderModel.model;
|
||||
initialPrompt = topLevelArgs.initialPrompt;
|
||||
background = topLevelArgs.background ?? false;
|
||||
normalizedTitle = topLevelArgs.title.trim();
|
||||
model = topLevelArgs.model;
|
||||
thinking = topLevelArgs.thinking;
|
||||
labels = topLevelArgs.labels;
|
||||
notifyOnFinish = topLevelArgs.notifyOnFinish ?? false;
|
||||
@@ -928,15 +1010,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"list_agents",
|
||||
{
|
||||
title: "List agents",
|
||||
description: "List all live agents managed by the server.",
|
||||
description: "List recent agents as compact metadata.",
|
||||
inputSchema: {
|
||||
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: {
|
||||
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 liveAgents = await Promise.all(
|
||||
liveSnapshots.map((snapshot) =>
|
||||
@@ -949,10 +1045,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.filter((record) => !record.internal && !liveIds.has(record.id))
|
||||
.filter((record) => includeArchived || !record.archivedAt)
|
||||
.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 {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }),
|
||||
structuredContent: ensureValidJson({ agents }),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -1296,15 +1399,33 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
const scheduleTarget =
|
||||
target === "self"
|
||||
? (() => {
|
||||
if (!callerAgentId) {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (!callerAgentId || !callerAgent) {
|
||||
throw new Error("target=self requires a caller agent");
|
||||
}
|
||||
if (provider !== undefined || cwd !== undefined) {
|
||||
throw new Error("provider and cwd can only be used with target=new-agent");
|
||||
const trimmedCwd = cwd?.trim();
|
||||
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 };
|
||||
})()
|
||||
: resolveNewAgentScheduleTarget({ provider, cwd });
|
||||
: (() => {
|
||||
return resolveNewAgentScheduleTarget({ provider, cwd });
|
||||
})();
|
||||
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
@@ -1498,7 +1619,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
throw new Error(`Provider ${provider} is not configured`);
|
||||
}
|
||||
|
||||
const models = await definition.fetchModels();
|
||||
const models = await definition.fetchModels({ cwd: resolveSnapshotCwd(), force: false });
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
|
||||
@@ -42,7 +42,7 @@ export const AgentSelectOptionSchema = z
|
||||
label: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
isDefault: z.boolean().nullish(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -53,7 +53,7 @@ export const AgentModelSchema = z
|
||||
label: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
isDefault: z.boolean().nullish(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullish(),
|
||||
thinkingOptions: z.array(AgentSelectOptionSchema).nullish(),
|
||||
defaultThinkingOptionId: z.string().nullish(),
|
||||
})
|
||||
@@ -67,41 +67,24 @@ export interface ResolvedProviderModel {
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
defaultProvider: AgentProvider;
|
||||
}): 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");
|
||||
}
|
||||
|
||||
export function resolveRequiredProviderModel(
|
||||
providerValue: string,
|
||||
): Required<ResolvedProviderModel> {
|
||||
const providerInput = providerValue.trim();
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput as AgentProvider,
|
||||
model: modelInput,
|
||||
};
|
||||
if (slashIndex <= 0 || slashIndex === providerInput.length - 1) {
|
||||
throw new Error("provider must be provider/model, for example codex/gpt-5.4");
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
throw new Error("provider must be <provider> or <provider>/<model>");
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
throw new Error(
|
||||
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
|
||||
);
|
||||
const model = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !model) {
|
||||
throw new Error("provider must be provider/model, for example codex/gpt-5.4");
|
||||
}
|
||||
|
||||
return {
|
||||
provider: provider as AgentProvider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import { expandTilde } from "../../utils/path.js";
|
||||
import type { Logger } from "pino";
|
||||
@@ -21,7 +22,8 @@ export async function resolveAgentModel(
|
||||
try {
|
||||
const providerRegistry = buildProviderRegistry(options.logger);
|
||||
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];
|
||||
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"]);
|
||||
});
|
||||
@@ -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([
|
||||
{
|
||||
@@ -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([
|
||||
{
|
||||
@@ -668,7 +677,10 @@ describe("buildProviderRegistry", () => {
|
||||
]);
|
||||
|
||||
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([
|
||||
{
|
||||
|
||||
@@ -42,8 +42,8 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
|
||||
|
||||
export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
createClient: (logger: Logger) => AgentClient;
|
||||
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
|
||||
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||
}
|
||||
|
||||
export type BuildProviderRegistryOptions = {
|
||||
@@ -329,9 +329,9 @@ function createRegistryEntry(
|
||||
const inner = resolved.createBaseClient(providerLogger);
|
||||
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
|
||||
},
|
||||
fetchModels: async (options?: ListModelsOptions) =>
|
||||
fetchModels: async (options: ListModelsOptions) =>
|
||||
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
|
||||
fetchModes: async (options?: ListModesOptions) => {
|
||||
fetchModes: async (options: ListModesOptions) => {
|
||||
const modes = modelClient.listModes
|
||||
? await modelClient.listModes(options)
|
||||
: resolved.definition.modes;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
@@ -7,6 +8,8 @@ import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
@@ -24,8 +27,8 @@ type MockProviderOptions = {
|
||||
description?: string;
|
||||
defaultModeId?: string | null;
|
||||
isAvailable?: () => Promise<boolean>;
|
||||
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd?: string) => Promise<AgentMode[]>;
|
||||
fetchModels?: (cwd: string, force: boolean) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd: string, force: boolean) => Promise<AgentMode[]>;
|
||||
};
|
||||
|
||||
type MockProviderHandle = {
|
||||
@@ -441,21 +444,22 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModes = deferred<AgentMode[]>();
|
||||
test("explicit refresh bypasses an in-flight background warm-up", async () => {
|
||||
const initialFetchModels = deferred<AgentModelDefinition[]>();
|
||||
const explicitFetchModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => fetchModels.promise,
|
||||
fetchModes: async () => fetchModes.promise,
|
||||
fetchModels: vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockImplementationOnce(async () => initialFetchModels.promise)
|
||||
.mockImplementationOnce(async () => explicitFetchModels.promise),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
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([
|
||||
{
|
||||
@@ -467,15 +471,18 @@ describe("ProviderSnapshotManager", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
const refreshPromise = manager.refresh({ cwd: projectCwd, providers: ["codex"] });
|
||||
|
||||
fetchModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
fetchModes.resolve([createMode("auto")]);
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
explicitFetchModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
await refreshPromise;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
||||
@@ -486,12 +493,170 @@ describe("ProviderSnapshotManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
|
||||
initialFetchModels.resolve([createModel("codex", "stale-background-model")]);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
let now = 1_000;
|
||||
const fetchModels = vi
|
||||
@@ -799,6 +964,91 @@ describe("ProviderSnapshotManager", () => {
|
||||
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 () => {
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({ provider: "claude" }),
|
||||
@@ -911,14 +1161,16 @@ function createRegistry(handles: MockProviderHandle[]): {
|
||||
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
|
||||
const fetchModels = vi.fn(
|
||||
async (listOptions?: { cwd?: string }) =>
|
||||
options.fetchModels?.(listOptions?.cwd) ?? [
|
||||
async (listOptions: ListModelsOptions) =>
|
||||
options.fetchModels?.(listOptions.cwd, listOptions.force) ?? [
|
||||
createModel(options.provider, `${options.provider}-default`),
|
||||
],
|
||||
);
|
||||
const fetchModes = vi.fn(
|
||||
async (listOptions?: { cwd?: string }) =>
|
||||
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
|
||||
async (listOptions: ListModesOptions) =>
|
||||
options.fetchModes?.(listOptions.cwd, listOptions.force) ?? [
|
||||
createMode(`${options.provider}-mode`),
|
||||
],
|
||||
);
|
||||
|
||||
const definition: ProviderDefinition = {
|
||||
@@ -937,7 +1189,7 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||
async resumeSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async listModels() {
|
||||
async listModels(_options: ListModelsOptions) {
|
||||
return [];
|
||||
},
|
||||
async isAvailable() {
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
const DEFAULT_CWD_KEY = "__default__";
|
||||
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 = {
|
||||
ttlMs?: number;
|
||||
refreshTimeoutMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
type ProviderSnapshotRefreshOptions = {
|
||||
cwd?: string;
|
||||
cwd: string;
|
||||
providers?: AgentProvider[];
|
||||
};
|
||||
type ProviderLoadOptions = {
|
||||
cwd: string;
|
||||
providers: AgentProvider[];
|
||||
force: boolean;
|
||||
};
|
||||
type ProviderLoad = {
|
||||
promise: Promise<void>;
|
||||
};
|
||||
|
||||
export class ProviderSnapshotManager {
|
||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||
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 destroyed = false;
|
||||
private readonly ttlMs: number;
|
||||
private readonly refreshTimeoutMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
@@ -34,37 +46,88 @@ export class ProviderSnapshotManager {
|
||||
options: ProviderSnapshotManagerOptions = {},
|
||||
) {
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
||||
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
const entries = this.snapshots.get(cwdKey);
|
||||
const resolvedCwd = resolveSnapshotCwd(cwd);
|
||||
const entries = this.snapshots.get(resolvedCwd);
|
||||
if (!entries) {
|
||||
const loadingEntries = this.resetSnapshotToLoading(cwdKey);
|
||||
void this.warmUp(cwd);
|
||||
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
|
||||
void this.warmUp(resolvedCwd);
|
||||
return entriesToArray(loadingEntries);
|
||||
}
|
||||
if (this.shouldRevalidate(cwdKey)) {
|
||||
void this.warmUp(cwd);
|
||||
const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
|
||||
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);
|
||||
}
|
||||
|
||||
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
|
||||
const { cwd } = options;
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
const inFlight = this.warmUps.get(cwdKey);
|
||||
if (inFlight) {
|
||||
await inFlight;
|
||||
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||
const cwd = resolveSnapshotCwd(options.cwd);
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
this.resetSnapshotToLoading(cwd, providers);
|
||||
this.emitChange(cwd);
|
||||
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;
|
||||
}
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
this.resetSnapshotToLoading(cwdKey, providers);
|
||||
this.emitChange(cwdKey);
|
||||
|
||||
const snapshot = this.snapshots.get(cwd);
|
||||
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);
|
||||
}
|
||||
|
||||
async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||
await this.refreshSnapshotForCwd(options);
|
||||
}
|
||||
|
||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||
this.events.on(event, listener);
|
||||
return this;
|
||||
@@ -80,7 +143,7 @@ export class ProviderSnapshotManager {
|
||||
this.events.removeAllListeners();
|
||||
this.snapshots.clear();
|
||||
this.lastCheckedAts.clear();
|
||||
this.warmUps.clear();
|
||||
this.providerLoads.clear();
|
||||
}
|
||||
|
||||
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
@@ -98,49 +161,87 @@ export class ProviderSnapshotManager {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
const inFlight = this.warmUps.get(cwdKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
private async warmUp(cwd: string, providers?: AgentProvider[]): Promise<void> {
|
||||
const providersToRefresh = providers ?? this.getProviderIds();
|
||||
|
||||
const warmUpPromise = Promise.allSettled(
|
||||
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
||||
).then(() => {
|
||||
if (!providers) {
|
||||
this.lastCheckedAts.set(cwdKey, this.now());
|
||||
}
|
||||
await this.loadProviders({
|
||||
cwd,
|
||||
providers: providersToRefresh,
|
||||
force: false,
|
||||
});
|
||||
|
||||
this.warmUps.set(cwdKey, warmUpPromise);
|
||||
|
||||
try {
|
||||
await warmUpPromise;
|
||||
} finally {
|
||||
if (this.warmUps.get(cwdKey) === warmUpPromise) {
|
||||
this.warmUps.delete(cwdKey);
|
||||
}
|
||||
if (!providers) {
|
||||
this.lastCheckedAts.set(cwd, this.now());
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshProvider(
|
||||
cwdKey: string,
|
||||
provider: AgentProvider,
|
||||
cwd?: string,
|
||||
): Promise<void> {
|
||||
const definition = this.providerRegistry[provider];
|
||||
private async refreshProviders(cwd: string, providers: AgentProvider[]): Promise<void> {
|
||||
await this.loadProviders({ cwd, providers, force: true });
|
||||
}
|
||||
|
||||
private async loadProviders(options: ProviderLoadOptions): Promise<void> {
|
||||
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) {
|
||||
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 {
|
||||
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 (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||
return;
|
||||
}
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
status: "unavailable",
|
||||
@@ -148,15 +249,22 @@ export class ProviderSnapshotManager {
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.emitChange(cwdKey);
|
||||
this.emitChange(cwd);
|
||||
return;
|
||||
}
|
||||
|
||||
const [models, modes] = await Promise.all([
|
||||
definition.fetchModels({ cwd }),
|
||||
definition.fetchModes({ cwd }),
|
||||
]);
|
||||
const [models, modes] = await withTimeout(
|
||||
Promise.all([
|
||||
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, {
|
||||
provider,
|
||||
status: "ready",
|
||||
@@ -167,8 +275,11 @@ export class ProviderSnapshotManager {
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.emitChange(cwdKey);
|
||||
this.emitChange(cwd);
|
||||
} catch (error) {
|
||||
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||
return;
|
||||
}
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
status: "error",
|
||||
@@ -177,14 +288,32 @@ export class ProviderSnapshotManager {
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.logger.warn(
|
||||
{ err: error, provider, cwd: cwdKey },
|
||||
"Failed to refresh provider snapshot",
|
||||
);
|
||||
this.emitChange(cwdKey);
|
||||
this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
|
||||
this.emitChange(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
@@ -193,11 +322,11 @@ export class ProviderSnapshotManager {
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
||||
this.events.emit("change", entriesToArray(snapshot), cwdKey);
|
||||
}
|
||||
|
||||
private shouldRevalidate(cwdKey: string): boolean {
|
||||
if (this.warmUps.has(cwdKey)) {
|
||||
if (this.providerLoads.has(cwdKey)) {
|
||||
return false;
|
||||
}
|
||||
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
|
||||
@@ -259,23 +388,52 @@ export class ProviderSnapshotManager {
|
||||
const providerIds = new Set(this.getProviderIds());
|
||||
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 {
|
||||
if (!cwd) {
|
||||
return DEFAULT_CWD_KEY;
|
||||
}
|
||||
|
||||
const trimmed = cwd.trim();
|
||||
export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||
const trimmed = cwd?.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_CWD_KEY;
|
||||
return homedir();
|
||||
}
|
||||
|
||||
return resolve(trimmed);
|
||||
}
|
||||
|
||||
function denormalizeCwdKey(cwdKey: string): string | undefined {
|
||||
return cwdKey === DEFAULT_CWD_KEY ? undefined : cwdKey;
|
||||
const expanded =
|
||||
trimmed === "~" || trimmed.startsWith("~/") ? `${homedir()}${trimmed.slice(1)}` : trimmed;
|
||||
return resolve(expanded);
|
||||
}
|
||||
|
||||
function entriesToArray(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
ACPAgentSession,
|
||||
type SpawnedACPProcess,
|
||||
type SessionStateResponse,
|
||||
deriveModelDefinitionsFromACP,
|
||||
deriveModesFromACP,
|
||||
@@ -218,7 +219,7 @@ describe("deriveModelDefinitionsFromACP", () => {
|
||||
describe("ACPAgentClient modelTransformer", () => {
|
||||
test("applies modelTransformer after deriving ACP models", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<any> {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
@@ -237,7 +238,7 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
};
|
||||
} as unknown as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
@@ -250,7 +251,7 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
modelTransformer: transformPiModels,
|
||||
});
|
||||
|
||||
await expect(client.listModels()).resolves.toEqual([
|
||||
await expect(client.listModels({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
@@ -266,7 +267,7 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
|
||||
describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<any> {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
const response: SessionStateResponse = {
|
||||
sessionId: "session-1",
|
||||
modes: {
|
||||
@@ -283,7 +284,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
newSession: vi.fn().mockResolvedValue(response),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
};
|
||||
} as unknown as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
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",
|
||||
label: "Review",
|
||||
@@ -315,9 +316,44 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<any> {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
@@ -340,7 +376,7 @@ describe("ACPAgentClient listModes", () => {
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
};
|
||||
} as unknown as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
@@ -353,7 +389,7 @@ describe("ACPAgentClient listModes", () => {
|
||||
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;
|
||||
};
|
||||
|
||||
type SpawnedACPProcess = {
|
||||
export type SpawnedACPProcess = {
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
connection: ClientSideConnection;
|
||||
initialize: InitializeResponse;
|
||||
@@ -394,8 +394,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const { cwd } = options;
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
const response = await probe.connection.newSession({
|
||||
@@ -414,8 +414,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listModes(options?: ListModesOptions): Promise<AgentMode[]> {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||
const { cwd } = options;
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
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 { isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
|
||||
@@ -40,21 +41,7 @@ async function nextStreamEvent(
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<IteratorResult<AgentStreamEvent>> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
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);
|
||||
}
|
||||
}
|
||||
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
|
||||
test("returns hardcoded claude models", async () => {
|
||||
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([
|
||||
"claude-opus-4-7[1m]",
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
} from "../agent-sdk-types.js";
|
||||
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand, spawnProcess } from "../../../utils/spawn.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();
|
||||
}
|
||||
|
||||
@@ -1157,7 +1159,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
const models = await this.listModels({ cwd: os.homedir(), force: false });
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
@@ -2097,12 +2099,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const startedAt = Date.now();
|
||||
this.logger.trace({ label }, "Claude query operation wait start");
|
||||
try {
|
||||
await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("timeout")), 3_000);
|
||||
}),
|
||||
]);
|
||||
await withTimeout(promise, 3_000, "timeout");
|
||||
this.logger.trace(
|
||||
{ label, durationMs: Date.now() - startedAt },
|
||||
"Claude query operation settled",
|
||||
|
||||
@@ -222,21 +222,26 @@ describe("Codex app-server provider (e2e)", () => {
|
||||
"lists models and runs a simple prompt",
|
||||
async () => {
|
||||
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);
|
||||
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
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.");
|
||||
expect(result.finalText.length).toBeGreaterThan(0);
|
||||
await session.close();
|
||||
const result = await session.run("Say hello in one sentence.");
|
||||
expect(result.finalText.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
process.on("uncaughtException", onUncaught);
|
||||
|
||||
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
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(uncaughtErrors).toHaveLength(0);
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
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 client = new CodexAppServerClient(child, this.logger);
|
||||
|
||||
@@ -4247,7 +4249,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
entries.push({ label: "Models", value: "Not checked" });
|
||||
} else {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
entries.push({ label: "Models", value: String(models.length) });
|
||||
} catch (error) {
|
||||
entries.push({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
@@ -69,7 +70,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
@@ -81,7 +82,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
|
||||
if (!modelsValue.startsWith("Error -")) {
|
||||
try {
|
||||
await this.listModes();
|
||||
await this.listModes({ cwd: homedir(), force: false });
|
||||
} catch (error) {
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "mode fetch",
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
test("default model is a five minute foreground stream", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
|
||||
const models = await client.listModels();
|
||||
const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
|
||||
|
||||
expect(models[0]).toMatchObject({
|
||||
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
@@ -197,11 +198,11 @@ export class MockLoadTestAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return MODELS;
|
||||
}
|
||||
|
||||
async listModes(): Promise<AgentMode[]> {
|
||||
async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
|
||||
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ describe("OpenCodeAgentClient.listModels timeout", () => {
|
||||
} as never);
|
||||
|
||||
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);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const modelsPromise = client.listModels();
|
||||
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
|
||||
@@ -103,7 +103,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
logger.info("beforeAll: Starting model selection");
|
||||
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
const models = await client.listModels({ cwd: os.homedir(), force: false });
|
||||
|
||||
logger.info(
|
||||
{ modelCount: models.length, elapsed: Date.now() - startTime },
|
||||
@@ -182,7 +182,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
|
||||
test("listModels returns models with required fields", async () => {
|
||||
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
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import {
|
||||
@@ -700,9 +702,10 @@ export const __openCodeInternals = {
|
||||
export class OpenCodeServerManager {
|
||||
private static instance: OpenCodeServerManager | null = null;
|
||||
private static exitHandlerRegistered = false;
|
||||
private server: ChildProcess | null = null;
|
||||
private port: number | null = null;
|
||||
private startPromise: Promise<{ port: number; url: string }> | null = null;
|
||||
private currentServer: OpenCodeServerGeneration | null = null;
|
||||
private retiredServers = new Set<OpenCodeServerGeneration>();
|
||||
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly runtimeSettingsKey: string;
|
||||
@@ -741,9 +744,7 @@ export class OpenCodeServerManager {
|
||||
|
||||
const cleanup = () => {
|
||||
const instance = OpenCodeServerManager.instance;
|
||||
if (instance?.server && !instance.server.killed) {
|
||||
instance.server.kill("SIGTERM");
|
||||
}
|
||||
void instance?.shutdown();
|
||||
};
|
||||
|
||||
process.on("exit", cleanup);
|
||||
@@ -752,35 +753,99 @@ export class OpenCodeServerManager {
|
||||
}
|
||||
|
||||
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) {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
if (this.server && this.port && !this.server.killed) {
|
||||
return { port: this.port, url: `http://127.0.0.1:${this.port}` };
|
||||
if (this.currentServer && !this.currentServer.process.killed) {
|
||||
return this.currentServer;
|
||||
}
|
||||
|
||||
this.startPromise = this.startServer();
|
||||
try {
|
||||
const result = await this.startPromise;
|
||||
if (!result.retired) {
|
||||
this.currentServer = result;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async startServer(): Promise<{ port: number; url: string }> {
|
||||
this.port = await findAvailablePort();
|
||||
const url = `http://127.0.0.1:${this.port}`;
|
||||
private async rotateCurrentServer(): Promise<void> {
|
||||
const existing = this.currentServer;
|
||||
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(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = spawnProcess(
|
||||
const serverProcess = spawnProcess(
|
||||
launchPrefix.command,
|
||||
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
||||
[...launchPrefix.args, "serve", "--port", String(port)],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||
@@ -794,54 +859,94 @@ export class OpenCodeServerManager {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
this.server.stdout?.on("data", (data: Buffer) => {
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
if (output.includes("listening on") && !started) {
|
||||
started = true;
|
||||
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.server.on("error", (error) => {
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.server.on("exit", (code) => {
|
||||
serverProcess.on("exit", (code) => {
|
||||
if (!started) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`OpenCode server exited with code ${code}`));
|
||||
}
|
||||
this.server = null;
|
||||
this.port = null;
|
||||
if (this.currentServer?.process === serverProcess) {
|
||||
this.currentServer = null;
|
||||
}
|
||||
for (const retired of Array.from(this.retiredServers)) {
|
||||
if (retired.process === serverProcess) {
|
||||
this.retiredServers.delete(retired);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.server && !this.server.killed) {
|
||||
this.server.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.server?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5000);
|
||||
this.server?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const servers = [
|
||||
...(this.currentServer ? [this.currentServer] : []),
|
||||
...Array.from(this.retiredServers),
|
||||
];
|
||||
for (const server of servers) {
|
||||
await this.killServer(server);
|
||||
}
|
||||
this.server = null;
|
||||
this.port = null;
|
||||
this.currentServer = 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 {
|
||||
readonly provider: "opencode" = "opencode";
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
@@ -862,40 +967,43 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
// Set a timeout for session creation to fail fast
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("OpenCode session.create timed out after 10s")), 10_000);
|
||||
});
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
client.session.create({ directory: openCodeConfig.cwd }),
|
||||
10_000,
|
||||
"OpenCode session.create timed out after 10s",
|
||||
);
|
||||
|
||||
const response = await Promise.race([
|
||||
client.session.create({ directory: openCodeConfig.cwd }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.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),
|
||||
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(
|
||||
@@ -914,119 +1022,125 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
...overrides,
|
||||
};
|
||||
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({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
try {
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
client,
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
);
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
client,
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.release,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const { url } = await this.serverManager.ensureRunning();
|
||||
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||
const { url } = acquisition.server;
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
directory: options?.cwd ?? process.cwd(),
|
||||
directory: options.cwd,
|
||||
});
|
||||
|
||||
// Background model discovery can be legitimately slow while OpenCode refreshes
|
||||
// provider state, so allow longer than turn execution paths.
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
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`,
|
||||
),
|
||||
),
|
||||
try {
|
||||
// Background model discovery can be legitimately slow while OpenCode refreshes
|
||||
// provider state, so allow longer than turn execution paths.
|
||||
const response = await withTimeout(
|
||||
client.provider.list({ directory: options.cwd }),
|
||||
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([
|
||||
client.provider.list({ directory: options?.cwd ?? process.cwd() }),
|
||||
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;
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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;
|
||||
}
|
||||
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[]> {
|
||||
const { url } = await this.serverManager.ensureRunning();
|
||||
const directory = options?.cwd ?? process.cwd();
|
||||
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||
const { url } = acquisition.server;
|
||||
const directory = options.cwd;
|
||||
const client = createOpencodeClient({ baseUrl: url, directory });
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("OpenCode app.agents timed out after 10s")), 10_000);
|
||||
});
|
||||
try {
|
||||
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) {
|
||||
return DEFAULT_MODES;
|
||||
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;
|
||||
} 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(
|
||||
@@ -1061,7 +1175,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
@@ -1073,7 +1187,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
if (!modelsValue.startsWith("Error -")) {
|
||||
try {
|
||||
await this.listModes();
|
||||
await this.listModes({ cwd: homedir(), force: false });
|
||||
} catch (error) {
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "mode fetch",
|
||||
@@ -1671,12 +1785,14 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||
private releaseServer: (() => void) | null;
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
releaseServer?: () => void,
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
@@ -1684,6 +1800,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.logger = logger;
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.releaseServer = releaseServer ?? null;
|
||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||
config.model,
|
||||
);
|
||||
@@ -2278,15 +2395,20 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.abortController?.abort();
|
||||
await reconcileOpenCodeSessionClose({
|
||||
client: this.client,
|
||||
sessionId: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
logger: this.logger,
|
||||
});
|
||||
this.subscribers.clear();
|
||||
this.activeForegroundTurnId = null;
|
||||
try {
|
||||
this.abortController?.abort();
|
||||
await reconcileOpenCodeSessionClose({
|
||||
client: this.client,
|
||||
sessionId: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
logger: this.logger,
|
||||
});
|
||||
this.subscribers.clear();
|
||||
this.activeForegroundTurnId = null;
|
||||
} finally {
|
||||
this.releaseServer?.();
|
||||
this.releaseServer = 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,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ToolCallDetail,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -1448,7 +1449,8 @@ export class PiDirectAgentClient implements AgentClient {
|
||||
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()
|
||||
.getAll()
|
||||
.map((model) => ({
|
||||
@@ -1467,7 +1469,7 @@ export class PiDirectAgentClient implements AgentClient {
|
||||
return transformPiModels(models);
|
||||
}
|
||||
|
||||
async listModes(): Promise<AgentMode[]> {
|
||||
async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1504,7 +1506,7 @@ export class PiDirectAgentClient implements AgentClient {
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.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 type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||
@@ -21,26 +22,6 @@ function tmpCwd(): string {
|
||||
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(
|
||||
messages: SessionOutboundMessage[],
|
||||
agentId: string,
|
||||
|
||||
@@ -451,15 +451,20 @@ describe(
|
||||
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
|
||||
async () => {
|
||||
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);
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("pi");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("pi");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
PI_TEST_TIMEOUT_MS,
|
||||
|
||||
@@ -20,3 +20,17 @@ export function resolvePathFromBase(baseCwd: string, requestedPath: string): str
|
||||
}
|
||||
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 + "/")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { CheckoutPrStatusSchema } from "../shared/messages.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(() => ({
|
||||
checkoutResolvedBranch: vi.fn(),
|
||||
@@ -36,6 +45,31 @@ const paseoWorktreeServiceMocks = vi.hoisted(() => ({
|
||||
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) => {
|
||||
const actual = await importOriginal<typeof import("../utils/checkout-git.js")>();
|
||||
return {
|
||||
@@ -113,6 +147,7 @@ function createSessionForTest(options?: {
|
||||
scriptRuntimeStore?: unknown;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
providerSnapshotManager?: ProviderSnapshotManager;
|
||||
messages?: unknown[];
|
||||
}): Session {
|
||||
const logger = pino({ level: "silent" });
|
||||
@@ -165,6 +200,7 @@ function createSessionForTest(options?: {
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: (options?.terminalManager ?? null) as any,
|
||||
providerSnapshotManager: options?.providerSnapshotManager,
|
||||
scriptRouteStore: options?.scriptRouteStore as any,
|
||||
scriptRuntimeStore: options?.scriptRuntimeStore as any,
|
||||
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(() => {
|
||||
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", () => {
|
||||
test("uses workspace git service snapshot for merge-to-base preflight", async () => {
|
||||
const messages: unknown[] = [];
|
||||
|
||||
@@ -86,7 +86,7 @@ import type {
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.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 {
|
||||
AgentTimelineCursor,
|
||||
AgentTimelineFetchDirection,
|
||||
@@ -234,7 +234,7 @@ const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
|
||||
|
||||
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
|
||||
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 parts = base.split(".").map(Number);
|
||||
const minParts = minVersion.split(".").map(Number);
|
||||
@@ -805,7 +805,7 @@ export class Session {
|
||||
);
|
||||
}
|
||||
if (this.providerSnapshotManager) {
|
||||
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => {
|
||||
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => {
|
||||
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
|
||||
const visibleEntries = entries.filter((entry) =>
|
||||
this.isProviderVisibleToClient(entry.provider),
|
||||
@@ -3071,13 +3071,16 @@ export class Session {
|
||||
private async handleListProviderModelsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
|
||||
): 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 manager = this.providerSnapshotManager;
|
||||
|
||||
if (!manager) {
|
||||
try {
|
||||
const models = await this.providerRegistry[msg.provider].fetchModels({ cwd });
|
||||
const models = await this.providerRegistry[msg.provider].fetchModels({
|
||||
cwd,
|
||||
force: false,
|
||||
});
|
||||
this.emit({
|
||||
type: "list_provider_models_response",
|
||||
payload: {
|
||||
@@ -3106,16 +3109,7 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
const findEntry = () =>
|
||||
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();
|
||||
}
|
||||
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
|
||||
|
||||
if (!entry) {
|
||||
this.emit({
|
||||
@@ -3164,9 +3158,60 @@ export class Session {
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
|
||||
): Promise<void> {
|
||||
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 {
|
||||
const modes = await this.providerRegistry[msg.provider].fetchModes({
|
||||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||||
cwd,
|
||||
force: false,
|
||||
});
|
||||
this.emit({
|
||||
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: {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
@@ -3301,10 +3368,16 @@ export class Session {
|
||||
private async handleRefreshProvidersSnapshotRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
||||
): Promise<void> {
|
||||
await this.providerSnapshotManager?.refresh({
|
||||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||||
providers: msg.providers,
|
||||
});
|
||||
if (msg.cwd) {
|
||||
await this.providerSnapshotManager?.refreshSnapshotForCwd({
|
||||
cwd: expandTilde(msg.cwd),
|
||||
providers: msg.providers,
|
||||
});
|
||||
} else {
|
||||
await this.providerSnapshotManager?.refreshSettingsSnapshot({
|
||||
providers: msg.providers,
|
||||
});
|
||||
}
|
||||
this.emit({
|
||||
type: "refresh_providers_snapshot_response",
|
||||
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") {
|
||||
return [
|
||||
{ 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 { OpenAISTT } from "./speech/providers/openai/stt.js";
|
||||
import { STTManager } from "./agent/stt-manager.js";
|
||||
import { withTimeout } from "../utils/promise-timeout.js";
|
||||
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||
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> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
@@ -148,20 +131,20 @@ describe("voice roundtrip e2e", () => {
|
||||
path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`),
|
||||
);
|
||||
const voiceAgent = await withTimeout(
|
||||
"createVoiceTargetAgent",
|
||||
30000,
|
||||
ctx.client.createAgent({
|
||||
config: {
|
||||
...getVoiceRoundtripConfig(targetProvider),
|
||||
cwd: voiceCwd,
|
||||
},
|
||||
}),
|
||||
30000,
|
||||
"Timed out during createVoiceTargetAgent after 30000ms",
|
||||
);
|
||||
const voiceAgentId = voiceAgent.id;
|
||||
const voiceMode = await withTimeout(
|
||||
"setVoiceMode",
|
||||
15000,
|
||||
ctx.client.setVoiceMode(true, voiceAgentId),
|
||||
15000,
|
||||
"Timed out during setVoiceMode after 15000ms",
|
||||
);
|
||||
expect(voiceMode.accepted).toBe(true);
|
||||
expect(voiceMode.enabled).toBe(true);
|
||||
@@ -194,14 +177,14 @@ describe("voice roundtrip e2e", () => {
|
||||
});
|
||||
|
||||
const inputSpeech = await withTimeout(
|
||||
"synthesizeInputAudio",
|
||||
30000,
|
||||
ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."),
|
||||
30000,
|
||||
"Timed out during synthesizeInputAudio after 30000ms",
|
||||
);
|
||||
const inputPcm = await withTimeout(
|
||||
"collectInputAudio",
|
||||
15000,
|
||||
streamToBuffer(inputSpeech.stream),
|
||||
15000,
|
||||
"Timed out during collectInputAudio after 15000ms",
|
||||
);
|
||||
const outputAudio = await (async () => {
|
||||
try {
|
||||
@@ -281,16 +264,24 @@ describe("voice roundtrip e2e", () => {
|
||||
);
|
||||
const isLast = offset + chunkBytes >= inputPcm.length;
|
||||
await withTimeout(
|
||||
"sendVoiceAudioChunk",
|
||||
5000,
|
||||
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) {
|
||||
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) {
|
||||
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)}`,
|
||||
@@ -311,11 +302,11 @@ describe("voice roundtrip e2e", () => {
|
||||
? "audio/wav"
|
||||
: `audio/${outputAudio.format}`;
|
||||
const transcription = await withTimeout(
|
||||
"transcribeOutputAudio",
|
||||
60000,
|
||||
sttOutput.transcribe(outputRaw, outputFormat, {
|
||||
label: "voice-roundtrip-output",
|
||||
}),
|
||||
60000,
|
||||
"Timed out during transcribeOutputAudio after 60000ms",
|
||||
);
|
||||
const normalized = transcription.text.trim().toLowerCase();
|
||||
|
||||
|
||||
@@ -397,7 +397,7 @@ const ToolCallBasePayloadSchema = z
|
||||
callId: z.string(),
|
||||
name: z.string(),
|
||||
detail: ToolCallDetailPayloadSchema,
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -534,7 +534,7 @@ const AgentPersistenceHandleSchema: z.ZodType<AgentPersistenceHandle | null> = z
|
||||
provider: AgentProviderSchema,
|
||||
sessionId: z.string(),
|
||||
nativeHandle: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
@@ -544,7 +544,7 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
|
||||
model: z.string().nullable().optional(),
|
||||
thinkingOptionId: 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({
|
||||
@@ -568,7 +568,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
lastUsage: AgentUsageSchema.optional(),
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string()).default({}),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
@@ -577,6 +577,28 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
|
||||
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>;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -615,6 +615,41 @@ const x = 1;
|
||||
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 () => {
|
||||
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
|
||||
|
||||
@@ -1524,6 +1559,36 @@ const x = 1;
|
||||
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 () => {
|
||||
execSync("git checkout -b develop", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
|
||||
@@ -103,6 +103,16 @@ type CheckoutFileChange = {
|
||||
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 {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -358,7 +368,7 @@ export async function checkoutResolvedBranch(
|
||||
|
||||
async function listCheckoutFileChanges(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
refs: CheckoutDiffRefs,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<CheckoutFileChange[]> {
|
||||
const changes: CheckoutFileChange[] = [];
|
||||
@@ -366,7 +376,7 @@ async function listCheckoutFileChanges(
|
||||
const { stdout: nameStatusOut } = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--name-status", ref],
|
||||
extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)],
|
||||
}),
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
@@ -405,24 +415,26 @@ async function listCheckoutFileChanges(
|
||||
});
|
||||
}
|
||||
|
||||
const { stdout: untrackedOut } = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
for (const file of untrackedOut
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)) {
|
||||
changes.push({
|
||||
path: file,
|
||||
status: "U",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
isUntracked: true,
|
||||
});
|
||||
if (refs.includeUntracked) {
|
||||
const { stdout: untrackedOut } = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
for (const file of untrackedOut
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)) {
|
||||
changes.push({
|
||||
path: file,
|
||||
status: "U",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
isUntracked: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
refs: CheckoutDiffRefs,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<Map<string, FileStat>> {
|
||||
const result = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--numstat", ref],
|
||||
extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)],
|
||||
}),
|
||||
{
|
||||
cwd,
|
||||
@@ -1507,10 +1519,10 @@ export async function getCheckoutDiff(
|
||||
): Promise<CheckoutDiffResult> {
|
||||
await requireGitRepo(cwd);
|
||||
|
||||
let refForDiff: string;
|
||||
let refsForDiff: CheckoutDiffRefs;
|
||||
|
||||
if (compare.mode === "uncommitted") {
|
||||
refForDiff = "HEAD";
|
||||
refsForDiff = { baseRef: "HEAD", includeUntracked: true };
|
||||
} else {
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd));
|
||||
@@ -1522,11 +1534,15 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
|
||||
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 changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
|
||||
const changes = await listCheckoutFileChanges(cwd, refsForDiff, ignoreWhitespace);
|
||||
changes.sort((a, b) => {
|
||||
if (a.path === b.path) return 0;
|
||||
return a.path < b.path ? -1 : 1;
|
||||
@@ -1557,7 +1573,7 @@ export async function getCheckoutDiff(
|
||||
|
||||
const trackedNumstatByPath =
|
||||
trackedChanges.length > 0
|
||||
? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace)
|
||||
? await getTrackedNumstatByPath(cwd, refsForDiff, ignoreWhitespace)
|
||||
: new Map<string, FileStat>();
|
||||
const trackedDiffPaths: string[] = [];
|
||||
const trackedPlaceholderByPath = new Map<
|
||||
@@ -1584,7 +1600,7 @@ export async function getCheckoutDiff(
|
||||
const trackedDiffResult = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: [refForDiff, "--", ...trackedDiffPaths],
|
||||
extra: [...getCheckoutDiffRefArgs(refsForDiff), "--", ...trackedDiffPaths],
|
||||
}),
|
||||
{
|
||||
cwd,
|
||||
@@ -1621,7 +1637,13 @@ export async function getCheckoutDiff(
|
||||
return null;
|
||||
}
|
||||
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);
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
45
packages/server/src/utils/promise-timeout.ts
Normal file
45
packages/server/src/utils/promise-timeout.ts
Normal file
@@ -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})`,
|
||||
};
|
||||
}
|
||||
@@ -1089,7 +1089,7 @@ describe("slugify", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
expectValidHostnameLabel(result);
|
||||
@@ -1107,7 +1107,7 @@ describe("slugify", () => {
|
||||
"feature/cool stuff",
|
||||
" Café Launch ",
|
||||
"__bar__",
|
||||
"Release Candidate ".repeat(12),
|
||||
"Beta Build ".repeat(12),
|
||||
"release***candidate",
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.60-rc.3",
|
||||
"version": "0.1.60-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -38,8 +38,9 @@ const entries = [
|
||||
["RELEASE_BASE_VERSION", info.baseVersion],
|
||||
["RELEASE_PRERELEASE", info.prerelease ?? ""],
|
||||
["IS_PRERELEASE", info.isPrerelease ? "true" : "false"],
|
||||
["IS_RELEASE_CANDIDATE", info.isReleaseCandidate ? "true" : "false"],
|
||||
["IS_BETA", info.isBeta ? "true" : "false"],
|
||||
["RELEASE_TYPE", info.releaseType],
|
||||
["RELEASE_CHANNEL", info.releaseChannel],
|
||||
["DESKTOP_VERSION", info.version],
|
||||
["IS_SMOKE_TAG", info.isSmokeTag ? "true" : "false"],
|
||||
];
|
||||
|
||||
@@ -14,7 +14,7 @@ export function parseReleaseVersion(version) {
|
||||
const match = trimmed.match(versionPattern);
|
||||
if (!match?.groups) {
|
||||
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 patch = Number.parseInt(match.groups.patch, 10);
|
||||
const prerelease = match.groups.prerelease ?? null;
|
||||
const rcMatch = prerelease?.match(/^rc\.(?<rc>\d+)$/) ?? null;
|
||||
const rcNumber = rcMatch?.groups?.rc ? Number.parseInt(rcMatch.groups.rc, 10) : null;
|
||||
const betaMatch = prerelease?.match(/^beta\.(?<beta>\d+)$/) ?? 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(minor, "minor version");
|
||||
assertInteger(patch, "patch version");
|
||||
if (rcNumber !== null) {
|
||||
assertInteger(rcNumber, "release candidate number");
|
||||
if (betaNumber !== null) {
|
||||
assertInteger(betaNumber, "beta number");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -40,8 +46,8 @@ export function parseReleaseVersion(version) {
|
||||
prerelease,
|
||||
baseVersion: `${major}.${minor}.${patch}`,
|
||||
isPrerelease: prerelease !== null,
|
||||
isReleaseCandidate: rcNumber !== null,
|
||||
rcNumber,
|
||||
isBeta: betaNumber !== null,
|
||||
betaNumber,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,7 +63,7 @@ export function normalizeReleaseTag(rawTag) {
|
||||
const match = trimmed.match(sourceTagPattern);
|
||||
if (!match?.groups?.version) {
|
||||
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}`;
|
||||
@@ -73,9 +79,10 @@ export function getReleaseInfoFromSourceTag(sourceTag) {
|
||||
baseVersion: parsed.baseVersion,
|
||||
prerelease: parsed.prerelease,
|
||||
isPrerelease: parsed.isPrerelease,
|
||||
isReleaseCandidate: parsed.isReleaseCandidate,
|
||||
rcNumber: parsed.rcNumber,
|
||||
isBeta: parsed.isBeta,
|
||||
betaNumber: parsed.betaNumber,
|
||||
releaseType: parsed.isPrerelease ? "prerelease" : "release",
|
||||
releaseChannel: parsed.isBeta ? "beta" : "latest",
|
||||
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) {
|
||||
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({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor,
|
||||
patch: parsed.patch + 1,
|
||||
prerelease: "rc.1",
|
||||
prerelease: "beta.1",
|
||||
});
|
||||
}
|
||||
if (mode === "rc-minor") {
|
||||
if (mode === "beta-minor") {
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor + 1,
|
||||
patch: 0,
|
||||
prerelease: "rc.1",
|
||||
prerelease: "beta.1",
|
||||
});
|
||||
}
|
||||
return formatReleaseVersion({
|
||||
major: parsed.major + 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: "rc.1",
|
||||
prerelease: "beta.1",
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "rc-next") {
|
||||
if (!parsed.isReleaseCandidate || parsed.rcNumber === null) {
|
||||
if (mode === "beta-next") {
|
||||
if (!parsed.isBeta || parsed.betaNumber === null) {
|
||||
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({
|
||||
major: parsed.major,
|
||||
minor: parsed.minor,
|
||||
patch: parsed.patch,
|
||||
prerelease: `rc.${parsed.rcNumber + 1}`,
|
||||
prerelease: `beta.${parsed.betaNumber + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "promote") {
|
||||
if (!parsed.isReleaseCandidate) {
|
||||
if (!parsed.isBeta) {
|
||||
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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user