mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
401fc82a17 | ||
|
|
9059f8e0e9 | ||
|
|
53c9c3c0be | ||
|
|
01eafccb6a | ||
|
|
ab7543ae1d | ||
|
|
e32c50f2d7 | ||
|
|
e3eb633582 | ||
|
|
beac4544fd | ||
|
|
c2271ff0ca | ||
|
|
f91806defe | ||
|
|
e9431517a0 | ||
|
|
4e28b0941b | ||
|
|
7c6152663e | ||
|
|
5fc53c576e |
+35
-31
@@ -1,51 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.102-beta.2 - 2026-06-29
|
||||
## 0.1.103 - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Fork a chat from any assistant answer ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- Use Paseo from a browser served directly by your daemon ([#1635](https://github.com/getpaseo/paseo/pull/1635))
|
||||
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
|
||||
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
|
||||
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
|
||||
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
|
||||
- Update a remote host from the app when a newer daemon is available ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Dropped files can be attached from every composer ([#1750](https://github.com/getpaseo/paseo/pull/1750))
|
||||
- MiniMax usage is available in provider quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- C# code blocks now have syntax highlighting ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
|
||||
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
|
||||
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
|
||||
### Improved
|
||||
|
||||
- The sidebar can show workspaces across all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538))
|
||||
- Multi-host workspace rows show which host they belong to ([#1775](https://github.com/getpaseo/paseo/pull/1775))
|
||||
- Host switcher rows show the connection currently in use
|
||||
- Creating a new workspace is available from an app-wide screen ([#1746](https://github.com/getpaseo/paseo/pull/1746))
|
||||
- New Workspace starts from the current project when opened inside a project ([#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress while results are fetched ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show feedback while checking for updates ([#1808](https://github.com/getpaseo/paseo/pull/1808))
|
||||
- Slow host requests time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results before timing out ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
|
||||
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
|
||||
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
|
||||
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
|
||||
- Web terminals stay smoother while scrolling ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Opening projects and directory suggestions work better in large repos ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile opens the saved workspace reliably on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename existing workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays at your scroll position when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
|
||||
- Chat actions wait until running tool calls finish
|
||||
- Claude subagent narration no longer leaks into chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Agent lists keep working when project records go stale ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews work with drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
|
||||
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
|
||||
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
|
||||
- Desktop file uploads keep their file extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Closing Claude Code sessions no longer leaves child processes running ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
|
||||
- `paseo daemon status` reports daemon health even when agent details are unavailable ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly after checkout ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
|
||||
## 0.1.101 - 2026-06-26
|
||||
|
||||
|
||||
+13
-4
@@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { voice: { apiKey: string, baseUrl: string } },
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -202,13 +207,17 @@ Set these to select OpenAI instead of local speech:
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI voice can be configured under `providers.openai`:
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -217,7 +226,7 @@ OpenAI voice can be configured under `providers.openai`:
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-4fS/faM5nUjfRnc0UznCisNAby2fiNg0ROklW0FyptY=
|
||||
sha256-uGqJm/y14KJYBwutBkPwxhNbStzpeeVx2PcB081r4nk=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35132,7 +35132,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36150,12 +36150,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/server": "0.1.102-beta.2",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/server": "0.1.103",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36401,10 +36401,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36415,7 +36415,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36658,7 +36658,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37554,7 +37554,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37786,7 +37786,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37798,7 +37798,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38016,15 +38016,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/highlight": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/highlight": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38561,7 +38561,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopAboutSettings,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickCheckForUpdates,
|
||||
expectPendingUpdateCheckResult,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
|
||||
test("manual check reports a found update while it downloads", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
updateReadyToInstall: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopAboutSettings(page);
|
||||
|
||||
await clickCheckForUpdates(page);
|
||||
|
||||
await expectPendingUpdateCheckResult(page, "1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
updateReadyToInstall?: boolean;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
readyToInstall: cfg.updateReadyToInstall ?? true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDesktopAboutSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickCheckForUpdates(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Check" }).click();
|
||||
}
|
||||
|
||||
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
|
||||
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
||||
await expect(
|
||||
page.getByText(
|
||||
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
|
||||
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
|
||||
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
|
||||
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
|
||||
// the view as multi-host without standing up a second daemon.
|
||||
//
|
||||
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
|
||||
// and reseeds on every navigation. We write the full registry here and set the fixture's
|
||||
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
|
||||
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
|
||||
// relabels the seeded primary host so assertions can target a distinctive name.
|
||||
export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const SECONDARY_HOST_ID = "host-filter-secondary";
|
||||
|
||||
test.describe("Sidebar host filter (multi-select)", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
|
||||
const serverId = getServerId();
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// A second (offline) host is enough to surface the host filter without a second daemon.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
// Pin the primary host — its workspace stays visible.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Add the secondary host without clearing the primary. Under single-select this would replace
|
||||
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
|
||||
await toggleHostFilter(page, SECONDARY_HOST_ID);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// Back to all hosts — the workspace returns.
|
||||
await selectAllHostsFilter(page);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
|
||||
return owners;
|
||||
}
|
||||
|
||||
function footerAssistantIds(layout: StreamLayout): string[] {
|
||||
return [
|
||||
...layout.history.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...layout.liveHead.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
[...layout.history, ...layout.liveHead].flatMap((item) =>
|
||||
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
@@ -291,7 +311,7 @@ describe("layoutStream", () => {
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
|
||||
"places inline footer after trailing visible tool rows before the next user on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
@@ -302,13 +322,36 @@ describe("layoutStream", () => {
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([tool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
|
||||
"places split live-head tool footer using the assistant from history on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
head: [tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[tool.id]: assistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for footer content while placing after the visible turn end on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const firstTool = toolCall("tool-1", 3);
|
||||
@@ -329,10 +372,46 @@ describe("layoutStream", () => {
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
|
||||
latestAssistant.id,
|
||||
);
|
||||
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
|
||||
expect(footerOwners(layout)).toEqual([latestTool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const secondAssistant = assistantMessage("a2", 4);
|
||||
const secondTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
userMessage("u2", 3),
|
||||
secondAssistant,
|
||||
secondTool,
|
||||
userMessage("u3", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, secondAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
|
||||
firstAssistant.id,
|
||||
);
|
||||
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
|
||||
secondAssistant.id,
|
||||
);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[firstAssistant.id]: firstAssistant.id,
|
||||
[secondTool.id]: secondAssistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
function createTurnFooterHost(input: {
|
||||
@@ -68,25 +75,45 @@ function createTurnFooterHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantIndexInTurn(input: {
|
||||
function findLatestAssistantInTurn(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): number | null {
|
||||
for (
|
||||
let index = input.startIndex;
|
||||
index >= 0 && index < input.items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = input.items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): AssistantFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
|
||||
while (true) {
|
||||
for (
|
||||
;
|
||||
index >= 0 && index < items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!canCrossBoundary ||
|
||||
!input.boundaryAboveItems ||
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return index;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
index = input.boundaryAboveIndex;
|
||||
canCrossBoundary = false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
@@ -100,104 +127,54 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (assistantIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[assistantIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
if (!assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item,
|
||||
items: footerItems,
|
||||
index: assistantIndex,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function findTurnEndIndexInSegment(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): number {
|
||||
let endIndex = input.startIndex;
|
||||
for (
|
||||
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
|
||||
index >= 0 && index < input.items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "below")
|
||||
) {
|
||||
const item = input.items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
endIndex = index;
|
||||
}
|
||||
return endIndex;
|
||||
}
|
||||
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
function resolveCompletedFooter(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
agentStatus: string;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryIndex: number | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
}): boolean {
|
||||
if (
|
||||
input.item.kind !== "assistant_message" ||
|
||||
input.auxiliaryTurnFooter?.itemId === input.item.id
|
||||
) {
|
||||
return false;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isToolSequenceItem(input.belowItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
|
||||
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnEndIndex = findTurnEndIndexInSegment({
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const belowTurnItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index: turnEndIndex,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
if (input.agentStatus === "running" && belowTurnItem?.kind !== "user_message") {
|
||||
return false;
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
return null;
|
||||
}
|
||||
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: turnEndIndex,
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
return assistantIndex === input.index;
|
||||
}
|
||||
|
||||
function isToolSequenceItem(
|
||||
@@ -270,24 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
agentStatus: input.agentStatus,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryBelowItem: input.boundaryBelowItem,
|
||||
})
|
||||
? createTurnFooterHost({
|
||||
item,
|
||||
items: input.items,
|
||||
index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
})
|
||||
: null;
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
@@ -328,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
const historyCacheKey = [
|
||||
input.agentStatus,
|
||||
frameOrder,
|
||||
historyBoundaryIndex ?? "null",
|
||||
liveHeadBoundaryItem?.id ?? "null",
|
||||
@@ -346,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
} else {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -354,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
boundaryAboveItems: null,
|
||||
boundaryAboveIndex: null,
|
||||
});
|
||||
byKey.set(historyCacheKey, history);
|
||||
}
|
||||
@@ -363,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -371,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
boundaryBelowItem: null,
|
||||
boundaryAboveItems: input.history,
|
||||
boundaryAboveIndex: historyBoundaryIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
|
||||
|
||||
interface CommandCenterAgentRowContentProps {
|
||||
agent: AggregatedAgent;
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
||||
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || t("shell.commandCenter.newAgent")}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1}>
|
||||
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{showHost ? `${agent.serverLabel} · ` : ""}
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function AgentItemsSection({
|
||||
@@ -257,6 +261,7 @@ function AgentItemsSection({
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
showHost,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -277,7 +282,7 @@ function AgentItemsSection({
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
|
||||
</CommandCenterAgentRow>
|
||||
);
|
||||
})}
|
||||
@@ -302,6 +307,8 @@ export function CommandCenter() {
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
// Host names only earn their space once results can span more than one host.
|
||||
const showHost = useHosts().length > 1;
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
@@ -477,6 +484,7 @@ export function CommandCenter() {
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
showHost={showHost}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
|
||||
|
||||
export function SidebarDisplayPreferencesMenu() {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
const hosts = useHosts();
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
[setGroupMode],
|
||||
);
|
||||
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string | null) => {
|
||||
setHostFilter(serverId);
|
||||
},
|
||||
[setHostFilter],
|
||||
);
|
||||
|
||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
void updateSettings({ workspaceTitleSource: source });
|
||||
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
const allHostsSelected = hostFilters.length === 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
||||
</View>
|
||||
<HostFilterItem
|
||||
label="All hosts"
|
||||
value={null}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-host-filter-all"
|
||||
selected={allHostsSelected}
|
||||
closeOnSelect={false}
|
||||
onSelect={clearHostFilters}
|
||||
>
|
||||
All hosts
|
||||
</DropdownMenuItem>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
key={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
serverId={host.serverId}
|
||||
value={host.serverId}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
selected={hostFilters.includes(host.serverId)}
|
||||
onToggle={toggleHostFilter}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
|
||||
function HostFilterItem({
|
||||
label,
|
||||
serverId,
|
||||
value,
|
||||
hostFilter,
|
||||
onSelect,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
serverId?: string;
|
||||
value: string | null;
|
||||
hostFilter: string | null;
|
||||
onSelect: (serverId: string | null) => void;
|
||||
serverId: string;
|
||||
selected: boolean;
|
||||
onToggle: (serverId: string) => void;
|
||||
}) {
|
||||
const isSelected = hostFilter === value;
|
||||
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
|
||||
const status = useHostRuntimeSnapshot(serverId ?? "");
|
||||
const subtitle = serverId
|
||||
? formatConnectionStatus(status?.connectionStatus ?? "idle")
|
||||
: undefined;
|
||||
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-host-filter-${serverId}`}
|
||||
selected={selected}
|
||||
closeOnSelect={false}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||
useSidebarViewStore.getState().setHostFilter("host-b");
|
||||
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.159.1",
|
||||
version: "0.161.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.159.1", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.8.0",
|
||||
version: "0.8.1",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.8.0", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.21",
|
||||
version: "1.1.22",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
|
||||
@@ -114,15 +114,17 @@ describe("desktop app updater — check", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
|
||||
it("reports the found update while it is still preparing", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
|
||||
port.nextCheckResult(
|
||||
buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }),
|
||||
);
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot()).toMatchObject({
|
||||
status: "pending",
|
||||
availableUpdate: null,
|
||||
availableUpdate: { latestVersion: "1.2.3", readyToInstall: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -408,17 +410,17 @@ describe("formatStatusText", () => {
|
||||
).toBe("Update ready: v1.2.3");
|
||||
});
|
||||
|
||||
it("keeps manual check feedback visible while an update is pending", () => {
|
||||
it("shows the found version while an update is pending", () => {
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "pending",
|
||||
availableUpdate: null,
|
||||
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
|
||||
installMessage: null,
|
||||
lastCheckedAt: 42,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("We'll let you know when the update is ready. Last checked at time-42.");
|
||||
).toBe("Update found: v1.2.3. Downloading... Last checked at time-42.");
|
||||
});
|
||||
|
||||
it("keeps manual check feedback visible when an update is available", () => {
|
||||
|
||||
@@ -137,6 +137,18 @@ export function formatStatusText(input: {
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
if (availableUpdate?.latestVersion) {
|
||||
return i18n.t(
|
||||
lastCheckedAt != null
|
||||
? "desktop.updates.status.pendingWithVersionAndLastChecked"
|
||||
: "desktop.updates.status.pendingWithVersion",
|
||||
{
|
||||
version: formatVersion(availableUpdate.latestVersion),
|
||||
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (lastCheckedAt != null) {
|
||||
return i18n.t("desktop.updates.status.pendingWithLastChecked", {
|
||||
time: formatLastCheckedAt(lastCheckedAt),
|
||||
@@ -244,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
|
||||
nextAvailable = result;
|
||||
} else if (result.hasUpdate) {
|
||||
nextStatus = "pending";
|
||||
nextAvailable = null;
|
||||
nextAvailable = result;
|
||||
} else {
|
||||
nextStatus = "up-to-date";
|
||||
nextAvailable = null;
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult {
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
hostFilter?: string | null;
|
||||
hostFilters?: readonly string[];
|
||||
enabled?: boolean;
|
||||
}): SidebarWorkspacesListResult {
|
||||
const runtime = getHostRuntimeStore();
|
||||
@@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const hostRegistryLoaded = useHostRegistryLoaded();
|
||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||
|
||||
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const hostFilter = options?.hostFilter ?? storeHostFilter;
|
||||
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
|
||||
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
|
||||
const effectiveHostFilter =
|
||||
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
|
||||
const storeHostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const hostFilters = options?.hostFilters ?? storeHostFilters;
|
||||
const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters);
|
||||
const isActive = options?.enabled !== false;
|
||||
|
||||
const serverIds = useMemo(() => {
|
||||
if (effectiveHostFilter) {
|
||||
return allServerIds.filter((id) => id === effectiveHostFilter);
|
||||
if (hostFilters.length === 0) {
|
||||
return allServerIds;
|
||||
}
|
||||
return allServerIds;
|
||||
}, [allServerIds, effectiveHostFilter]);
|
||||
const selected = new Set(hostFilters);
|
||||
const matched = allServerIds.filter((id) => selected.has(id));
|
||||
// Registry has settled but none of the pinned hosts still exist — fall back to every
|
||||
// host rather than leaving the sidebar empty.
|
||||
if (hostRegistryLoaded && matched.length === 0) {
|
||||
return allServerIds;
|
||||
}
|
||||
return matched;
|
||||
}, [allServerIds, hostFilters, hostRegistryLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRegistryLoaded) {
|
||||
return;
|
||||
}
|
||||
reconcileHostFilter(allServerIds);
|
||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
|
||||
reconcileHostFilters(allServerIds);
|
||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilters]);
|
||||
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||
|
||||
|
||||
@@ -524,6 +524,12 @@ describe("translation resources", () => {
|
||||
expect(en.desktop.updates.status.pendingWithLastChecked).toBe(
|
||||
"We'll let you know when the update is ready. Last checked at {{time}}.",
|
||||
);
|
||||
expect(en.desktop.updates.status.pendingWithVersion).toBe(
|
||||
"Update found: {{version}}. Downloading...",
|
||||
);
|
||||
expect(en.desktop.updates.status.pendingWithVersionAndLastChecked).toBe(
|
||||
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
|
||||
);
|
||||
expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}");
|
||||
expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe(
|
||||
"Update ready: {{version}}. Last checked at {{time}}.",
|
||||
|
||||
@@ -939,6 +939,9 @@ export const ar: TranslationResources = {
|
||||
upToDateWithLastChecked: "Up to date. Last checked at {{time}}.",
|
||||
pending: "سنخبرك عندما يصبح التحديث جاهزًا.",
|
||||
pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.",
|
||||
pendingWithVersion: "تم العثور على تحديث: {{version}}. جارٍ التنزيل...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"تم العثور على تحديث: {{version}}. جارٍ التنزيل... آخر فحص في {{time}}.",
|
||||
availableWithVersion: "التحديث جاهز:{{version}}",
|
||||
availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.",
|
||||
available: "تحديث التطبيق جاهز للتثبيت.",
|
||||
|
||||
@@ -947,6 +947,9 @@ export const en = {
|
||||
pending: "We'll let you know when the update is ready.",
|
||||
pendingWithLastChecked:
|
||||
"We'll let you know when the update is ready. Last checked at {{time}}.",
|
||||
pendingWithVersion: "Update found: {{version}}. Downloading...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
|
||||
availableWithVersion: "Update ready: {{version}}",
|
||||
availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.",
|
||||
available: "An app update is ready to install.",
|
||||
|
||||
@@ -967,6 +967,9 @@ export const es: TranslationResources = {
|
||||
pending: "Le avisaremos cuando la actualización esté lista.",
|
||||
pendingWithLastChecked:
|
||||
"Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.",
|
||||
pendingWithVersion: "Actualización encontrada: {{version}}. Descargando...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Actualización encontrada: {{version}}. Descargando... Última comprobación a las {{time}}.",
|
||||
availableWithVersion: "Actualización lista:{{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Actualización lista:{{version}}. Última comprobación a las {{time}}.",
|
||||
|
||||
@@ -966,6 +966,9 @@ export const fr: TranslationResources = {
|
||||
pending: "Nous vous informerons lorsque la mise à jour sera prête.",
|
||||
pendingWithLastChecked:
|
||||
"Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.",
|
||||
pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.",
|
||||
availableWithVersion: "Mise à jour prête:{{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Mise à jour prête:{{version}}. Dernière vérification à {{time}}.",
|
||||
|
||||
@@ -951,6 +951,9 @@ export const ja: TranslationResources = {
|
||||
upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。",
|
||||
pending: "更新の準備ができたらお知らせします。",
|
||||
pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。",
|
||||
pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。",
|
||||
availableWithVersion: "更新の準備ができました: {{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"更新の準備ができました: {{version}}。最終確認: {{time}}。",
|
||||
|
||||
@@ -958,6 +958,9 @@ export const ptBR: TranslationResources = {
|
||||
pending: "Avisaremos quando a atualização estiver pronta.",
|
||||
pendingWithLastChecked:
|
||||
"Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.",
|
||||
pendingWithVersion: "Atualização encontrada: {{version}}. Baixando...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Atualização encontrada: {{version}}. Baixando... Última verificação às {{time}}.",
|
||||
availableWithVersion: "Atualização pronta: {{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Atualização pronta: {{version}}. Última verificação às {{time}}.",
|
||||
|
||||
@@ -959,6 +959,9 @@ export const ru: TranslationResources = {
|
||||
pending: "Мы сообщим вам, когда обновление будет готово.",
|
||||
pendingWithLastChecked:
|
||||
"Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.",
|
||||
pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.",
|
||||
availableWithVersion: "Обновление готово:{{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Обновление готово:{{version}}. Последняя проверка в {{time}}.",
|
||||
|
||||
@@ -928,6 +928,9 @@ export const zhCN: TranslationResources = {
|
||||
upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。",
|
||||
pending: "更新准备好后会通知你。",
|
||||
pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。",
|
||||
pendingWithVersion: "发现更新:{{version}}。正在下载...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"发现更新:{{version}}。正在下载... 上次检查时间:{{time}}。",
|
||||
availableWithVersion: "更新已就绪:{{version}}",
|
||||
availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。",
|
||||
available: "有 app 更新可安装。",
|
||||
|
||||
@@ -701,6 +701,9 @@ function DesktopAppUpdateRow() {
|
||||
});
|
||||
}, [installUpdate, isDesktopApp, t]);
|
||||
|
||||
const isUpdateReady = availableUpdate?.readyToInstall === true;
|
||||
const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null;
|
||||
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
@@ -725,10 +728,10 @@ function DesktopAppUpdateRow() {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{statusText}</Text>
|
||||
{availableUpdate?.latestVersion ? (
|
||||
{readyUpdateVersion ? (
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.about.updates.readyToInstall", {
|
||||
version: formatVersionWithPrefix(availableUpdate.latestVersion),
|
||||
version: formatVersionWithPrefix(readyUpdateVersion),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -747,9 +750,9 @@ function DesktopAppUpdateRow() {
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleInstallUpdate}
|
||||
disabled={isChecking || isInstalling || !availableUpdate}
|
||||
disabled={isChecking || isInstalling || !isUpdateReady}
|
||||
>
|
||||
{getUpdateButtonLabel(t, isInstalling, availableUpdate?.latestVersion)}
|
||||
{getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -39,24 +39,44 @@ describe("sidebar view store", () => {
|
||||
beforeEach(() => {
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a host filter that still points at an available host", () => {
|
||||
useSidebarViewStore.getState().setHostFilter("host-a");
|
||||
it("toggles multiple hosts into and out of the filter", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("host-b");
|
||||
|
||||
useSidebarViewStore.getState().reconcileHostFilter(["host-a", "host-b"]);
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilter).toBe("host-a");
|
||||
store.toggleHostFilter("host-a");
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-b"]);
|
||||
|
||||
store.clearHostFilters();
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears a host filter after that host is removed", () => {
|
||||
useSidebarViewStore.getState().setHostFilter("removed-host");
|
||||
it("keeps host filters that still point at available hosts", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("host-b");
|
||||
|
||||
useSidebarViewStore.getState().reconcileHostFilter(["host-a"]);
|
||||
store.reconcileHostFilters(["host-a", "host-b", "host-c"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilter).toBeNull();
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
|
||||
});
|
||||
|
||||
it("drops a host filter after that host is removed", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("removed-host");
|
||||
|
||||
store.reconcileHostFilters(["host-a"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a"]);
|
||||
});
|
||||
|
||||
it("migrates legacy per-host group modes to the new global mode", () => {
|
||||
@@ -69,11 +89,11 @@ describe("sidebar view store", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps current persisted sidebar view state during version migration", () => {
|
||||
it("migrates a pre-v2 single host filter to the multi-host list", () => {
|
||||
expect(
|
||||
migrateSidebarViewState({
|
||||
groupMode: "status",
|
||||
@@ -81,7 +101,19 @@ describe("sidebar view store", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilter: "host-a",
|
||||
hostFilters: ["host-a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps current persisted sidebar view state during version migration", () => {
|
||||
expect(
|
||||
migrateSidebarViewState({
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,8 +140,8 @@ describe("sidebar view store", () => {
|
||||
it("uses the new storage key without reading the legacy key when current state exists", async () => {
|
||||
const storage = createMemoryStorage({
|
||||
"sidebar-view": JSON.stringify({
|
||||
state: { groupMode: "project", hostFilter: "host-a" },
|
||||
version: 1,
|
||||
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||
version: 2,
|
||||
}),
|
||||
"sidebar-group-mode": JSON.stringify({
|
||||
state: { groupModeByServerId: { "host-b": "status" } },
|
||||
@@ -121,8 +153,8 @@ describe("sidebar view store", () => {
|
||||
|
||||
expect(value).toBe(
|
||||
JSON.stringify({
|
||||
state: { groupMode: "project", hostFilter: "host-a" },
|
||||
version: 1,
|
||||
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||
version: 2,
|
||||
}),
|
||||
);
|
||||
expect(storage.reads).toEqual(["sidebar-view"]);
|
||||
|
||||
@@ -6,19 +6,21 @@ export type SidebarGroupMode = "project" | "status";
|
||||
|
||||
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
|
||||
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 1;
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 2;
|
||||
|
||||
interface SidebarViewStoreState {
|
||||
groupMode: SidebarGroupMode;
|
||||
hostFilter: string | null;
|
||||
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
|
||||
hostFilters: string[];
|
||||
setGroupMode: (mode: SidebarGroupMode) => void;
|
||||
setHostFilter: (serverId: string | null) => void;
|
||||
reconcileHostFilter: (serverIds: readonly string[]) => void;
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
reconcileHostFilters: (serverIds: readonly string[]) => void;
|
||||
}
|
||||
|
||||
interface SidebarViewPersistedState {
|
||||
groupMode: SidebarGroupMode;
|
||||
hostFilter: string | null;
|
||||
hostFilters: string[];
|
||||
}
|
||||
|
||||
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
|
||||
@@ -40,19 +42,32 @@ function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGr
|
||||
return modes.includes("status") ? "status" : "project";
|
||||
}
|
||||
|
||||
// Reads the host filter from any persisted shape: the current `hostFilters` array, or the
|
||||
// pre-v2 single `hostFilter` string (null/absent meant "all hosts").
|
||||
function readHostFilters(persistedState: Record<string, unknown>): string[] {
|
||||
const hostFilters = persistedState.hostFilters;
|
||||
if (Array.isArray(hostFilters)) {
|
||||
return hostFilters.filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
// COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted
|
||||
// sidebar state (a single `hostFilter` string) has aged out.
|
||||
const legacyHostFilter = persistedState.hostFilter;
|
||||
return typeof legacyHostFilter === "string" ? [legacyHostFilter] : [];
|
||||
}
|
||||
|
||||
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
|
||||
if (!isRecord(persistedState)) {
|
||||
return { groupMode: "project", hostFilter: null };
|
||||
return { groupMode: "project", hostFilters: [] };
|
||||
}
|
||||
|
||||
const legacyGroupMode = readLegacyGroupMode(persistedState);
|
||||
if (legacyGroupMode) {
|
||||
return { groupMode: legacyGroupMode, hostFilter: null };
|
||||
return { groupMode: legacyGroupMode, hostFilters: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
|
||||
hostFilter: typeof persistedState.hostFilter === "string" ? persistedState.hostFilter : null,
|
||||
hostFilters: readHostFilters(persistedState),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,15 +91,26 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
setGroupMode: (mode) => set({ groupMode: mode }),
|
||||
setHostFilter: (serverId) => set({ hostFilter: serverId }),
|
||||
reconcileHostFilter: (serverIds) =>
|
||||
toggleHostFilter: (serverId) =>
|
||||
set((state) => ({
|
||||
hostFilters: state.hostFilters.includes(serverId)
|
||||
? state.hostFilters.filter((id) => id !== serverId)
|
||||
: [...state.hostFilters, serverId],
|
||||
})),
|
||||
clearHostFilters: () => set({ hostFilters: [] }),
|
||||
reconcileHostFilters: (serverIds) =>
|
||||
set((state) => {
|
||||
if (!state.hostFilter || serverIds.includes(state.hostFilter)) {
|
||||
if (state.hostFilters.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { hostFilter: null };
|
||||
const allowed = new Set(serverIds);
|
||||
const next = state.hostFilters.filter((id) => allowed.has(id));
|
||||
if (next.length === state.hostFilters.length) {
|
||||
return state;
|
||||
}
|
||||
return { hostFilters: next };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
@@ -93,7 +119,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
storage: createJSONStorage(createSidebarViewStorage),
|
||||
partialize: (state) => ({
|
||||
groupMode: state.groupMode,
|
||||
hostFilter: state.hostFilter,
|
||||
hostFilters: state.hostFilters,
|
||||
}),
|
||||
migrate: migrateSidebarViewState,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/server": "0.1.102-beta.2",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/server": "0.1.103",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -60,6 +60,11 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.8",
|
||||
descriptionFragment: "Latest release",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
model: "Sonnet 5",
|
||||
descriptionFragment: "Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-7[1m]",
|
||||
model: "Opus 4.7 1M",
|
||||
@@ -392,7 +397,7 @@ try {
|
||||
"--quiet should print the current Claude catalog IDs",
|
||||
);
|
||||
assert(
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-6"),
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
|
||||
"captured --json output should include the current Claude everyday model id",
|
||||
);
|
||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -58,6 +58,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
this.configuration?.onUpdateAvailable(info);
|
||||
}
|
||||
|
||||
finishUpdateDownload(info: RuntimeUpdateInfo): void {
|
||||
this.configuration?.onUpdateDownloaded(info);
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<{
|
||||
isUpdateAvailable: boolean;
|
||||
updateInfo: RuntimeUpdateInfo;
|
||||
@@ -144,6 +148,37 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("performs a fresh manual check when an update is already cached", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
runtime.nextCheck({
|
||||
isUpdateAvailable: true,
|
||||
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
|
||||
});
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts the runtime availability decision before comparing versions", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||
@@ -347,6 +382,70 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("performs a fresh manual check after an update preparation error", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
runtime.nextCheck({
|
||||
isUpdateAvailable: true,
|
||||
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
|
||||
});
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a downloaded update ready when a manual check re-announces it", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const recheck = runtime.deferNextCheck();
|
||||
const pending = service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.prepareUpdate(rolledOutUpdate);
|
||||
recheck.resolve({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
const result = await pending;
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
@@ -163,9 +163,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
});
|
||||
},
|
||||
onUpdateAvailable(info) {
|
||||
const alreadyReady = downloadedUpdateVersion === info.version;
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = true;
|
||||
downloadedUpdateVersion = alreadyReady ? info.version : null;
|
||||
downloading = !alreadyReady;
|
||||
runtimeErrorMessage = null;
|
||||
},
|
||||
onUpdateDownloaded(info) {
|
||||
@@ -212,7 +213,12 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
}
|
||||
|
||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||
if (!runtimeErrorResult && cachedVersion && cachedVersion !== currentVersion) {
|
||||
if (
|
||||
!runtimeErrorResult &&
|
||||
intent === "automatic" &&
|
||||
cachedVersion &&
|
||||
cachedVersion !== currentVersion
|
||||
) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface AgentModelDefinition {
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
contextWindowMaxTokens?: number;
|
||||
metadata?: AgentMetadata;
|
||||
thinkingOptions?: AgentSelectOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("provider snapshot message schemas", () => {
|
||||
id: "MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7",
|
||||
isDefault: true,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off" },
|
||||
{ id: "max", label: "Max", isDefault: true },
|
||||
@@ -62,6 +63,7 @@ describe("provider snapshot message schemas", () => {
|
||||
id: "MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7",
|
||||
isDefault: true,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off" },
|
||||
{ id: "max", label: "Max", isDefault: true },
|
||||
|
||||
@@ -242,6 +242,7 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
contextWindowMaxTokens: z.number().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
|
||||
defaultThinkingOptionId: z.string().optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# OpenAI API Key (for GPT-4, Whisper STT, and TTS)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional: point speech-to-text and text-to-speech at different OpenAI-compatible
|
||||
# endpoints. Each falls back to OPENAI_API_KEY / OPENAI_BASE_URL when unset.
|
||||
# STT covers composer dictation + voice mode STT; TTS covers voice mode TTS.
|
||||
OPENAI_STT_API_KEY=
|
||||
OPENAI_STT_BASE_URL=
|
||||
OPENAI_TTS_API_KEY=
|
||||
OPENAI_TTS_BASE_URL=
|
||||
|
||||
# TTS Configuration (optional - defaults shown)
|
||||
TTS_VOICE=alloy
|
||||
# Available voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -65,10 +65,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/highlight": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/highlight": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -26,7 +26,7 @@ async function main(): Promise<void> {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
logger,
|
||||
agentClients: {},
|
||||
openai: { apiKey },
|
||||
openai: { stt: { apiKey }, tts: { apiKey } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface AgentModelDefinition {
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
contextWindowMaxTokens?: number;
|
||||
metadata?: AgentMetadata;
|
||||
thinkingOptions?: AgentSelectOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js
|
||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
@@ -644,24 +645,23 @@ function addDerivedProviders(
|
||||
enabled: override.enabled !== false,
|
||||
derivedFromProviderId: null,
|
||||
providerParams: override.params,
|
||||
createBaseClient: (logger) =>
|
||||
providerId === "cursor"
|
||||
? new CursorACPAgentClient({
|
||||
logger,
|
||||
command,
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
})
|
||||
: new GenericACPAgentClient({
|
||||
logger,
|
||||
command,
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
}),
|
||||
createBaseClient: (logger) => {
|
||||
const acpOptions = {
|
||||
logger,
|
||||
command,
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
};
|
||||
if (providerId === "cursor") {
|
||||
return new CursorACPAgentClient(acpOptions);
|
||||
}
|
||||
if (providerId === "kiro") {
|
||||
return new KiroACPAgentClient(acpOptions);
|
||||
}
|
||||
return new GenericACPAgentClient(acpOptions);
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
writeCopilotProviderMode,
|
||||
} from "./copilot-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
import { parseKiroExtensionCommands } from "./kiro-acp-agent.js";
|
||||
import { transformPiModels } from "./pi/agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
|
||||
@@ -165,6 +166,35 @@ function createSessionWithConfig(
|
||||
);
|
||||
}
|
||||
|
||||
function createKiroSession(
|
||||
options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {},
|
||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "kiro",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "kiro",
|
||||
logger,
|
||||
defaultCommand: ["kiro-cli", "acp"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
extensionCommandsParser: parseKiroExtensionCommands,
|
||||
waitForInitialCommands: options.waitForInitialCommands ?? false,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createTerminalChildStub(): ChildProcess {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
child.stdout = new EventEmitter() as ChildProcess["stdout"];
|
||||
@@ -1902,6 +1932,86 @@ describe("ACPAgentSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("maps the Kiro _kiro.dev/commands/available notification into slash commands and skills", async () => {
|
||||
const session = createKiroSession({
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 1500,
|
||||
});
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
await session.extNotification("_kiro.dev/commands/available", {
|
||||
sessionId: "session-1",
|
||||
commands: [
|
||||
{
|
||||
name: "/agent",
|
||||
description: "Select or list available agents",
|
||||
meta: { inputType: "selection", hint: "swap <name>" },
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
name: "agent-sync-doctor",
|
||||
description: "Hand off Claude or Codex state across Macs",
|
||||
arguments: [],
|
||||
serverName: "skill:config",
|
||||
},
|
||||
],
|
||||
// Tools are not slash commands and must be ignored.
|
||||
tools: [{ name: "code", description: "Code intelligence", source: "built-in" }],
|
||||
});
|
||||
|
||||
expect(await listCommandsPromise).toEqual([
|
||||
{
|
||||
name: "agent",
|
||||
description: "Select or list available agents",
|
||||
argumentHint: "swap <name>",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "agent-sync-doctor",
|
||||
description: "Hand off Claude or Codex state across Macs",
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores Kiro _kiro.dev/commands/available for a different session", async () => {
|
||||
const session = createKiroSession();
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
|
||||
await session.extNotification("_kiro.dev/commands/available", {
|
||||
sessionId: "other-session",
|
||||
commands: [{ name: "/agent", description: "Select or list available agents" }],
|
||||
prompts: [],
|
||||
});
|
||||
|
||||
expect(await session.listCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
test("settles listCommands() immediately on an empty Kiro commands batch", async () => {
|
||||
// A long timeout means a resolution can only come from settleCommandsReady()
|
||||
// firing — not from the wait timer — so this test would hang if the empty
|
||||
// batch failed to unblock listCommands() (the P1 regression).
|
||||
const session = createKiroSession({
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 60_000,
|
||||
});
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
await session.extNotification("_kiro.dev/commands/available", {
|
||||
sessionId: "session-1",
|
||||
commands: [],
|
||||
prompts: [],
|
||||
});
|
||||
|
||||
expect(await listCommandsPromise).toEqual([]);
|
||||
});
|
||||
|
||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||
|
||||
@@ -334,6 +334,17 @@ export function createLoggedNdJsonStream(
|
||||
return { readable, writable };
|
||||
}
|
||||
|
||||
// Lets a provider that publishes its slash commands through a vendor-specific
|
||||
// ACP extension notification (rather than the standard
|
||||
// `available_commands_update` session update) translate that payload into Paseo
|
||||
// slash commands, without the generic ACP session/client carrying any vendor
|
||||
// knowledge. Return the parsed commands (possibly empty) for a notification this
|
||||
// provider owns, or null to ignore notifications it does not handle.
|
||||
export type ACPExtensionCommandsParser = (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => AgentSlashCommand[] | null;
|
||||
|
||||
interface ACPAgentClientOptions {
|
||||
provider: string;
|
||||
logger: Logger;
|
||||
@@ -356,6 +367,7 @@ interface ACPAgentClientOptions {
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
terminateProcess?: ProcessTerminator;
|
||||
@@ -383,6 +395,7 @@ interface ACPAgentSessionOptions {
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
handle?: AgentPersistenceHandle;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
@@ -692,6 +705,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
) => Promise<void>;
|
||||
private readonly waitForInitialCommands: boolean;
|
||||
private readonly initialCommandsWaitTimeoutMs: number;
|
||||
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
protected readonly terminateProcess: ProcessTerminator;
|
||||
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
@@ -716,6 +730,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
this.extensionCommandsParser = options.extensionCommandsParser;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -743,6 +758,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
@@ -791,6 +807,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
handle,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
@@ -1273,6 +1290,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private commandsReadySettled = false;
|
||||
private waitForInitialCommands: boolean;
|
||||
private initialCommandsWaitTimeoutMs: number;
|
||||
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
private currentTurnUsage: AgentUsage | undefined;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private closed = false;
|
||||
@@ -1309,6 +1327,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.currentTitle = config.title ?? null;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
this.extensionCommandsParser = options.extensionCommandsParser;
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -2082,6 +2101,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
},
|
||||
"provider.acp.extension_notification",
|
||||
);
|
||||
|
||||
const parsedCommands = this.extensionCommandsParser?.(method, params);
|
||||
if (parsedCommands) {
|
||||
this.applyResolvedCommands(parsedCommands, {
|
||||
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cache an asynchronously-delivered slash-command batch and unblock any
|
||||
// listCommands() call that is waiting on the initial batch. Used when a
|
||||
// provider supplies an extensionCommandsParser whose result arrives after
|
||||
// session/new (e.g. via a vendor extension notification). The ready gate is
|
||||
// always settled — even for an empty batch — so a provider that legitimately
|
||||
// reports no commands does not leave listCommands() blocked for the full
|
||||
// initial-commands timeout. An optional sessionId scopes the batch to this
|
||||
// session; notifications addressed to a different session are ignored.
|
||||
private applyResolvedCommands(
|
||||
commands: AgentSlashCommand[],
|
||||
options?: { sessionId?: string },
|
||||
): void {
|
||||
if (
|
||||
options?.sessionId !== undefined &&
|
||||
this.sessionId !== null &&
|
||||
options.sessionId !== this.sessionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
this.cachedCommands = commands;
|
||||
}
|
||||
this.settleCommandsReady();
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {
|
||||
|
||||
@@ -416,6 +416,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6[1m]",
|
||||
@@ -457,6 +458,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
|
||||
expect(getThinkingIds("claude-sonnet-5")).not.toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-7")).not.toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
|
||||
} finally {
|
||||
@@ -1889,6 +1892,45 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("selected native-1M Claude models seed active context window usage with 1M max tokens", async () => {
|
||||
const session = await createSessionForTurns(
|
||||
[
|
||||
[
|
||||
createInitMessage(),
|
||||
createMessageStartEvent(),
|
||||
createSuccessResult({ modelUsage: undefined }),
|
||||
],
|
||||
],
|
||||
{ model: "claude-sonnet-5" },
|
||||
);
|
||||
|
||||
try {
|
||||
const events = await collectStreamEvents(session);
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
contextWindowUsedTokens: 150,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: expect.objectContaining({
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("message_delta stream events update per-request usage", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
mapTaskNotificationSystemRecordToToolCall,
|
||||
mapTaskNotificationUserContentToToolCall,
|
||||
} from "./task-notification-tool-call.js";
|
||||
import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
import {
|
||||
findClaudeModel,
|
||||
getClaudeModelsWithSettings,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
} from "./models.js";
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
@@ -1626,23 +1630,6 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
|
||||
return maxContextWindow;
|
||||
}
|
||||
|
||||
function resolveInitialContextWindowSize(modelId: string | null | undefined): number | undefined {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim().toLowerCase() : "";
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.includes("[1m]") || normalized.includes("context-1m")) {
|
||||
return 1_000_000;
|
||||
}
|
||||
if (normalized.includes("claude-fable-5")) {
|
||||
return 1_000_000;
|
||||
}
|
||||
if (/(?:^|[~/_-])(?:claude[-_ ]*)?(opus|sonnet|haiku)(?:$|[-_ ./])/.test(normalized)) {
|
||||
return 200_000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
|
||||
const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage);
|
||||
if (!messageUsage) {
|
||||
@@ -1941,7 +1928,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.queryFactory = options.queryFactory;
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
this.contextUsage = new ClaudeContextUsageState(
|
||||
resolveInitialContextWindowSize(this.config.model),
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
);
|
||||
const handle = options.handle;
|
||||
|
||||
@@ -2187,7 +2174,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await this.applyFastModeFeature(false, activeQuery);
|
||||
}
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||
resolveInitialContextWindowSize(this.config.model),
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
);
|
||||
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
||||
this.lastRuntimeModel = null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
|
||||
const createdClaudeConfigDirs: string[] = [];
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("getClaudeModels", () => {
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6[1m]",
|
||||
@@ -55,6 +56,22 @@ describe("getClaudeModels", () => {
|
||||
expect(defaults[0].id).toBe("claude-opus-4-8");
|
||||
});
|
||||
|
||||
it("keeps fixed context windows on the model entries", () => {
|
||||
const modelsById = new Map(getClaudeModels().map((model) => [model.id, model]));
|
||||
|
||||
expect(modelsById.get("claude-fable-5")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
expect(modelsById.get("claude-opus-4-8[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
expect(modelsById.get("claude-opus-4-8")?.contextWindowMaxTokens).toBe(200_000);
|
||||
expect(modelsById.get("claude-sonnet-5")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
expect(modelsById.get("claude-sonnet-4-6")?.contextWindowMaxTokens).toBe(200_000);
|
||||
expect(modelsById.get("claude-haiku-4-5")?.contextWindowMaxTokens).toBe(200_000);
|
||||
});
|
||||
|
||||
it("finds models through the runtime normalizer", () => {
|
||||
expect(findClaudeModel("claude-sonnet-5-20260101")?.id).toBe("claude-sonnet-5");
|
||||
expect(findClaudeModel("claude-sonnet-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("returns fresh copies each call", () => {
|
||||
const a = getClaudeModels();
|
||||
const b = getClaudeModels();
|
||||
@@ -203,12 +220,14 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("normalizes dated model IDs to base model", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101")).toBe("claude-sonnet-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
||||
@@ -218,6 +237,10 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
});
|
||||
|
||||
it("strips [1m] suffix for natively-1M models", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5");
|
||||
});
|
||||
|
||||
it("returns null for empty/null/undefined", () => {
|
||||
expect(normalizeClaudeRuntimeModelId(null)).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId(undefined)).toBeNull();
|
||||
|
||||
@@ -12,7 +12,7 @@ const CLAUDE_THINKING_OPTIONS = [
|
||||
{ id: "max", label: "Max" },
|
||||
] as const;
|
||||
|
||||
const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
|
||||
const CLAUDE_EXTENDED_THINKING_OPTIONS = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
@@ -21,7 +21,7 @@ const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
const CLAUDE_ULTRACODE_THINKING_OPTIONS = [
|
||||
...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS,
|
||||
...CLAUDE_EXTENDED_THINKING_OPTIONS,
|
||||
{ id: "ultracode", label: "Ultracode" },
|
||||
] as const;
|
||||
|
||||
@@ -31,6 +31,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
id: "claude-fable-5",
|
||||
label: "Fable 5",
|
||||
description: "Fable 5 · Most powerful model",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
@@ -38,6 +39,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
id: "claude-opus-4-8[1m]",
|
||||
label: "Opus 4.8 1M",
|
||||
description: "Opus 4.8 with 1M context window",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
@@ -46,27 +48,39 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
label: "Opus 4.8",
|
||||
description: "Opus 4.8 · Latest release",
|
||||
isDefault: true,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-5",
|
||||
label: "Sonnet 5",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-7[1m]",
|
||||
label: "Opus 4.7 1M",
|
||||
description: "Opus 4.7 with 1M context window",
|
||||
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-7",
|
||||
label: "Opus 4.7",
|
||||
description: "Opus 4.7 · Previous release",
|
||||
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
|
||||
contextWindowMaxTokens: 200_000,
|
||||
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6[1m]",
|
||||
label: "Opus 4.6 1M",
|
||||
description: "Opus 4.6 with 1M context window",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
@@ -74,6 +88,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
@@ -81,6 +96,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
id: "claude-sonnet-4-6[1m]",
|
||||
label: "Sonnet 4.6 1M",
|
||||
description: "Sonnet 4.6 with 1M context window",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
@@ -88,6 +104,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Sonnet 4.6",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
@@ -95,6 +112,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -110,6 +128,14 @@ export function getClaudeModels(): AgentModelDefinition[] {
|
||||
return CLAUDE_MODELS.map((model) => ({ ...model }));
|
||||
}
|
||||
|
||||
export function findClaudeModel(modelId: string | null | undefined): AgentModelDefinition | null {
|
||||
const normalizedModelId = normalizeClaudeRuntimeModelId(modelId);
|
||||
if (!normalizedModelId) {
|
||||
return null;
|
||||
}
|
||||
return CLAUDE_MODELS.find((model) => model.id === normalizedModelId) ?? null;
|
||||
}
|
||||
|
||||
export async function getClaudeModelsWithSettings(
|
||||
logger: Logger,
|
||||
configDir?: string,
|
||||
@@ -218,13 +244,17 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Fable uses a single-segment version (claude-fable-5), not the {major}-{minor}
|
||||
// scheme of opus/sonnet/haiku, so match it separately. This maps dated runtime
|
||||
// strings (e.g. claude-fable-5-20260301) back to the catalog ID. No [1m] variant:
|
||||
// Fable 5 is natively 1M, so there is no 200K-default model to opt into 1M.
|
||||
const fableMatch = trimmed.match(/(?:claude-)?fable[-_ ]+(\d+)/i);
|
||||
if (fableMatch) {
|
||||
return `claude-fable-${fableMatch[1]}`;
|
||||
// Some new Claude model families use single-segment versions, not the
|
||||
// {major}-{minor} scheme of older opus/sonnet/haiku models. Map dated or
|
||||
// suffixed runtime strings back to the matching catalog ID.
|
||||
const singleSegmentMatch = trimmed.match(/(?:claude-)?(fable|sonnet)[-_ ]+(\d+)/i);
|
||||
if (singleSegmentMatch) {
|
||||
const family = singleSegmentMatch[1].toLowerCase();
|
||||
const major = singleSegmentMatch[2];
|
||||
const modelId = `claude-${family}-${major}`;
|
||||
if (CLAUDE_MODELS.some((model) => model.id === modelId)) {
|
||||
return modelId;
|
||||
}
|
||||
}
|
||||
|
||||
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix
|
||||
|
||||
@@ -3,7 +3,11 @@ import { z } from "zod";
|
||||
|
||||
import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
|
||||
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
DEFAULT_ACP_CAPABILITIES,
|
||||
type ACPExtensionCommandsParser,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
formatProviderDiagnostic,
|
||||
@@ -29,6 +33,7 @@ interface GenericACPAgentClientOptions {
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
diagnosticPhaseTimeoutMs?: number;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
}
|
||||
|
||||
export class GenericACPAgentClient extends ACPAgentClient {
|
||||
@@ -48,6 +53,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
capabilities: buildGenericACPCapabilities(options),
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
extensionCommandsParser: options.extensionCommandsParser,
|
||||
});
|
||||
|
||||
this.command = options.command;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ACPExtensionCommandsParser } from "./acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
import type { AgentSlashCommand, AgentSlashCommandKind } from "../agent-sdk-types.js";
|
||||
|
||||
interface KiroACPAgentClientOptions {
|
||||
logger: Logger;
|
||||
command: [string, ...string[]];
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
}
|
||||
|
||||
// Kiro CLI publishes its slash commands and skills asynchronously through the
|
||||
// `_kiro.dev/commands/available` extension notification shortly after
|
||||
// `session/new` resolves. Wait for that first batch so listCommands() doesn't
|
||||
// resolve to an empty list before Kiro has reported its commands.
|
||||
const KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ACP extension method (per the `_`-prefixed vendor namespace convention) that
|
||||
// Kiro CLI uses to publish its slash commands and skills after session/new.
|
||||
const KIRO_COMMANDS_AVAILABLE_METHOD = "_kiro.dev/commands/available";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Maps a `_kiro.dev/commands/available` payload onto Paseo slash commands.
|
||||
// Kiro reports built-in slash commands under `commands` (names arrive with a
|
||||
// leading "/", e.g. "/agent") and skills/prompts under `prompts` (names without
|
||||
// a slash, tagged with a `skill:` serverName). Paseo stores command names
|
||||
// without the leading slash — the composer prepends it on insertion.
|
||||
function mapKiroAvailableCommands(params: Record<string, unknown>): AgentSlashCommand[] {
|
||||
const result: AgentSlashCommand[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pushEntry = (entry: unknown): void => {
|
||||
if (!isRecord(entry)) {
|
||||
return;
|
||||
}
|
||||
const rawName = typeof entry.name === "string" ? entry.name.trim() : "";
|
||||
const name = rawName.replace(/^\/+/, "");
|
||||
if (!name || seen.has(name)) {
|
||||
return;
|
||||
}
|
||||
seen.add(name);
|
||||
|
||||
const description = typeof entry.description === "string" ? entry.description : "";
|
||||
const meta = isRecord(entry.meta) ? entry.meta : null;
|
||||
const argumentHint = meta && typeof meta.hint === "string" ? meta.hint : "";
|
||||
const serverName = typeof entry.serverName === "string" ? entry.serverName : "";
|
||||
const kind: AgentSlashCommandKind = serverName.startsWith("skill:") ? "skill" : "command";
|
||||
|
||||
result.push({ name, description, argumentHint, kind });
|
||||
};
|
||||
|
||||
if (Array.isArray(params.commands)) {
|
||||
for (const entry of params.commands) {
|
||||
pushEntry(entry);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(params.prompts)) {
|
||||
for (const entry of params.prompts) {
|
||||
pushEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Provider-specific parser injected into the generic ACP session via the
|
||||
// `extensionCommandsParser` option (mirrors how Cursor/Copilot inject their
|
||||
// behavior through constructor options). Kiro advertises its slash commands and
|
||||
// skills through the `_kiro.dev/commands/available` extension notification
|
||||
// instead of the standard `available_commands_update` session update; this
|
||||
// recognizes that one method and returns the parsed commands (possibly empty),
|
||||
// or null for any other notification so the base session ignores it.
|
||||
export const parseKiroExtensionCommands: ACPExtensionCommandsParser = (method, params) => {
|
||||
if (method !== KIRO_COMMANDS_AVAILABLE_METHOD) {
|
||||
return null;
|
||||
}
|
||||
return mapKiroAvailableCommands(params);
|
||||
};
|
||||
|
||||
export class KiroACPAgentClient extends GenericACPAgentClient {
|
||||
constructor(options: KiroACPAgentClientOptions) {
|
||||
super({
|
||||
logger: options.logger,
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerId: options.providerId,
|
||||
label: options.label,
|
||||
providerParams: options.providerParams,
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
|
||||
extensionCommandsParser: parseKiroExtensionCommands,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describe("paseo daemon bootstrap", () => {
|
||||
|
||||
test("starts and serves health endpoint", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
openai: { apiKey: "test-openai-api-key" },
|
||||
openai: { stt: { apiKey: "test-openai-api-key" }, tts: { apiKey: "test-openai-api-key" } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
@@ -547,7 +547,9 @@ beforeAll(async () => {
|
||||
|
||||
ctx = await createDaemonTestContext({
|
||||
dictationFinalTimeoutMs: 5000,
|
||||
...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}),
|
||||
...(openaiApiKey
|
||||
? { openai: { stt: { apiKey: openaiApiKey }, tts: { apiKey: openaiApiKey } } }
|
||||
: {}),
|
||||
...(speechConfig ? { speech: speechConfig } : {}),
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
@@ -116,20 +116,26 @@ describe("PersistedConfigSchema worktrees config", () => {
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema provider credentials", () => {
|
||||
test("accepts OpenAI voice credentials", () => {
|
||||
test("accepts separate OpenAI STT and TTS credentials", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
voice: {
|
||||
apiKey: " voice-secret ",
|
||||
baseUrl: " https://voice.example.com/v1 ",
|
||||
stt: {
|
||||
apiKey: " stt-secret ",
|
||||
baseUrl: " https://stt.example.com/v1 ",
|
||||
},
|
||||
tts: {
|
||||
apiKey: " tts-secret ",
|
||||
baseUrl: " https://tts.example.com/v1 ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.providers?.openai?.voice?.apiKey).toBe("voice-secret");
|
||||
expect(parsed.providers?.openai?.voice?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret");
|
||||
expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1");
|
||||
expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret");
|
||||
expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -650,6 +656,38 @@ describe("loadPersistedConfig", () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads a config that still uses the removed providers.openai.voice block", () => {
|
||||
const home = createTempHome();
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "global-key",
|
||||
voice: { apiKey: "voice-key", baseUrl: "https://voice.example.com/v1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const config = loadPersistedConfig(home);
|
||||
|
||||
expect(config.providers?.openai?.apiKey).toBe("global-key");
|
||||
expect((config.providers?.openai as Record<string, unknown>)?.voice).toBeUndefined();
|
||||
expect(config.providers?.openai?.stt).toBeUndefined();
|
||||
expect(config.providers?.openai?.tts).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ const LogConfigSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const OpenAiVoiceProviderSchema = z
|
||||
const OpenAiSpeechEndpointSchema = z
|
||||
.object({
|
||||
apiKey: z.string().trim().min(1).optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
@@ -55,8 +55,9 @@ const OpenAiVoiceProviderSchema = z
|
||||
const OpenAiProviderSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(1).optional(),
|
||||
voice: OpenAiVoiceProviderSchema.optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
stt: OpenAiSpeechEndpointSchema.optional(),
|
||||
tts: OpenAiSpeechEndpointSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -346,7 +347,11 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
|
||||
return logger?.child({ module: "config" });
|
||||
}
|
||||
|
||||
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
|
||||
// Removed config fields are stripped before parsing so the strict schema does not
|
||||
// reject a config written by an older release. The stripped values are discarded,
|
||||
// not migrated — there is no back-compat for the removed `providers.openai.voice`
|
||||
// block (use `providers.openai.stt` / `providers.openai.tts`).
|
||||
function stripRemovedConfigFields(parsed: unknown): unknown {
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -358,18 +363,25 @@ function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
|
||||
}
|
||||
|
||||
const providersRecord = { ...(providers as Record<string, unknown>) };
|
||||
|
||||
const local = providersRecord.local;
|
||||
if (!local || typeof local !== "object" || Array.isArray(local)) {
|
||||
root.providers = providersRecord;
|
||||
return root;
|
||||
}
|
||||
|
||||
const localRecord = { ...(local as Record<string, unknown>) };
|
||||
if ("autoDownload" in localRecord) {
|
||||
if (local && typeof local === "object" && !Array.isArray(local)) {
|
||||
const localRecord = { ...(local as Record<string, unknown>) };
|
||||
delete localRecord.autoDownload;
|
||||
providersRecord.local = localRecord;
|
||||
}
|
||||
|
||||
const openai = providersRecord.openai;
|
||||
if (openai && typeof openai === "object" && !Array.isArray(openai)) {
|
||||
const openaiRecord = { ...(openai as Record<string, unknown>) };
|
||||
// COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30.
|
||||
// Drop a `providers.openai.voice` block left by an older release so the strict
|
||||
// schema doesn't reject it. The value is discarded, not migrated — there is no
|
||||
// back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead.
|
||||
delete openaiRecord.voice;
|
||||
providersRecord.openai = openaiRecord;
|
||||
}
|
||||
|
||||
providersRecord.local = localRecord;
|
||||
root.providers = providersRecord;
|
||||
return root;
|
||||
}
|
||||
@@ -412,7 +424,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
|
||||
});
|
||||
}
|
||||
|
||||
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
|
||||
const migrated = stripRemovedConfigFields(parsed);
|
||||
const result = PersistedConfigSchema.safeParse(migrated);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
|
||||
@@ -2,6 +2,14 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PersistedConfigSchema } from "../../../persisted-config.js";
|
||||
import { resolveOpenAiSpeechConfig } from "./config.js";
|
||||
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||
|
||||
const ALL_OPENAI: RequestedSpeechProviders = {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceTurnDetection: { provider: "local", explicit: false },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
};
|
||||
|
||||
describe("resolveOpenAiSpeechConfig", () => {
|
||||
test("treats empty OPENAI_API_KEY as unset", () => {
|
||||
@@ -14,6 +22,7 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
...ALL_OPENAI,
|
||||
dictationStt: { provider: "local", explicit: false },
|
||||
voiceStt: { provider: "local", explicit: false },
|
||||
voiceTts: { provider: "local", explicit: false },
|
||||
@@ -23,138 +32,175 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
expect(resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uses trimmed OPENAI_API_KEY when configured", () => {
|
||||
test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: " sk-test ",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.apiKey).toBe("sk-test");
|
||||
expect(resolved?.stt?.apiKey).toBe("sk-test");
|
||||
expect(resolved?.tts?.apiKey).toBe("sk-test");
|
||||
});
|
||||
|
||||
test("uses nested voice config before env and non-voice fallbacks", () => {
|
||||
test("resolves distinct endpoints for STT and TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
voice: {
|
||||
apiKey: "voice-config-key",
|
||||
baseUrl: " https://voice.example.com/v1 ",
|
||||
stt: {
|
||||
apiKey: "stt-key",
|
||||
baseUrl: " https://stt.example.com/v1 ",
|
||||
},
|
||||
tts: {
|
||||
apiKey: "tts-key",
|
||||
baseUrl: " https://tts.example.com/v1 ",
|
||||
},
|
||||
baseUrl: "https://legacy-config.example.com/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_VOICE_API_KEY: "voice-env-key",
|
||||
OPENAI_VOICE_BASE_URL: "https://voice-env.example.com/v1",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.stt?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("tts-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
|
||||
});
|
||||
|
||||
test("uses voice env config when nested voice config is unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_VOICE_API_KEY: "voice-env-key",
|
||||
OPENAI_VOICE_BASE_URL: " https://voice-env.example.com/v1 ",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.stt?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.tts?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to non-voice OpenAI config", () => {
|
||||
test("prefers nested STT/TTS config over env and global fallbacks", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
baseUrl: " https://legacy-config.example.com/v1 ",
|
||||
baseUrl: "https://global-config.example.com/v1",
|
||||
stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " },
|
||||
tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " },
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_BASE_URL: " https://env.example.com/v1 ",
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_STT_API_KEY: "stt-env-key",
|
||||
OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1",
|
||||
OPENAI_TTS_API_KEY: "tts-env-key",
|
||||
OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.apiKey).toBe("fallback-config-key");
|
||||
expect(resolved?.baseUrl).toBe("https://legacy-config.example.com/v1");
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-config-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("tts-config-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to global OpenAI env config when voice-specific inputs are unset", () => {
|
||||
test("uses STT/TTS env config when nested config is unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_STT_API_KEY: "stt-env-key",
|
||||
OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ",
|
||||
OPENAI_TTS_API_KEY: "tts-env-key",
|
||||
OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-env-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("tts-env-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to global OpenAI config for both STT and TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
baseUrl: " https://global-config.example.com/v1 ",
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("fallback-config-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("fallback-config-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to global OpenAI env config when feature inputs are unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_BASE_URL: " https://env.example.com/v1 ",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("env-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("env-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1");
|
||||
});
|
||||
|
||||
test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "global-key",
|
||||
OPENAI_STT_API_KEY: "",
|
||||
OPENAI_STT_BASE_URL: " ",
|
||||
OPENAI_TTS_API_KEY: "",
|
||||
OPENAI_TTS_BASE_URL: "",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("global-key");
|
||||
expect(resolved?.tts?.apiKey).toBe("global-key");
|
||||
});
|
||||
|
||||
test("omits TTS when only an STT key is configured", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
openai: {
|
||||
stt: { apiKey: "stt-only-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("env-key");
|
||||
expect(resolved?.baseUrl).toBe("https://env.example.com/v1");
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||
expect(resolved?.tts).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves STT even when an unused TTS env var is invalid", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
stt: { apiKey: "stt-only-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||
expect(resolved?.tts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { TTSConfig } from "./tts.js";
|
||||
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
|
||||
|
||||
export interface OpenAiSpeechProviderConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
stt?: Partial<STTConfig> & { apiKey?: string };
|
||||
tts?: Partial<TTSConfig> & { apiKey?: string };
|
||||
}
|
||||
@@ -30,11 +28,23 @@ const OptionalTrimmedStringSchema = z
|
||||
.optional()
|
||||
.transform((value) => (value && value.length > 0 ? value : undefined));
|
||||
|
||||
const OpenAiSpeechResolutionSchema = z.object({
|
||||
apiKey: OptionalTrimmedStringSchema,
|
||||
baseUrl: OptionalTrimmedStringSchema,
|
||||
// Endpoint credentials only — plain trimmed strings, so this never throws on a
|
||||
// malformed value. The STT/TTS option groups parse separately and only for the
|
||||
// endpoint that is actually configured, so a stale env var for an unused endpoint
|
||||
// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one.
|
||||
const OpenAiEndpointKeysSchema = z.object({
|
||||
sttApiKey: OptionalTrimmedStringSchema,
|
||||
sttBaseUrl: OptionalTrimmedStringSchema,
|
||||
ttsApiKey: OptionalTrimmedStringSchema,
|
||||
ttsBaseUrl: OptionalTrimmedStringSchema,
|
||||
});
|
||||
|
||||
const OpenAiSttOptionsSchema = z.object({
|
||||
sttConfidenceThreshold: OptionalFiniteNumberSchema,
|
||||
sttModel: OptionalTrimmedStringSchema,
|
||||
});
|
||||
|
||||
const OpenAiTtsOptionsSchema = z.object({
|
||||
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
|
||||
ttsModel: z
|
||||
.string()
|
||||
@@ -57,9 +67,15 @@ function pickIfOpenAi<T>(
|
||||
|
||||
function firstDefined<T>(values: Array<T | null | undefined>): T | undefined {
|
||||
for (const value of values) {
|
||||
if (value !== undefined && value !== null) {
|
||||
return value;
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
// Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=)
|
||||
// must not shadow a later fallback such as OPENAI_API_KEY.
|
||||
if (typeof value === "string" && value.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -108,18 +124,32 @@ function buildOpenAiResolutionInput(params: {
|
||||
persisted: PersistedConfig;
|
||||
providers: RequestedSpeechProviders;
|
||||
}): Record<string, unknown> {
|
||||
const { env } = params;
|
||||
const openai = params.persisted.providers?.openai;
|
||||
return {
|
||||
apiKey: firstDefined<string>([
|
||||
params.persisted.providers?.openai?.voice?.apiKey,
|
||||
params.env.OPENAI_VOICE_API_KEY,
|
||||
params.persisted.providers?.openai?.apiKey,
|
||||
params.env.OPENAI_API_KEY,
|
||||
sttApiKey: firstDefined<string>([
|
||||
openai?.stt?.apiKey,
|
||||
env.OPENAI_STT_API_KEY,
|
||||
openai?.apiKey,
|
||||
env.OPENAI_API_KEY,
|
||||
]),
|
||||
baseUrl: firstDefined<string>([
|
||||
params.persisted.providers?.openai?.voice?.baseUrl,
|
||||
params.env.OPENAI_VOICE_BASE_URL,
|
||||
params.persisted.providers?.openai?.baseUrl,
|
||||
params.env.OPENAI_BASE_URL,
|
||||
sttBaseUrl: firstDefined<string>([
|
||||
openai?.stt?.baseUrl,
|
||||
env.OPENAI_STT_BASE_URL,
|
||||
openai?.baseUrl,
|
||||
env.OPENAI_BASE_URL,
|
||||
]),
|
||||
ttsApiKey: firstDefined<string>([
|
||||
openai?.tts?.apiKey,
|
||||
env.OPENAI_TTS_API_KEY,
|
||||
openai?.apiKey,
|
||||
env.OPENAI_API_KEY,
|
||||
]),
|
||||
ttsBaseUrl: firstDefined<string>([
|
||||
openai?.tts?.baseUrl,
|
||||
env.OPENAI_TTS_BASE_URL,
|
||||
openai?.baseUrl,
|
||||
env.OPENAI_BASE_URL,
|
||||
]),
|
||||
...buildOpenAiSttInput(params),
|
||||
...buildOpenAiTtsInput(params),
|
||||
@@ -131,29 +161,46 @@ export function resolveOpenAiSpeechConfig(params: {
|
||||
persisted: PersistedConfig;
|
||||
providers: RequestedSpeechProviders;
|
||||
}): OpenAiSpeechProviderConfig | undefined {
|
||||
const parsed = OpenAiSpeechResolutionSchema.parse(buildOpenAiResolutionInput(params));
|
||||
const input = buildOpenAiResolutionInput(params);
|
||||
const keys = OpenAiEndpointKeysSchema.parse(input);
|
||||
|
||||
if (!parsed.apiKey) {
|
||||
if (!keys.sttApiKey && !keys.ttsApiKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
stt: {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
...(parsed.sttConfidenceThreshold !== undefined
|
||||
? { confidenceThreshold: parsed.sttConfidenceThreshold }
|
||||
: {}),
|
||||
...(parsed.sttModel ? { model: parsed.sttModel } : {}),
|
||||
},
|
||||
tts: {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
voice: parsed.ttsVoice,
|
||||
model: parsed.ttsModel,
|
||||
responseFormat: "pcm",
|
||||
},
|
||||
...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}),
|
||||
...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSttConfig(
|
||||
apiKey: string,
|
||||
baseUrl: string | undefined,
|
||||
input: Record<string, unknown>,
|
||||
): OpenAiSpeechProviderConfig["stt"] {
|
||||
const options = OpenAiSttOptionsSchema.parse(input);
|
||||
return {
|
||||
apiKey,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(options.sttConfidenceThreshold !== undefined
|
||||
? { confidenceThreshold: options.sttConfidenceThreshold }
|
||||
: {}),
|
||||
...(options.sttModel ? { model: options.sttModel } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTtsConfig(
|
||||
apiKey: string,
|
||||
baseUrl: string | undefined,
|
||||
input: Record<string, unknown>,
|
||||
): OpenAiSpeechProviderConfig["tts"] {
|
||||
const options = OpenAiTtsOptionsSchema.parse(input);
|
||||
return {
|
||||
apiKey,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
voice: options.ttsVoice,
|
||||
model: options.ttsModel,
|
||||
responseFormat: "pcm",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ describe("initializeOpenAiSpeechServices", () => {
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
openaiConfig: {
|
||||
apiKey: "sk-test",
|
||||
stt: { apiKey: "sk-test" },
|
||||
tts: { apiKey: "sk-test" },
|
||||
},
|
||||
|
||||
@@ -29,11 +29,11 @@ export interface SpeechServices {
|
||||
function resolveOpenAiCredentials(
|
||||
openaiConfig: OpenAiSpeechProviderConfig | undefined,
|
||||
): OpenAiCredentialState {
|
||||
const openaiApiKey = openaiConfig?.apiKey;
|
||||
const sttApiKey = openaiConfig?.stt?.apiKey;
|
||||
return {
|
||||
openaiSttApiKey: openaiConfig?.stt?.apiKey ?? openaiApiKey,
|
||||
openaiTtsApiKey: openaiConfig?.tts?.apiKey ?? openaiApiKey,
|
||||
openaiDictationApiKey: openaiApiKey,
|
||||
openaiSttApiKey: sttApiKey,
|
||||
openaiTtsApiKey: openaiConfig?.tts?.apiKey,
|
||||
openaiDictationApiKey: sttApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,8 @@ describe("resolveSpeechConfig", () => {
|
||||
dictation: "es",
|
||||
voice: "pt",
|
||||
});
|
||||
expect(result.openai?.apiKey).toBe("persisted-key");
|
||||
expect(result.openai?.stt?.apiKey).toBe("persisted-key");
|
||||
expect(result.openai?.tts?.apiKey).toBe("persisted-key");
|
||||
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ function waitForSignal<T>(
|
||||
beforeAll(async () => {
|
||||
ctx = await createDaemonTestContext({
|
||||
agentClients: {},
|
||||
openai: { apiKey: openaiApiKey! },
|
||||
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
@@ -161,7 +161,7 @@ let ctx: DaemonTestContext;
|
||||
beforeAll(async () => {
|
||||
ctx = await createDaemonTestContext({
|
||||
agentClients: {},
|
||||
openai: { apiKey: openaiApiKey! },
|
||||
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -174,6 +174,38 @@
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"stt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"tts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -204,6 +204,8 @@ In the mobile app, enter the password in the direct connection setup screen.
|
||||
- `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles`
|
||||
- `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported)
|
||||
- `OPENAI_API_KEY`, override OpenAI provider key
|
||||
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
|
||||
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
|
||||
- `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`)
|
||||
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`)
|
||||
- `PASEO_LOCAL_MODELS_DIR`, control local model directory
|
||||
|
||||
@@ -90,7 +90,11 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"stt": {
|
||||
"apiKey": "...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -99,7 +103,7 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` configure only Paseo OpenAI voice traffic, without changing Codex or other OpenAI-backed tools.
|
||||
`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
@@ -111,6 +115,8 @@ Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override
|
||||
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`)
|
||||
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
|
||||
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
|
||||
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
|
||||
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
|
||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
|
||||
|
||||
Reference in New Issue
Block a user