Compare commits

...

13 Commits

Author SHA1 Message Date
Mohamed Boudra
00870740ef Fix committed diff excluding dirty worktree 2026-04-21 15:00:08 +07:00
Mohamed Boudra
ebf8a21c08 Fix TTLCache import and rename schedule provider resolver 2026-04-21 14:19:51 +07:00
Mohamed Boudra
515ba62be6 Persist sidebar collapse state and stabilize Metro port 2026-04-21 14:01:16 +07:00
Mohamed Boudra
32a20cbeea Merge branch 'research/mcp-list-agents-summary' 2026-04-21 13:46:25 +07:00
Mohamed Boudra
9877936d13 feat: require provider/model format and return compact list items 2026-04-21 13:44:55 +07:00
Mohamed Boudra
1a473ed7f3 Persist composer preferences and stabilize hydration 2026-04-21 13:43:22 +07:00
Mohamed Boudra
3832053736 fix: provider-scoped snapshot refresh with self-healing invalidation 2026-04-21 13:14:59 +07:00
Mohamed Boudra
b01e274cce Fix provider refresh scope and self-healing 2026-04-21 12:41:28 +07:00
Mohamed Boudra
92e701be8f style: adjust typography and standardize ui text 2026-04-20 21:31:59 +07:00
Mohamed Boudra
c2c990e763 docs: note beta rename in changelog 2026-04-20 20:49:38 +07:00
Mohamed Boudra
76b9f89f68 fix: tolerate beta release creation races 2026-04-20 20:41:24 +07:00
github-actions[bot]
970bf828b3 fix: update lockfile signatures and Nix hash 2026-04-20 13:40:07 +00:00
Mohamed Boudra
dfc814147a feat: add beta release channel 2026-04-20 20:38:56 +07:00
104 changed files with 4265 additions and 1306 deletions

View File

@@ -4,9 +4,9 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- '!v*-beta.*'
- 'app-v*'
- '!app-v*-rc.*'
- '!app-v*-beta.*'
workflow_dispatch:
jobs:

View File

@@ -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[@]}"

View File

@@ -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

View File

@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.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

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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);

View File

@@ -118,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
const WEB_TOOLCALL_SHIMMER_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>
);
});

View File

@@ -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>
}
>

View File

@@ -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>
);
}

View 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} />;
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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.");
}

View File

@@ -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,

View 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");
});
});
});

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,

View 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();
});
});

View File

@@ -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)),
});
}

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 } : {}),

View File

@@ -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);

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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 }

View File

@@ -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);
});
});

View File

@@ -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,
};
},
},
),
);

View File

@@ -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);
};
}, []);

View File

@@ -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",

View File

@@ -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"

View File

@@ -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)"

View File

@@ -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

View File

@@ -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(),

View File

@@ -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)) {

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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);

View File

@@ -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> {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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",

View File

@@ -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,
};
}

View File

@@ -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).

View File

@@ -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

View File

@@ -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({

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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([
{

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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(

View File

@@ -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([]);
});
});

View File

@@ -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({

View File

@@ -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(

View File

@@ -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]",

View File

@@ -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",

View File

@@ -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,
);

View File

@@ -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);

View File

@@ -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({

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 });
});
});

View File

@@ -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());

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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)}`;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 + "/")
);
}

View File

@@ -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[] = [];

View File

@@ -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: {

View File

@@ -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 },

View File

@@ -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();

View File

@@ -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>;
// ============================================================================

View File

@@ -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 });

View File

@@ -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);
},
})
: [];

View 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})`,
};
}

View File

@@ -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",
];

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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"],
];

View File

@@ -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