Merge remote-tracking branch 'origin/main' into dev
# Conflicts: # nix/package.nix # packages/app/src/components/adaptive-modal-sheet.tsx # packages/app/src/hooks/use-providers-snapshot.ts # packages/app/src/screens/workspace/workspace-screen.tsx # packages/server/src/server/agent/agent-management-mcp.ts # packages/server/src/server/agent/mcp-server.ts # packages/server/src/server/persistence-hooks.ts # packages/server/src/server/schedule/service.ts # packages/server/src/server/session.ts
This commit is contained in:
@@ -107,6 +107,7 @@ Required fields for custom providers:
|
|||||||
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
|
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
|
||||||
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
|
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
|
||||||
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
|
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
|
||||||
|
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||||
- Automated setup is also available: `npx @z_ai/coding-helper`
|
- Automated setup is also available: `npx @z_ai/coding-helper`
|
||||||
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
|||||||
|
|
||||||
- API keys must be created in the **Singapore region**
|
- API keys must be created in the **Singapore region**
|
||||||
- The coding plan is for personal use only in interactive coding tools
|
- The coding plan is for personal use only in interactive coding tools
|
||||||
|
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||||
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -436,6 +438,7 @@ Every entry under `agents.providers` accepts these fields:
|
|||||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||||
|
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||||
| `order` | `number` | No | Sort order in the provider list |
|
| `order` | `number` | No | Sort order in the provider list |
|
||||||
|
|
||||||
@@ -460,6 +463,29 @@ Each entry in the `models` array:
|
|||||||
| `description` | `string` | No | Short description |
|
| `description` | `string` | No | Short description |
|
||||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||||
|
|
||||||
|
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||||
|
|
||||||
|
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||||
|
|
||||||
|
Use `disallowedTools` to disable unsupported tools:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"providers": {
|
||||||
|
"my-proxy": {
|
||||||
|
"extends": "claude",
|
||||||
|
"label": "My Proxy",
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||||
|
},
|
||||||
|
"disallowedTools": ["WebSearch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Valid `extends` values
|
### Valid `extends` values
|
||||||
|
|
||||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||||
|
|||||||
@@ -139,6 +139,33 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
|||||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||||
|
|
||||||
|
## Changelog attribution
|
||||||
|
|
||||||
|
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
|
||||||
|
|
||||||
|
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
|
||||||
|
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
|
||||||
|
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
|
||||||
|
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
|
||||||
|
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
|
||||||
|
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
|
||||||
|
|
||||||
|
## Changelog ordering
|
||||||
|
|
||||||
|
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||||
|
|
||||||
|
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
|
||||||
|
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
|
||||||
|
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
|
||||||
|
|
||||||
## Pre-release sanity check
|
## Pre-release sanity check
|
||||||
|
|
||||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-hsN56kvNxPEDY06JFh0sKCVlEnHXYHPpM0DbH/oZvz0=";
|
npmDepsHash = "sha256-UrTPJVju3jiSlkUMfh25rWobIo9hWNVFoVewFGxRQC0=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -34854,16 +34854,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.56",
|
"@getpaseo/expo-two-way-audio": "0.1.57-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.56",
|
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||||
"@getpaseo/server": "0.1.56",
|
"@getpaseo/server": "0.1.57-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -35004,11 +35004,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.56",
|
"@getpaseo/relay": "0.1.57-rc.1",
|
||||||
"@getpaseo/server": "0.1.56",
|
"@getpaseo/server": "0.1.57-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -35049,11 +35049,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.56",
|
"@getpaseo/cli": "0.1.57-rc.1",
|
||||||
"@getpaseo/server": "0.1.56",
|
"@getpaseo/server": "0.1.57-rc.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -35087,7 +35087,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35288,7 +35288,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35314,7 +35314,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35330,14 +35330,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.56",
|
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||||
"@getpaseo/relay": "0.1.56",
|
"@getpaseo/relay": "0.1.57-rc.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -35765,7 +35765,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.56",
|
"@getpaseo/expo-two-way-audio": "0.1.57-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.56",
|
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||||
"@getpaseo/server": "0.1.56",
|
"@getpaseo/server": "0.1.57-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -51,6 +51,17 @@
|
|||||||
[contenteditable='true'] {
|
[contenteditable='true'] {
|
||||||
-webkit-app-region: no-drag !important;
|
-webkit-app-region: no-drag !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Suppress the browser's default focus outline — it appears on mouse
|
||||||
|
clicks and looks out of place against our themed surfaces. Keep a
|
||||||
|
themed ring for keyboard users via :focus-visible. */
|
||||||
|
*:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid #20744A;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ import { FileDropZone } from "@/components/file-drop-zone";
|
|||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
type EscHandler = () => void;
|
||||||
|
const escStack: EscHandler[] = [];
|
||||||
|
let escListenerAttached = false;
|
||||||
|
|
||||||
|
function handleEscKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
const top = escStack[escStack.length - 1];
|
||||||
|
if (!top) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
top();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushEscHandler(handler: EscHandler): () => void {
|
||||||
|
escStack.push(handler);
|
||||||
|
if (!escListenerAttached && typeof window !== "undefined") {
|
||||||
|
window.addEventListener("keydown", handleEscKeyDown, true);
|
||||||
|
escListenerAttached = true;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
const index = escStack.lastIndexOf(handler);
|
||||||
|
if (index !== -1) escStack.splice(index, 1);
|
||||||
|
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("keydown", handleEscKeyDown, true);
|
||||||
|
escListenerAttached = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
@@ -55,10 +84,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
|
flex: 1,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginLeft: theme.spacing[3],
|
||||||
|
marginRight: theme.spacing[2],
|
||||||
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
padding: theme.spacing[2],
|
padding: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
@@ -91,6 +128,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
bottomSheetStaticContent: {
|
||||||
|
flex: 1,
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
desktopStaticContent: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
@@ -116,6 +165,7 @@ export interface AdaptiveModalSheetProps {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
headerActions?: ReactNode;
|
||||||
snapPoints?: string[];
|
snapPoints?: string[];
|
||||||
stackBehavior?: "push" | "switch" | "replace";
|
stackBehavior?: "push" | "switch" | "replace";
|
||||||
testID?: string;
|
testID?: string;
|
||||||
@@ -123,6 +173,7 @@ export interface AdaptiveModalSheetProps {
|
|||||||
desktopMaxWidth?: number;
|
desktopMaxWidth?: number;
|
||||||
/** When provided, wraps the card content in a FileDropZone. */
|
/** When provided, wraps the card content in a FileDropZone. */
|
||||||
onFilesDropped?: (files: ImageAttachment[]) => void;
|
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||||
|
scrollable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdaptiveModalSheet({
|
export function AdaptiveModalSheet({
|
||||||
@@ -131,11 +182,13 @@ export function AdaptiveModalSheet({
|
|||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
|
headerActions,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
stackBehavior,
|
stackBehavior,
|
||||||
testID,
|
testID,
|
||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
onFilesDropped,
|
onFilesDropped,
|
||||||
|
scrollable = true,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
@@ -143,19 +196,6 @@ export function AdaptiveModalSheet({
|
|||||||
const dismissingForVisibilityRef = useRef(false);
|
const dismissingForVisibilityRef = useRef(false);
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobile || !visible || !isWeb || typeof window === "undefined") return;
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Capture phase: RN Web TextInput stops propagation, so bubbling listeners never fire.
|
|
||||||
window.addEventListener("keydown", handleKeyDown, true);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
||||||
}, [isMobile, visible, onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -187,6 +227,11 @@ export function AdaptiveModalSheet({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || isMobile || !visible) return;
|
||||||
|
return pushEscHandler(onClose);
|
||||||
|
}, [visible, isMobile, onClose]);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -205,20 +250,27 @@ export function AdaptiveModalSheet({
|
|||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</View>
|
</View>
|
||||||
|
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<BottomSheetScrollView
|
{scrollable ? (
|
||||||
contentContainerStyle={styles.bottomSheetContent}
|
<BottomSheetScrollView
|
||||||
keyboardShouldPersistTaps="handled"
|
contentContainerStyle={styles.bottomSheetContent}
|
||||||
showsVerticalScrollIndicator={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{children}
|
>
|
||||||
</BottomSheetScrollView>
|
{children}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||||
|
)}
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,21 +279,28 @@ export function AdaptiveModalSheet({
|
|||||||
<>
|
<>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</View>
|
</View>
|
||||||
|
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
{scrollable ? (
|
||||||
style={styles.desktopScroll}
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopContent}
|
style={styles.desktopScroll}
|
||||||
keyboardShouldPersistTaps="handled"
|
contentContainerStyle={styles.desktopContent}
|
||||||
showsVerticalScrollIndicator={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{children}
|
>
|
||||||
</ScrollView>
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
|
|||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
|
||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
</View>
|
</View>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.contentScroll}
|
style={[styles.contentScroll, webScrollbarStyle]}
|
||||||
contentContainerStyle={styles.contentScrollContainer}
|
contentContainerStyle={styles.contentScrollContainer}
|
||||||
>
|
>
|
||||||
{artifact.type === "image" ? (
|
{artifact.type === "image" ? (
|
||||||
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.codeContainer}>
|
<View style={styles.codeContainer}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
style={webScrollbarStyle}
|
||||||
|
>
|
||||||
<Text style={styles.codeText}>{content}</Text>
|
<Text style={styles.codeText}>{content}</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function BranchSwitcher({
|
|||||||
|
|
||||||
const titleContent = (
|
const titleContent = (
|
||||||
<>
|
<>
|
||||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
interface DiffScrollProps {
|
interface DiffScrollProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -14,12 +15,14 @@ export function DiffScroll({
|
|||||||
style,
|
style,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
}: DiffScrollProps) {
|
}: DiffScrollProps) {
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
style={style}
|
style={[style, webScrollbarStyle]}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function DiffViewer({
|
|||||||
fillAvailableHeight = false,
|
fillAvailableHeight = false,
|
||||||
}: DiffViewerProps) {
|
}: DiffViewerProps) {
|
||||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
|
||||||
if (!diffLines.length) {
|
if (!diffLines.length) {
|
||||||
return (
|
return (
|
||||||
@@ -38,6 +40,7 @@ export function DiffViewer({
|
|||||||
styles.verticalScroll,
|
styles.verticalScroll,
|
||||||
maxHeight !== undefined && { maxHeight },
|
maxHeight !== undefined && { maxHeight },
|
||||||
fillAvailableHeight && styles.fillHeight,
|
fillAvailableHeight && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.verticalContent}
|
contentContainerStyle={styles.verticalContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -47,6 +50,7 @@ export function DiffViewer({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={styles.horizontalContent}
|
contentContainerStyle={styles.horizontalContent}
|
||||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -167,23 +167,32 @@ export function FileExplorerPane({
|
|||||||
if (hasInitializedRef.current) {
|
if (hasInitializedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Mark initialized eagerly so concurrent effect re-runs don't double-fetch.
|
||||||
|
// If the root listing fails (e.g. client not yet connected), we reset the
|
||||||
|
// flag so the next time requestDirectoryListing is recreated (when client
|
||||||
|
// becomes available) this effect retries automatically.
|
||||||
hasInitializedRef.current = true;
|
hasInitializedRef.current = true;
|
||||||
void requestDirectoryListing(".", {
|
void requestDirectoryListing(".", {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
}).then((succeeded) => {
|
||||||
const persistedPaths =
|
if (!succeeded) {
|
||||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
hasInitializedRef.current = false;
|
||||||
if (persistedPaths) {
|
return;
|
||||||
for (const path of persistedPaths) {
|
}
|
||||||
if (path !== ".") {
|
const persistedPaths =
|
||||||
void requestDirectoryListing(path, {
|
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||||
recordHistory: false,
|
if (persistedPaths) {
|
||||||
setCurrentPath: false,
|
for (const path of persistedPaths) {
|
||||||
});
|
if (path !== ".") {
|
||||||
|
void requestDirectoryListing(path, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||||
|
|
||||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
|
|
||||||
|
describe("isRenderedMarkdownFile", () => {
|
||||||
|
it("detects .md files", () => {
|
||||||
|
expect(isRenderedMarkdownFile("README.md")).toBe(true);
|
||||||
|
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects .markdown files", () => {
|
||||||
|
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
|
||||||
|
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat .mdx files as rendered markdown", () => {
|
||||||
|
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat other text files as rendered markdown", () => {
|
||||||
|
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
|
||||||
|
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function isRenderedMarkdownFile(filePath: string): boolean {
|
||||||
|
const normalizedPath = filePath.trim().toLowerCase();
|
||||||
|
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useRef } from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
@@ -12,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
darkHighlightColors,
|
darkHighlightColors,
|
||||||
@@ -20,7 +22,9 @@ import {
|
|||||||
type HighlightStyle,
|
type HighlightStyle,
|
||||||
} from "@getpaseo/highlight";
|
} from "@getpaseo/highlight";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({
|
|||||||
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
||||||
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={codeLineStyles.lineText}>
|
<Text selectable style={codeLineStyles.lineText}>
|
||||||
{tokens.map((token, index) => (
|
{tokens.map((token, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={index}
|
key={index}
|
||||||
@@ -117,19 +121,23 @@ function FilePreviewBody({
|
|||||||
const isDark = theme.colorScheme === "dark";
|
const isDark = theme.colorScheme === "dark";
|
||||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||||
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||||
|
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
||||||
|
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
});
|
});
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
const highlightedLines = useMemo(() => {
|
||||||
if (!preview || preview.kind !== "text") {
|
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return highlightCode(preview.content ?? "", filePath);
|
return highlightCode(preview.content ?? "", filePath);
|
||||||
}, [preview?.kind, preview?.content, filePath]);
|
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
|
||||||
|
|
||||||
const gutterWidth = useMemo(() => {
|
const gutterWidth = useMemo(() => {
|
||||||
if (!highlightedLines) return 0;
|
if (!highlightedLines) return 0;
|
||||||
@@ -154,6 +162,28 @@ function FilePreviewBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (preview.kind === "text") {
|
if (preview.kind === "text") {
|
||||||
|
if (isMarkdownFile) {
|
||||||
|
return (
|
||||||
|
<View style={styles.previewScrollContainer}>
|
||||||
|
<RNScrollView
|
||||||
|
ref={previewScrollRef}
|
||||||
|
style={styles.previewContent}
|
||||||
|
contentContainerStyle={styles.previewMarkdownScrollContent}
|
||||||
|
onLayout={scrollbar.onLayout}
|
||||||
|
onScroll={scrollbar.onScroll}
|
||||||
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
|
>
|
||||||
|
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
||||||
|
{preview.content ?? ""}
|
||||||
|
</Markdown>
|
||||||
|
</RNScrollView>
|
||||||
|
{scrollbar.overlay}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||||
const codeLines = (
|
const codeLines = (
|
||||||
<View>
|
<View>
|
||||||
@@ -188,6 +218,7 @@ function FilePreviewBody({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={styles.previewCodeScrollContent}
|
contentContainerStyle={styles.previewCodeScrollContent}
|
||||||
>
|
>
|
||||||
{codeLines}
|
{codeLines}
|
||||||
@@ -329,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
previewCodeScrollContent: {
|
previewCodeScrollContent: {
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
previewMarkdownScrollContent: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
previewImageScrollContent: {
|
previewImageScrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|||||||
import { formatShortcut } from "@/utils/format-shortcut";
|
import { formatShortcut } from "@/utils/format-shortcut";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import {
|
import {
|
||||||
markScrollInvestigationEvent,
|
markScrollInvestigationEvent,
|
||||||
markScrollInvestigationRender,
|
markScrollInvestigationRender,
|
||||||
@@ -917,7 +918,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
// IME composition in progress (e.g. CJK input) — all key events belong to the
|
// IME composition in progress (e.g. CJK input) — all key events belong to the
|
||||||
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
|
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
|
||||||
// cleared before the keydown fires.
|
// cleared before the keydown fires.
|
||||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||||
|
|
||||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||||
if (onKeyPressCallback) {
|
if (onKeyPressCallback) {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { AlertCircle, Search } from "lucide-react-native";
|
||||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
interface ProviderDiagnosticSheetProps {
|
interface ProviderDiagnosticSheetProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({
|
|||||||
}: ProviderDiagnosticSheetProps) {
|
}: ProviderDiagnosticSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||||
|
const providerEntry = useMemo(
|
||||||
|
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
||||||
|
[snapshotEntries, provider],
|
||||||
|
);
|
||||||
|
const models = providerEntry?.models ?? [];
|
||||||
|
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||||
|
const providerErrorMessage =
|
||||||
|
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
|
||||||
|
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
|
||||||
|
|
||||||
const fetchDiagnostic = useCallback(async () => {
|
const [clockTick, setClockTick] = useState(0);
|
||||||
if (!client || !provider) return;
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [visible]);
|
||||||
|
const fetchedAtLabel = useMemo(() => {
|
||||||
|
if (!providerEntry?.fetchedAt) return null;
|
||||||
|
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||||
|
// clockTick triggers re-computation on timer
|
||||||
|
}, [providerEntry?.fetchedAt, clockTick]);
|
||||||
|
|
||||||
setLoading(true);
|
const q = query.trim().toLowerCase();
|
||||||
setDiagnostic(null);
|
const filteredModels = q
|
||||||
|
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
|
||||||
|
: models;
|
||||||
|
|
||||||
try {
|
const fetchDiagnostic = useCallback(
|
||||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
async (options?: { keepCurrent?: boolean }) => {
|
||||||
setDiagnostic(result.diagnostic);
|
if (!client || !provider) return;
|
||||||
} catch (err) {
|
|
||||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
setLoading(true);
|
||||||
} finally {
|
if (!options?.keepCurrent) {
|
||||||
setLoading(false);
|
setDiagnostic(null);
|
||||||
}
|
}
|
||||||
}, [client, provider]);
|
|
||||||
|
try {
|
||||||
|
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||||
|
setDiagnostic(result.diagnostic);
|
||||||
|
} catch (err) {
|
||||||
|
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, provider],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void refresh([provider as AgentProvider]);
|
||||||
|
void fetchDiagnostic({ keepCurrent: true });
|
||||||
|
}, [fetchDiagnostic, provider, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
fetchDiagnostic();
|
fetchDiagnostic();
|
||||||
} else {
|
} else {
|
||||||
setDiagnostic(null);
|
setDiagnostic(null);
|
||||||
|
setQuery("");
|
||||||
}
|
}
|
||||||
}, [visible, fetchDiagnostic]);
|
}, [visible, fetchDiagnostic]);
|
||||||
|
|
||||||
|
function renderModelsBody() {
|
||||||
|
if (models.length === 0 && providerSnapshotRefreshing) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>Loading models…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (models.length === 0 && providerErrorMessage) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (models.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<Text style={sheetStyles.mutedText}>No models detected.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filteredModels.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>No models match your search</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filteredModels.map((model: AgentModelDefinition, index) => (
|
||||||
|
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
|
||||||
|
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
|
||||||
|
{model.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
|
||||||
|
{model.id}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
title={providerLabel}
|
title={providerLabel}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
snapPoints={["50%", "85%"]}
|
snapPoints={["50%", "85%"]}
|
||||||
>
|
scrollable={false}
|
||||||
{loading ? (
|
headerActions={
|
||||||
<View style={sheetStyles.loadingContainer}>
|
<Pressable
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
onPress={handleRefresh}
|
||||||
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
|
disabled={refreshInFlight}
|
||||||
</View>
|
hitSlop={8}
|
||||||
) : diagnostic ? (
|
style={({ hovered, pressed }) => [
|
||||||
<ScrollView
|
sheetStyles.iconButton,
|
||||||
horizontal
|
(hovered || pressed) && sheetStyles.iconButtonHovered,
|
||||||
style={sheetStyles.scrollContainer}
|
refreshInFlight ? sheetStyles.disabled : null,
|
||||||
contentContainerStyle={sheetStyles.scrollContent}
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||||
>
|
>
|
||||||
<Text style={sheetStyles.diagnosticText} selectable>
|
<SpinningRefreshIcon
|
||||||
{diagnostic}
|
spinning={refreshInFlight}
|
||||||
</Text>
|
size={theme.iconSize.sm}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={sheetStyles.section}>
|
||||||
|
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
|
||||||
|
<View style={sheetStyles.codeBlock}>
|
||||||
|
{loading && !diagnostic ? (
|
||||||
|
<View style={sheetStyles.codeBlockLoading}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
|
||||||
|
</View>
|
||||||
|
) : diagnostic ? (
|
||||||
|
<ScrollView
|
||||||
|
style={sheetStyles.codeScroll}
|
||||||
|
contentContainerStyle={sheetStyles.codeContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<Text style={sheetStyles.codeText} selectable>
|
||||||
|
{diagnostic}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={sheetStyles.codeBlockLoading}>
|
||||||
|
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={sheetStyles.modelsSection}>
|
||||||
|
<View style={sheetStyles.modelsHeader}>
|
||||||
|
<Text style={sheetStyles.sectionTitle}>Models</Text>
|
||||||
|
<View style={sheetStyles.modelsHeaderMeta}>
|
||||||
|
<Text style={sheetStyles.countText}>{models.length}</Text>
|
||||||
|
{fetchedAtLabel ? (
|
||||||
|
<>
|
||||||
|
<Text style={sheetStyles.metaDot}>·</Text>
|
||||||
|
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{models.length > 0 ? (
|
||||||
|
<View style={sheetStyles.searchContainer}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<AdaptiveTextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Search models"
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
|
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<ScrollView
|
||||||
|
style={sheetStyles.modelsScroll}
|
||||||
|
contentContainerStyle={sheetStyles.modelsScrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{renderModelsBody()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetStyles = StyleSheet.create((theme) => ({
|
const sheetStyles = StyleSheet.create((theme) => ({
|
||||||
loadingContainer: {
|
section: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
mutedText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
iconButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxHeight: 180,
|
||||||
|
},
|
||||||
|
codeScroll: {
|
||||||
|
maxHeight: 180,
|
||||||
|
},
|
||||||
|
codeContent: {
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
codeText: {
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
codeBlockLoading: {
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelsSection: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelsHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
modelsHeaderMeta: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
metaDot: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
modelsScroll: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
modelsScrollContent: {
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelRow: {
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
},
|
||||||
|
modelRowBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
modelLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
modelId: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[6],
|
paddingVertical: theme.spacing[6],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
loadingText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
scrollContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingBottom: theme.spacing[4],
|
|
||||||
},
|
|
||||||
diagnosticText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
lineHeight: theme.fontSize.sm * 1.6,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { RefreshCw } from "lucide-react-native";
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
interface SpinningRefreshIconProps {
|
||||||
|
spinning: boolean;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (spinning) {
|
||||||
|
rotation.value = 0;
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, {
|
||||||
|
duration: 1000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAnimation(rotation);
|
||||||
|
const remainder = rotation.value % 360;
|
||||||
|
if (Math.abs(remainder) < 0.001) {
|
||||||
|
rotation.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotation.value = withTiming(360, {
|
||||||
|
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
|
||||||
|
easing: Easing.linear,
|
||||||
|
});
|
||||||
|
}, [rotation, spinning]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<RefreshCw size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Fonts } from "@/constants/theme";
|
|||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
|
import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
|
||||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -30,6 +31,7 @@ export function ToolCallDetailsContent({
|
|||||||
showLoadingSkeleton = false,
|
showLoadingSkeleton = false,
|
||||||
}: ToolCallDetailsContentProps) {
|
}: ToolCallDetailsContentProps) {
|
||||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
|
||||||
// Compute diff lines for edit type
|
// Compute diff lines for edit type
|
||||||
const diffLines = useMemo(() => {
|
const diffLines = useMemo(() => {
|
||||||
@@ -65,6 +67,7 @@ export function ToolCallDetailsContent({
|
|||||||
styles.codeVerticalScroll,
|
styles.codeVerticalScroll,
|
||||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
shouldFill && styles.fillHeight,
|
shouldFill && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.codeVerticalContent}
|
contentContainerStyle={styles.codeVerticalContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -74,6 +77,7 @@ export function ToolCallDetailsContent({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={styles.codeHorizontalContent}
|
contentContainerStyle={styles.codeHorizontalContent}
|
||||||
>
|
>
|
||||||
<View style={styles.codeLine}>
|
<View style={styles.codeLine}>
|
||||||
@@ -99,6 +103,7 @@ export function ToolCallDetailsContent({
|
|||||||
styles.codeVerticalScroll,
|
styles.codeVerticalScroll,
|
||||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
shouldFill && styles.fillHeight,
|
shouldFill && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.codeVerticalContent}
|
contentContainerStyle={styles.codeVerticalContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -108,6 +113,7 @@ export function ToolCallDetailsContent({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={styles.codeHorizontalContent}
|
contentContainerStyle={styles.codeHorizontalContent}
|
||||||
>
|
>
|
||||||
<View style={styles.codeLine}>
|
<View style={styles.codeLine}>
|
||||||
@@ -137,6 +143,7 @@ export function ToolCallDetailsContent({
|
|||||||
styles.codeVerticalScroll,
|
styles.codeVerticalScroll,
|
||||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
shouldFill && styles.fillHeight,
|
shouldFill && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.codeVerticalContent}
|
contentContainerStyle={styles.codeVerticalContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -146,6 +153,7 @@ export function ToolCallDetailsContent({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={styles.codeHorizontalContent}
|
contentContainerStyle={styles.codeHorizontalContent}
|
||||||
>
|
>
|
||||||
<View style={styles.codeLine}>
|
<View style={styles.codeLine}>
|
||||||
@@ -181,12 +189,18 @@ export function ToolCallDetailsContent({
|
|||||||
styles.scrollArea,
|
styles.scrollArea,
|
||||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
shouldFill && styles.fillHeight,
|
shouldFill && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator={true}
|
showsVerticalScrollIndicator={true}
|
||||||
>
|
>
|
||||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
style={webScrollbarStyle}
|
||||||
|
>
|
||||||
<Text selectable style={styles.scrollText}>
|
<Text selectable style={styles.scrollText}>
|
||||||
{detail.content}
|
{detail.content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -204,12 +218,18 @@ export function ToolCallDetailsContent({
|
|||||||
styles.scrollArea,
|
styles.scrollArea,
|
||||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
shouldFill && styles.fillHeight,
|
shouldFill && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator={true}
|
showsVerticalScrollIndicator={true}
|
||||||
>
|
>
|
||||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
style={webScrollbarStyle}
|
||||||
|
>
|
||||||
<Text selectable style={styles.scrollText}>
|
<Text selectable style={styles.scrollText}>
|
||||||
{detail.content}
|
{detail.content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -236,12 +256,18 @@ export function ToolCallDetailsContent({
|
|||||||
style={[
|
style={[
|
||||||
styles.scrollArea,
|
styles.scrollArea,
|
||||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
>
|
>
|
||||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
|
>
|
||||||
<Text selectable style={styles.scrollText}>
|
<Text selectable style={styles.scrollText}>
|
||||||
{detail.content}
|
{detail.content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -286,12 +312,18 @@ export function ToolCallDetailsContent({
|
|||||||
styles.scrollArea,
|
styles.scrollArea,
|
||||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||||
shouldFill && styles.fillHeight,
|
shouldFill && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
>
|
>
|
||||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
|
>
|
||||||
<Text selectable style={styles.scrollText}>
|
<Text selectable style={styles.scrollText}>
|
||||||
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -356,7 +388,7 @@ export function ToolCallDetailsContent({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
style={styles.jsonScroll}
|
style={[styles.jsonScroll, webScrollbarStyle]}
|
||||||
contentContainerStyle={styles.jsonContent}
|
contentContainerStyle={styles.jsonContent}
|
||||||
showsHorizontalScrollIndicator={true}
|
showsHorizontalScrollIndicator={true}
|
||||||
>
|
>
|
||||||
@@ -378,7 +410,7 @@ export function ToolCallDetailsContent({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
style={[styles.jsonScroll, styles.jsonScrollError]}
|
style={[styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle]}
|
||||||
contentContainerStyle={styles.jsonContent}
|
contentContainerStyle={styles.jsonContent}
|
||||||
showsHorizontalScrollIndicator={true}
|
showsHorizontalScrollIndicator={true}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -890,6 +890,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
comboboxItemLabel: {
|
comboboxItemLabel: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
comboboxItemDescription: {
|
comboboxItemDescription: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Check, CheckCircle } from "lucide-react-native";
|
|||||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
// Keep parity with dropdown-menu action statuses.
|
// Keep parity with dropdown-menu action statuses.
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -348,6 +349,7 @@ export function ContextMenuContent({
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const context = useContextMenuContext("ContextMenuContent");
|
const context = useContextMenuContext("ContextMenuContent");
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||||
@@ -537,6 +539,7 @@ export function ContextMenuContent({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
bounces={false}
|
bounces={false}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={{ flexGrow: 1 }}
|
contentContainerStyle={{ flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
// Action status for menu items with loading/success feedback
|
// Action status for menu items with loading/success feedback
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -267,6 +268,7 @@ export function DropdownMenuContent({
|
|||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
@@ -411,6 +413,7 @@ export function DropdownMenuContent({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
bounces={false}
|
bounces={false}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={{ flexGrow: 1 }}
|
contentContainerStyle={{ flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -82,9 +82,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requestDirectoryListing = useCallback(
|
const requestDirectoryListing = useCallback(
|
||||||
async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => {
|
async (
|
||||||
|
path: string,
|
||||||
|
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||||
|
): Promise<boolean> => {
|
||||||
if (!workspaceStateKey) {
|
if (!workspaceStateKey) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||||
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
|
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
|
||||||
@@ -113,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
lastError: "Workspace is unavailable",
|
lastError: "Workspace is unavailable",
|
||||||
pendingRequest: null,
|
pendingRequest: null,
|
||||||
}));
|
}));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -123,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
lastError: "Host is not connected",
|
lastError: "Host is not connected",
|
||||||
pendingRequest: null,
|
pendingRequest: null,
|
||||||
}));
|
}));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +153,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
|
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateExplorerState((state) => ({
|
updateExplorerState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -157,6 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
lastError: error instanceof Error ? error.message : "Failed to list directory",
|
lastError: error instanceof Error ? error.message : "Failed to list directory",
|
||||||
pendingRequest: null,
|
pendingRequest: null,
|
||||||
}));
|
}));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],
|
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
|||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
|
|
||||||
export function useKeyboardShortcuts({
|
export function useKeyboardShortcuts({
|
||||||
enabled,
|
enabled,
|
||||||
@@ -310,6 +311,12 @@ export function useKeyboardShortcuts({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// During IME composition, Enter confirms the candidate selection and must
|
||||||
|
// not route through global shortcuts like message send.
|
||||||
|
if (isImeComposingKeyboardEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const store = useKeyboardShortcutsStore.getState();
|
const store = useKeyboardShortcutsStore.getState();
|
||||||
if (store.capturingShortcut) {
|
if (store.capturingShortcut) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useSessionForServer } from "./use-session-directory";
|
import { useSessionForServer } from "./use-session-directory";
|
||||||
@@ -23,9 +23,10 @@ interface UseProvidersSnapshotResult {
|
|||||||
entries: ProviderSnapshotEntry[] | undefined;
|
entries: ProviderSnapshotEntry[] | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
supportsSnapshot: boolean;
|
supportsSnapshot: boolean;
|
||||||
refresh: () => void;
|
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||||
invalidate: () => void;
|
invalidate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,16 @@ export function useProvidersSnapshot(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: async (providers?: AgentProvider[]) => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
||||||
return;
|
return;
|
||||||
@@ -81,12 +92,12 @@ export function useProvidersSnapshot(
|
|||||||
});
|
});
|
||||||
}, [client, isConnected, normalizedCwdKey, queryClient, queryKey, serverId, supportsSnapshot]);
|
}, [client, isConnected, normalizedCwdKey, queryClient, queryKey, serverId, supportsSnapshot]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(
|
||||||
if (!client) {
|
async (providers?: AgentProvider[]) => {
|
||||||
return;
|
await refreshSnapshot(providers);
|
||||||
}
|
},
|
||||||
void client.refreshProvidersSnapshot({ cwd: normalizedCwd });
|
[refreshSnapshot],
|
||||||
}, [client, normalizedCwd]);
|
);
|
||||||
|
|
||||||
const invalidate = useCallback(() => {
|
const invalidate = useCallback(() => {
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
@@ -96,6 +107,7 @@ export function useProvidersSnapshot(
|
|||||||
entries: snapshotQuery.data?.entries ?? undefined,
|
entries: snapshotQuery.data?.entries ?? undefined,
|
||||||
isLoading: snapshotQuery.isLoading,
|
isLoading: snapshotQuery.isLoading,
|
||||||
isFetching: snapshotQuery.isFetching,
|
isFetching: snapshotQuery.isFetching,
|
||||||
|
isRefreshing,
|
||||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||||
supportsSnapshot,
|
supportsSnapshot,
|
||||||
refresh,
|
refresh,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./use-web-scrollbar-style.web";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
export function useWebScrollbarStyle(): ViewStyle | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { ViewStyle } from "react-native";
|
||||||
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
|
// CSS scrollbar properties are supported by React Native Web at runtime
|
||||||
|
// but are not included in React Native's ViewStyle type definition.
|
||||||
|
interface WebScrollbarStyle extends ViewStyle {
|
||||||
|
scrollbarColor: string;
|
||||||
|
scrollbarWidth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebScrollbarStyle(): WebScrollbarStyle {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
return useMemo(
|
||||||
|
(): WebScrollbarStyle => ({
|
||||||
|
scrollbarColor: `${theme.colors.scrollbarHandle} transparent`,
|
||||||
|
scrollbarWidth: "thin",
|
||||||
|
}),
|
||||||
|
[theme.colors.scrollbarHandle],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
|
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -521,9 +522,11 @@ interface ProvidersSectionProps {
|
|||||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId);
|
||||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
const providerRefreshInFlight =
|
||||||
|
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
|
||||||
|
|
||||||
const hasServer = routeServerId.length > 0;
|
const hasServer = routeServerId.length > 0;
|
||||||
|
|
||||||
@@ -534,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||||
{hasServer && isConnected ? (
|
{hasServer && isConnected ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={refresh}
|
onPress={() => {
|
||||||
disabled={isFetching}
|
void refresh();
|
||||||
style={[settingsStyles.sectionHeaderLink, isFetching ? { opacity: 0.5 } : null]}
|
}}
|
||||||
|
disabled={providerRefreshInFlight}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
settingsStyles.sectionHeaderLink,
|
||||||
|
styles.providerRefreshButton,
|
||||||
|
(hovered || pressed) && styles.providerRefreshButtonHovered,
|
||||||
|
providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Refresh providers"
|
||||||
>
|
>
|
||||||
<Text
|
<SpinningRefreshIcon
|
||||||
style={{
|
spinning={providerRefreshInFlight}
|
||||||
color: theme.colors.primary,
|
size={theme.iconSize.sm}
|
||||||
fontSize: theme.fontSize.xs,
|
color={theme.colors.foregroundMuted}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
? entry.error.trim()
|
? entry.error.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const modelCount = entry?.models?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={def.id} style={styles.audioRow}>
|
<Pressable
|
||||||
|
key={def.id}
|
||||||
|
style={styles.audioRow}
|
||||||
|
onPress={() => setDiagnosticProvider(def.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
<View style={styles.audioRowContent}>
|
<View style={styles.audioRowContent}>
|
||||||
<View
|
<View
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
||||||
@@ -584,31 +601,27 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
{providerError}
|
{providerError}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{status === "ready" && modelCount > 0 ? (
|
||||||
|
<Text style={styles.audioRowSubtitle}>
|
||||||
|
{modelCount === 1 ? "1 model" : `${modelCount} models`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.providerActions}>
|
<StatusBadge
|
||||||
<StatusBadge
|
label={
|
||||||
label={
|
status === "ready"
|
||||||
status === "ready"
|
? "Available"
|
||||||
? "Available"
|
: status === "error"
|
||||||
: status === "error"
|
? "Error"
|
||||||
? "Error"
|
: status === "loading"
|
||||||
: status === "loading"
|
? "Loading..."
|
||||||
? "Loading..."
|
: "Not installed"
|
||||||
: "Not installed"
|
}
|
||||||
}
|
variant={
|
||||||
variant={
|
status === "ready" ? "success" : status === "error" ? "error" : "muted"
|
||||||
status === "ready" ? "success" : status === "error" ? "error" : "muted"
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Pressable>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => setDiagnosticProvider(def.id)}
|
|
||||||
>
|
|
||||||
Diagnostic
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
formButtonPrimaryText: {
|
formButtonPrimaryText: {
|
||||||
color: theme.colors.palette.white,
|
color: theme.colors.palette.white,
|
||||||
},
|
},
|
||||||
|
providerRefreshButton: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
providerRefreshButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
providerRefreshButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
// Audio settings card
|
// Audio settings card
|
||||||
audioCard: {
|
audioCard: {
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
@@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
marginTop: theme.spacing[1],
|
marginTop: theme.spacing[1],
|
||||||
},
|
},
|
||||||
providerActions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
aboutValue: {
|
aboutValue: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Fonts } from "@/constants/theme";
|
|||||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
type StartupSplashScreenProps = {
|
type StartupSplashScreenProps = {
|
||||||
bootstrapState?: {
|
bootstrapState?: {
|
||||||
@@ -162,6 +163,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
|
|
||||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||||
const [logsError, setLogsError] = useState<string | null>(null);
|
const [logsError, setLogsError] = useState<string | null>(null);
|
||||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||||
@@ -283,7 +285,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
<View style={styles.errorScreen}>
|
<View style={styles.errorScreen}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.errorScrollView}
|
style={[styles.errorScrollView, webScrollbarStyle]}
|
||||||
contentContainerStyle={styles.errorScrollContent}
|
contentContainerStyle={styles.errorScrollContent}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
>
|
>
|
||||||
@@ -304,7 +306,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
|
|
||||||
<View style={styles.logsContainer}>
|
<View style={styles.logsContainer}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.logsScroll}
|
style={[styles.logsScroll, webScrollbarStyle]}
|
||||||
contentContainerStyle={styles.logsContent}
|
contentContainerStyle={styles.logsContent}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -764,12 +764,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
};
|
};
|
||||||
}, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]);
|
}, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]);
|
||||||
|
|
||||||
|
const isCheckoutQueryEnabled = Boolean(client && isConnected) && Boolean(workspaceDirectory);
|
||||||
const checkoutQuery = useQuery({
|
const checkoutQuery = useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(
|
queryKey: checkoutStatusQueryKey(
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
||||||
),
|
),
|
||||||
enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory),
|
enabled: isCheckoutQueryEnabled,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client || !workspaceDirectory) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
@@ -778,6 +779,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
},
|
},
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
});
|
});
|
||||||
|
const isCheckoutStatusLoading =
|
||||||
|
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
|
||||||
const hasHydratedWorkspaces = useSessionStore(
|
const hasHydratedWorkspaces = useSessionStore(
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||||
);
|
);
|
||||||
@@ -811,7 +814,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const workspaceHeader = workspaceDescriptor
|
const workspaceHeader = workspaceDescriptor
|
||||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||||
: null;
|
: null;
|
||||||
const isWorkspaceHeaderLoading = workspaceHeader === null;
|
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
|
||||||
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
||||||
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
||||||
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isImeComposingKeyboardEvent } from "./keyboard-ime";
|
||||||
|
|
||||||
|
describe("isImeComposingKeyboardEvent", () => {
|
||||||
|
it("ignores events while IME composition is active", () => {
|
||||||
|
expect(
|
||||||
|
isImeComposingKeyboardEvent({
|
||||||
|
isComposing: true,
|
||||||
|
keyCode: 13,
|
||||||
|
} as KeyboardEvent),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores Chromium IME fallback events with keyCode 229", () => {
|
||||||
|
expect(
|
||||||
|
isImeComposingKeyboardEvent({
|
||||||
|
isComposing: false,
|
||||||
|
keyCode: 229,
|
||||||
|
} as KeyboardEvent),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps regular keyboard events eligible for shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
isImeComposingKeyboardEvent({
|
||||||
|
isComposing: false,
|
||||||
|
keyCode: 13,
|
||||||
|
} as KeyboardEvent),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function isImeComposingKeyboardEvent(event: {
|
||||||
|
isComposing?: boolean;
|
||||||
|
keyCode?: number;
|
||||||
|
}): boolean {
|
||||||
|
return Boolean(event.isComposing) || event.keyCode === 229;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.56",
|
"@getpaseo/relay": "0.1.57-rc.1",
|
||||||
"@getpaseo/server": "0.1.56",
|
"@getpaseo/server": "0.1.57-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.56",
|
"@getpaseo/cli": "0.1.57-rc.1",
|
||||||
"@getpaseo/server": "0.1.56",
|
"@getpaseo/server": "0.1.57-rc.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ npm run build:main
|
|||||||
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
|
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
|
||||||
export EXPO_PORT
|
export EXPO_PORT
|
||||||
|
|
||||||
|
# Allow any origin in dev so Electron on random localhost ports can reach
|
||||||
|
# the daemon websocket. Safe here because this script is development-only
|
||||||
|
# and the daemon still binds to localhost.
|
||||||
|
export PASEO_CORS_ORIGINS="*"
|
||||||
|
|
||||||
echo "══════════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════════"
|
||||||
echo " Paseo Desktop Dev"
|
echo " Paseo Desktop Dev"
|
||||||
echo "══════════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════════"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"types": "build/index.d.ts",
|
"types": "build/index.d.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.56",
|
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||||
"@getpaseo/relay": "0.1.56",
|
"@getpaseo/relay": "0.1.57-rc.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
|
|||||||
@@ -2838,6 +2838,7 @@ export class DaemonClient {
|
|||||||
|
|
||||||
async refreshProvidersSnapshot(options?: {
|
async refreshProvidersSnapshot(options?: {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
providers?: AgentProvider[];
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}): Promise<RefreshProvidersSnapshotPayload> {
|
}): Promise<RefreshProvidersSnapshotPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
@@ -2845,6 +2846,7 @@ export class DaemonClient {
|
|||||||
message: {
|
message: {
|
||||||
type: "refresh_providers_snapshot_request",
|
type: "refresh_providers_snapshot_request",
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
|
providers: options?.providers,
|
||||||
},
|
},
|
||||||
responseType: "refresh_providers_snapshot_response",
|
responseType: "refresh_providers_snapshot_response",
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import type pino from "pino";
|
|
||||||
|
|
||||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
|
||||||
import type { AgentManager } from "./agent/agent-manager.js";
|
|
||||||
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
|
||||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
|
||||||
import {
|
|
||||||
buildConfigOverrides,
|
|
||||||
buildSessionConfig,
|
|
||||||
extractTimestamps,
|
|
||||||
toAgentPersistenceHandle,
|
|
||||||
} from "./persistence-hooks.js";
|
|
||||||
|
|
||||||
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
|
|
||||||
|
|
||||||
export type AgentLoadingServiceOptions = {
|
|
||||||
agentManager: Pick<
|
|
||||||
AgentManager,
|
|
||||||
| "createAgent"
|
|
||||||
| "getAgent"
|
|
||||||
| "hydrateTimelineFromProvider"
|
|
||||||
| "reloadAgentSession"
|
|
||||||
| "resumeAgentFromPersistence"
|
|
||||||
>;
|
|
||||||
agentStorage: Pick<AgentStorage, "get">;
|
|
||||||
logger: pino.Logger;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
|
|
||||||
export class AgentLoadingService {
|
|
||||||
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
|
|
||||||
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
|
|
||||||
private readonly logger: pino.Logger;
|
|
||||||
|
|
||||||
constructor(options: AgentLoadingServiceOptions) {
|
|
||||||
this.agentManager = options.agentManager;
|
|
||||||
this.agentStorage = options.agentStorage;
|
|
||||||
this.logger = options.logger.child({ component: "agent-loading" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
|
|
||||||
const existing = this.agentManager.getAgent(options.agentId);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
|
|
||||||
if (inflight) {
|
|
||||||
return inflight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initPromise = this.loadStoredAgent(options);
|
|
||||||
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await initPromise;
|
|
||||||
} finally {
|
|
||||||
pendingAgentBootstrapLoads.delete(options.agentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeAgent(options: {
|
|
||||||
handle: AgentPersistenceHandle;
|
|
||||||
overrides?: Partial<AgentSessionConfig>;
|
|
||||||
}): Promise<ManagedAgent> {
|
|
||||||
const snapshot = await this.agentManager.resumeAgentFromPersistence(
|
|
||||||
options.handle,
|
|
||||||
options.overrides,
|
|
||||||
);
|
|
||||||
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
|
||||||
const existing = this.agentManager.getAgent(options.agentId);
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.persistence) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await this.agentManager.reloadAgentSession(options.agentId);
|
|
||||||
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = await this.agentStorage.get(options.agentId);
|
|
||||||
if (!record) {
|
|
||||||
throw new Error(`Agent not found: ${options.agentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
|
||||||
if (!handle) {
|
|
||||||
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await this.agentManager.resumeAgentFromPersistence(
|
|
||||||
handle,
|
|
||||||
buildConfigOverrides(record),
|
|
||||||
options.agentId,
|
|
||||||
extractTimestamps(record),
|
|
||||||
);
|
|
||||||
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
|
||||||
const record = await this.agentStorage.get(options.agentId);
|
|
||||||
if (!record) {
|
|
||||||
throw new Error(`Agent not found: ${options.agentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
|
||||||
let snapshot: ManagedAgent;
|
|
||||||
if (handle) {
|
|
||||||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
|
||||||
handle,
|
|
||||||
buildConfigOverrides(record),
|
|
||||||
options.agentId,
|
|
||||||
extractTimestamps(record),
|
|
||||||
);
|
|
||||||
this.logger.info(
|
|
||||||
{ agentId: options.agentId, provider: record.provider },
|
|
||||||
"Agent resumed from persistence",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const sessionConfig = buildSessionConfig(record);
|
|
||||||
if (!sessionConfig) {
|
|
||||||
throw new Error(
|
|
||||||
`Agent ${options.agentId} has an invalid provider '${record.provider}' and cannot be loaded`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
snapshot = await this.agentManager.createAgent(sessionConfig, options.agentId, {
|
|
||||||
labels: record.labels,
|
|
||||||
});
|
|
||||||
this.logger.info(
|
|
||||||
{ agentId: options.agentId, provider: record.provider },
|
|
||||||
"Agent created from stored config",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||||
|
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||||
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
|
import {
|
||||||
|
buildConfigOverrides,
|
||||||
|
buildSessionConfig,
|
||||||
|
extractTimestamps,
|
||||||
|
toAgentPersistenceHandle,
|
||||||
|
} from "../persistence-hooks.js";
|
||||||
|
|
||||||
|
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||||
|
|
||||||
|
export interface EnsureAgentLoadedDeps {
|
||||||
|
agentManager: AgentManager;
|
||||||
|
agentStorage: AgentStorage;
|
||||||
|
validProviders?: Iterable<AgentProvider>;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureAgentLoaded(
|
||||||
|
agentId: string,
|
||||||
|
deps: EnsureAgentLoadedDeps,
|
||||||
|
): Promise<ManagedAgent> {
|
||||||
|
const existing = deps.agentManager.getAgent(agentId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = pendingAgentInitializations.get(agentId);
|
||||||
|
if (inflight) {
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initPromise = (async () => {
|
||||||
|
const record = await deps.agentStorage.get(agentId);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Agent not found: ${agentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
|
||||||
|
const handle = toAgentPersistenceHandle(deps.logger, validProviders, record.persistence);
|
||||||
|
|
||||||
|
let snapshot: ManagedAgent;
|
||||||
|
if (handle) {
|
||||||
|
snapshot = await deps.agentManager.resumeAgentFromPersistence(
|
||||||
|
handle,
|
||||||
|
buildConfigOverrides(record),
|
||||||
|
agentId,
|
||||||
|
extractTimestamps(record),
|
||||||
|
);
|
||||||
|
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
|
||||||
|
} else {
|
||||||
|
const config = buildSessionConfig(record, {
|
||||||
|
validProviders,
|
||||||
|
logger: deps.logger,
|
||||||
|
});
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||||
|
}
|
||||||
|
snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||||
|
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.agentManager.hydrateTimelineFromProvider(agentId);
|
||||||
|
return deps.agentManager.getAgent(agentId) ?? snapshot;
|
||||||
|
})();
|
||||||
|
|
||||||
|
pendingAgentInitializations.set(agentId, initPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await initPromise;
|
||||||
|
} finally {
|
||||||
|
const current = pendingAgentInitializations.get(agentId);
|
||||||
|
if (current === initPromise) {
|
||||||
|
pendingAgentInitializations.delete(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ import type {
|
|||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type { JsonValue } from "../json-utils.js";
|
import type { JsonValue } from "../json-utils.js";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import { buildProviderRegistry } from "./provider-registry.js";
|
||||||
|
import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-hooks.js";
|
||||||
|
|
||||||
export type { ManagedAgent };
|
export type { ManagedAgent };
|
||||||
|
|
||||||
@@ -129,6 +132,95 @@ export function toAgentPayload(
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildStoredAgentPayload(
|
||||||
|
record: StoredAgentRecord,
|
||||||
|
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
||||||
|
logger: Logger,
|
||||||
|
): AgentSnapshotPayload {
|
||||||
|
const defaultCapabilities = {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const createdAt = new Date(record.createdAt);
|
||||||
|
const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record));
|
||||||
|
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
||||||
|
|
||||||
|
const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id);
|
||||||
|
const runtimeInfo = record.runtimeInfo
|
||||||
|
? {
|
||||||
|
provider: coerceAgentProvider(
|
||||||
|
logger,
|
||||||
|
providerRegistry,
|
||||||
|
record.runtimeInfo.provider,
|
||||||
|
record.id,
|
||||||
|
),
|
||||||
|
sessionId: record.runtimeInfo.sessionId,
|
||||||
|
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model")
|
||||||
|
? { model: record.runtimeInfo.model ?? null }
|
||||||
|
: {}),
|
||||||
|
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId")
|
||||||
|
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
|
||||||
|
: {}),
|
||||||
|
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId")
|
||||||
|
? { modeId: record.runtimeInfo.modeId ?? null }
|
||||||
|
: {}),
|
||||||
|
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
provider,
|
||||||
|
cwd: record.cwd,
|
||||||
|
model: record.config?.model ?? null,
|
||||||
|
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||||
|
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||||||
|
runtimeInfo,
|
||||||
|
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||||
|
}),
|
||||||
|
...(runtimeInfo ? { runtimeInfo } : {}),
|
||||||
|
createdAt: createdAt.toISOString(),
|
||||||
|
updatedAt: updatedAt.toISOString(),
|
||||||
|
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||||||
|
status: record.lastStatus,
|
||||||
|
capabilities: defaultCapabilities,
|
||||||
|
currentModeId: record.lastModeId ?? null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence),
|
||||||
|
lastUsage: undefined,
|
||||||
|
lastError: undefined,
|
||||||
|
title: record.title ?? record.config?.title ?? null,
|
||||||
|
requiresAttention: record.requiresAttention ?? false,
|
||||||
|
attentionReason: record.attentionReason ?? null,
|
||||||
|
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||||
|
archivedAt: record.archivedAt ?? null,
|
||||||
|
labels: record.labels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
|
||||||
|
const timestamps = [record.updatedAt, record.lastActivityAt]
|
||||||
|
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||||
|
.map((value) => ({
|
||||||
|
raw: value,
|
||||||
|
parsed: Date.parse(value),
|
||||||
|
}))
|
||||||
|
.filter((value) => !Number.isNaN(value.parsed));
|
||||||
|
|
||||||
|
if (timestamps.length === 0) {
|
||||||
|
return record.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.sort((a, b) => b.parsed - a.parsed);
|
||||||
|
return timestamps[0].raw;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||||
const serializable: SerializableAgentConfig = {};
|
const serializable: SerializableAgentConfig = {};
|
||||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { createAgentMcpServer } from "./mcp-server.js";
|
import { createAgentMcpServer } from "./mcp-server.js";
|
||||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentStorage } from "./agent-storage.js";
|
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
|
||||||
type TestDeps = {
|
type TestDeps = {
|
||||||
@@ -29,10 +29,17 @@ function createTestDeps(): TestDeps {
|
|||||||
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
|
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
|
||||||
notifyAgentState: vi.fn(),
|
notifyAgentState: vi.fn(),
|
||||||
getAgent: vi.fn(),
|
getAgent: vi.fn(),
|
||||||
|
listAgents: vi.fn().mockReturnValue([]),
|
||||||
|
getTimeline: vi.fn().mockReturnValue([]),
|
||||||
|
resumeAgentFromPersistence: vi.fn(),
|
||||||
|
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||||
|
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||||
|
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||||
respondToPermission: vi.fn(),
|
respondToPermission: vi.fn(),
|
||||||
cancelAgentRun: vi.fn(),
|
cancelAgentRun: vi.fn(),
|
||||||
getPendingPermissions: vi.fn(),
|
getPendingPermissions: vi.fn(),
|
||||||
|
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentStorageSpies = {
|
const agentStorageSpies = {
|
||||||
@@ -40,7 +47,7 @@ function createTestDeps(): TestDeps {
|
|||||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||||
upsert: vi.fn().mockResolvedValue(undefined),
|
upsert: vi.fn().mockResolvedValue(undefined),
|
||||||
applySnapshot: vi.fn(),
|
applySnapshot: vi.fn(),
|
||||||
list: vi.fn(),
|
list: vi.fn().mockResolvedValue([]),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +75,43 @@ function createProviderDefinition(overrides: Partial<ProviderDefinition>): Provi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStoredRecord(overrides: Partial<StoredAgentRecord> = {}): StoredAgentRecord {
|
||||||
|
const now = "2026-04-11T00:00:00.000Z";
|
||||||
|
return {
|
||||||
|
id: "stored-agent",
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/tmp/stored-project",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastActivityAt: now,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: "Stored agent",
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "closed",
|
||||||
|
lastModeId: "default",
|
||||||
|
config: {
|
||||||
|
modeId: "default",
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
},
|
||||||
|
runtimeInfo: {
|
||||||
|
provider: "claude",
|
||||||
|
sessionId: "session-123",
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
persistence: {
|
||||||
|
provider: "claude",
|
||||||
|
sessionId: "session-123",
|
||||||
|
},
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
internal: false,
|
||||||
|
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("create_agent MCP tool", () => {
|
describe("create_agent MCP tool", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const existingCwd = process.cwd();
|
const existingCwd = process.cwd();
|
||||||
@@ -483,4 +527,223 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
});
|
});
|
||||||
expect(Array.isArray(structured.agents[0].features)).toBe(true);
|
expect(Array.isArray(structured.agents[0].features)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns archived agent snapshots from storage for get_agent_status", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
const record = createStoredRecord({
|
||||||
|
id: "archived-agent",
|
||||||
|
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
spies.agentManager.getAgent.mockReturnValue(null);
|
||||||
|
spies.agentStorage.get.mockResolvedValue(record);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
logger,
|
||||||
|
providerRegistry: {
|
||||||
|
claude: createProviderDefinition({}),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["get_agent_status"];
|
||||||
|
const response = await tool.callback({ agentId: "archived-agent" });
|
||||||
|
|
||||||
|
expect(response.structuredContent).toEqual({
|
||||||
|
status: "closed",
|
||||||
|
snapshot: expect.objectContaining({
|
||||||
|
id: "archived-agent",
|
||||||
|
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
title: "Stored agent",
|
||||||
|
status: "closed",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose internal stored agents from get_agent_status", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
spies.agentManager.getAgent.mockReturnValue(null);
|
||||||
|
spies.agentStorage.get.mockResolvedValue(
|
||||||
|
createStoredRecord({
|
||||||
|
id: "internal-agent",
|
||||||
|
internal: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
logger,
|
||||||
|
providerRegistry: {
|
||||||
|
claude: createProviderDefinition({}),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["get_agent_status"];
|
||||||
|
|
||||||
|
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
|
||||||
|
"Agent internal-agent not found",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes stored non-archived agents in list_agents by default", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
const liveAgent = {
|
||||||
|
id: "live-agent",
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/tmp/live-project",
|
||||||
|
config: {},
|
||||||
|
runtimeInfo: undefined,
|
||||||
|
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lifecycle: "idle",
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
features: [],
|
||||||
|
pendingPermissions: new Map(),
|
||||||
|
persistence: null,
|
||||||
|
labels: {},
|
||||||
|
attention: { requiresAttention: false },
|
||||||
|
} as unknown as ManagedAgent;
|
||||||
|
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
|
||||||
|
spies.agentStorage.list.mockResolvedValue([
|
||||||
|
createStoredRecord({ id: "closed-agent", archivedAt: null }),
|
||||||
|
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
|
||||||
|
createStoredRecord({ id: "live-agent", archivedAt: null }),
|
||||||
|
createStoredRecord({ id: "internal-agent", archivedAt: null, internal: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
logger,
|
||||||
|
providerRegistry: {
|
||||||
|
claude: createProviderDefinition({}),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["list_agents"];
|
||||||
|
const response = await tool.callback({});
|
||||||
|
|
||||||
|
expect(response.structuredContent.agents).toEqual([
|
||||||
|
expect.objectContaining({ id: "live-agent" }),
|
||||||
|
expect.objectContaining({ id: "closed-agent", archivedAt: null }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes archived stored agents in list_agents when requested", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
const liveAgent = {
|
||||||
|
id: "live-agent",
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/tmp/live-project",
|
||||||
|
config: {},
|
||||||
|
runtimeInfo: undefined,
|
||||||
|
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lifecycle: "idle",
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
features: [],
|
||||||
|
pendingPermissions: new Map(),
|
||||||
|
persistence: null,
|
||||||
|
labels: {},
|
||||||
|
attention: { requiresAttention: false },
|
||||||
|
} as unknown as ManagedAgent;
|
||||||
|
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
|
||||||
|
spies.agentStorage.list.mockResolvedValue([
|
||||||
|
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
|
||||||
|
createStoredRecord({ id: "live-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
|
||||||
|
createStoredRecord({
|
||||||
|
id: "internal-archived-agent",
|
||||||
|
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
internal: true,
|
||||||
|
}),
|
||||||
|
createStoredRecord({ id: "not-archived-agent", archivedAt: null }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
logger,
|
||||||
|
providerRegistry: {
|
||||||
|
claude: createProviderDefinition({}),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["list_agents"];
|
||||||
|
const response = await tool.callback({ includeArchived: true });
|
||||||
|
|
||||||
|
expect(response.structuredContent.agents).toEqual([
|
||||||
|
expect.objectContaining({ id: "live-agent" }),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "archived-agent",
|
||||||
|
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "not-archived-agent",
|
||||||
|
archivedAt: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads archived agents before reading get_agent_activity", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
const record = createStoredRecord({ id: "archived-activity-agent" });
|
||||||
|
const snapshot = {
|
||||||
|
id: "archived-activity-agent",
|
||||||
|
currentModeId: "default",
|
||||||
|
} as ManagedAgent;
|
||||||
|
spies.agentManager.getAgent
|
||||||
|
.mockReturnValueOnce(null)
|
||||||
|
.mockReturnValue(snapshot)
|
||||||
|
.mockReturnValue(snapshot);
|
||||||
|
spies.agentStorage.get.mockResolvedValue(record);
|
||||||
|
spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot);
|
||||||
|
spies.agentManager.getTimeline.mockReturnValue([
|
||||||
|
{
|
||||||
|
kind: "status",
|
||||||
|
timestamp: "2026-04-11T00:00:00.000Z",
|
||||||
|
text: "Agent resumed",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
logger,
|
||||||
|
providerRegistry: {
|
||||||
|
claude: createProviderDefinition({}),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["get_agent_activity"];
|
||||||
|
const response = await tool.callback({ agentId: "archived-activity-agent" });
|
||||||
|
|
||||||
|
expect(response.structuredContent).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
agentId: "archived-activity-agent",
|
||||||
|
updateCount: 1,
|
||||||
|
currentModeId: "default",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled();
|
||||||
|
expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(
|
||||||
|
"archived-activity-agent",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
AgentPermissionResponseSchema,
|
AgentPermissionResponseSchema,
|
||||||
AgentSnapshotPayloadSchema,
|
AgentSnapshotPayloadSchema,
|
||||||
} from "../messages.js";
|
} from "../messages.js";
|
||||||
import { toAgentPayload } from "./agent-projections.js";
|
import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js";
|
||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import type { AgentStorage } from "./agent-storage.js";
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
import {
|
import {
|
||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
emitLiveTimelineItemIfAgentKnown,
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
@@ -199,6 +200,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const requireProviderRegistry = (): Record<AgentProvider, ProviderDefinition> => {
|
||||||
|
if (!providerRegistry) {
|
||||||
|
throw new Error("Provider registry is required to load stored agent records");
|
||||||
|
}
|
||||||
|
return providerRegistry;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveCallerAgent = () => {
|
const resolveCallerAgent = () => {
|
||||||
if (!callerAgentId) {
|
if (!callerAgentId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -598,6 +606,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
if (notifyOnFinish && callerAgentId) {
|
if (notifyOnFinish && callerAgentId) {
|
||||||
setupFinishNotification({
|
setupFinishNotification({
|
||||||
agentManager,
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
childAgentId: snapshot.id,
|
childAgentId: snapshot.id,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
@@ -790,6 +799,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
if (notifyOnFinish && callerAgentId) {
|
if (notifyOnFinish && callerAgentId) {
|
||||||
setupFinishNotification({
|
setupFinishNotification({
|
||||||
agentManager,
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
childAgentId: agentId,
|
childAgentId: agentId,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
@@ -853,19 +863,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
async ({ agentId }) => {
|
async ({ agentId }) => {
|
||||||
const snapshot = agentManager.getAgent(agentId);
|
const snapshot = agentManager.getAgent(agentId);
|
||||||
if (!snapshot) {
|
if (snapshot) {
|
||||||
|
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||||
|
agentStorage,
|
||||||
|
snapshot,
|
||||||
|
childLogger,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({
|
||||||
|
status: snapshot.lifecycle,
|
||||||
|
snapshot: structuredSnapshot,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await agentStorage.get(agentId);
|
||||||
|
if (!record || record.internal) {
|
||||||
throw new Error(`Agent ${agentId} not found`);
|
throw new Error(`Agent ${agentId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
const structuredSnapshot = buildStoredAgentPayload(
|
||||||
agentStorage,
|
record,
|
||||||
snapshot,
|
requireProviderRegistry(),
|
||||||
childLogger,
|
childLogger,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
content: [],
|
content: [],
|
||||||
structuredContent: ensureValidJson({
|
structuredContent: ensureValidJson({
|
||||||
status: snapshot.lifecycle,
|
status: structuredSnapshot.status,
|
||||||
snapshot: structuredSnapshot,
|
snapshot: structuredSnapshot,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -877,21 +903,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
{
|
{
|
||||||
title: "List agents",
|
title: "List agents",
|
||||||
description: "List all live agents managed by the server.",
|
description: "List all live agents managed by the server.",
|
||||||
inputSchema: {},
|
inputSchema: {
|
||||||
|
includeArchived: z.boolean().optional().default(false),
|
||||||
|
},
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
agents: z.array(AgentSnapshotPayloadSchema),
|
agents: z.array(AgentSnapshotPayloadSchema),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async () => {
|
async ({ includeArchived }) => {
|
||||||
const snapshots = agentManager.listAgents();
|
const liveSnapshots = agentManager.listAgents();
|
||||||
const agents = await Promise.all(
|
const liveAgents = await Promise.all(
|
||||||
snapshots.map((snapshot) =>
|
liveSnapshots.map((snapshot) =>
|
||||||
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
|
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id));
|
||||||
|
const storedRecords = await agentStorage.list();
|
||||||
|
const storedAgents = storedRecords
|
||||||
|
.filter((record) => !record.internal && !liveIds.has(record.id))
|
||||||
|
.filter((record) => includeArchived || !record.archivedAt)
|
||||||
|
.map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [],
|
content: [],
|
||||||
structuredContent: ensureValidJson({ agents }),
|
structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1566,6 +1601,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ agentId, limit }) => {
|
async ({ agentId, limit }) => {
|
||||||
|
await ensureAgentLoaded(agentId, {
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
logger: childLogger,
|
||||||
|
});
|
||||||
const timeline = agentManager.getTimeline(agentId);
|
const timeline = agentManager.getTimeline(agentId);
|
||||||
const snapshot = agentManager.getAgent(agentId);
|
const snapshot = agentManager.getAgent(agentId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
|
import { setupFinishNotification } from "./mcp-shared.js";
|
||||||
|
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||||
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
|
|
||||||
|
describe("setupFinishNotification", () => {
|
||||||
|
it("does not notify archived callers", async () => {
|
||||||
|
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||||
|
|
||||||
|
const childAgent = {
|
||||||
|
id: "child-agent",
|
||||||
|
lifecycle: "idle",
|
||||||
|
config: { title: "Child Agent" },
|
||||||
|
} as ManagedAgent;
|
||||||
|
|
||||||
|
const agentManager = {
|
||||||
|
getAgent: vi.fn((agentId: string) => {
|
||||||
|
if (agentId === "child-agent") {
|
||||||
|
return childAgent;
|
||||||
|
}
|
||||||
|
if (agentId === "caller-agent") {
|
||||||
|
return {
|
||||||
|
id: "caller-agent",
|
||||||
|
lifecycle: "idle",
|
||||||
|
config: { title: "Caller Agent" },
|
||||||
|
} as ManagedAgent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||||
|
subscriber = callback;
|
||||||
|
return () => {
|
||||||
|
subscriber = null;
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||||
|
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||||
|
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
|
||||||
|
} as unknown as AgentManager;
|
||||||
|
|
||||||
|
const agentStorage = {
|
||||||
|
get: vi.fn(async (agentId: string) =>
|
||||||
|
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
||||||
|
),
|
||||||
|
} as unknown as AgentStorage;
|
||||||
|
|
||||||
|
setupFinishNotification({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
childAgentId: "child-agent",
|
||||||
|
callerAgentId: "caller-agent",
|
||||||
|
logger: createTestLogger(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(subscriber).not.toBeNull();
|
||||||
|
|
||||||
|
childAgent.lifecycle = "running";
|
||||||
|
subscriber?.({
|
||||||
|
type: "agent_state",
|
||||||
|
agent: childAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
childAgent.lifecycle = "idle";
|
||||||
|
subscriber?.({
|
||||||
|
type: "agent_state",
|
||||||
|
agent: childAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((agentManager as any).streamAgent).not.toHaveBeenCalled();
|
||||||
|
expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -187,18 +187,19 @@ export function startAgentRun(
|
|||||||
|
|
||||||
interface SetupFinishNotificationParams {
|
interface SetupFinishNotificationParams {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
|
agentStorage: AgentStorage;
|
||||||
childAgentId: string;
|
childAgentId: string;
|
||||||
callerAgentId: string;
|
callerAgentId: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
|
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
|
||||||
const { agentManager, childAgentId, callerAgentId, logger } = params;
|
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
|
||||||
let hasSeenRunning = false;
|
let hasSeenRunning = false;
|
||||||
let fired = false;
|
let fired = false;
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
function notify(reason: "finished" | "errored" | "needs permission"): void {
|
async function notify(reason: "finished" | "errored" | "needs permission"): Promise<void> {
|
||||||
if (fired) {
|
if (fired) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,6 +210,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const callerRecord = await agentStorage.get(callerAgentId);
|
||||||
|
if (callerRecord?.archivedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
|
const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
|
||||||
const prompt = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`;
|
const prompt = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`;
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const ProviderRuntimeSettingsSchema = z
|
|||||||
.object({
|
.object({
|
||||||
command: ProviderCommandSchema.optional(),
|
command: ProviderCommandSchema.optional(),
|
||||||
env: z.record(z.string()).optional(),
|
env: z.record(z.string()).optional(),
|
||||||
|
disallowedTools: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export const ProviderOverrideSchema = z
|
|||||||
command: z.array(z.string().min(1)).min(1).optional(),
|
command: z.array(z.string().min(1)).min(1).optional(),
|
||||||
env: z.record(z.string()).optional(),
|
env: z.record(z.string()).optional(),
|
||||||
models: z.array(ProviderProfileModelSchema).optional(),
|
models: z.array(ProviderProfileModelSchema).optional(),
|
||||||
|
disallowedTools: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
order: z.number().optional(),
|
order: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -473,6 +473,52 @@ describe("buildProviderRegistry", () => {
|
|||||||
expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude");
|
expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("disallowedTools flows through to runtime settings", () => {
|
||||||
|
buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: {
|
||||||
|
claude: {
|
||||||
|
disallowedTools: ["WebSearch", "WebFetch"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockState.constructorArgs.claude[0]).toEqual({
|
||||||
|
runtimeSettings: {
|
||||||
|
command: undefined,
|
||||||
|
env: undefined,
|
||||||
|
disallowedTools: ["WebSearch", "WebFetch"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derived provider inherits and merges disallowedTools from base", () => {
|
||||||
|
buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: {
|
||||||
|
claude: {
|
||||||
|
disallowedTools: ["WebSearch"],
|
||||||
|
},
|
||||||
|
zai: {
|
||||||
|
extends: "claude",
|
||||||
|
label: "ZAI",
|
||||||
|
disallowedTools: ["ComputerUse"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const zaiArgs = mockState.constructorArgs.claude.find(
|
||||||
|
(entry) =>
|
||||||
|
Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) &&
|
||||||
|
(entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes(
|
||||||
|
"ComputerUse",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(zaiArgs).toBeDefined();
|
||||||
|
expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([
|
||||||
|
"WebSearch",
|
||||||
|
"ComputerUse",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
|
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
|
||||||
buildProviderRegistry(logger, {
|
buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function getProviderClientFactory(provider: string): ProviderClientFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined {
|
function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined {
|
||||||
if (!override?.command && !override?.env) {
|
if (!override?.command && !override?.env && !override?.disallowedTools) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +102,7 @@ function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
env: override.env,
|
env: override.env,
|
||||||
|
disallowedTools: override.disallowedTools,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +123,10 @@ function mergeRuntimeSettings(
|
|||||||
...(override?.env ?? {}),
|
...(override?.env ?? {}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
disallowedTools:
|
||||||
|
base?.disallowedTools || override?.disallowedTools
|
||||||
|
? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])]
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const TEST_CAPABILITIES = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
describe("ProviderSnapshotManager", () => {
|
describe("ProviderSnapshotManager", () => {
|
||||||
|
const ttlMs = 5 * 60 * 1_000;
|
||||||
const projectCwd = resolve("/tmp/project");
|
const projectCwd = resolve("/tmp/project");
|
||||||
const projectACwd = resolve("/tmp/project-a");
|
const projectACwd = resolve("/tmp/project-a");
|
||||||
const projectBCwd = resolve("/tmp/project-b");
|
const projectBCwd = resolve("/tmp/project-b");
|
||||||
@@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refresh with providers only re-fetches matching providers", async () => {
|
||||||
|
const codexFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const claudeFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet-4")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: codexFetchModels,
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
createMockProvider({
|
||||||
|
provider: "claude",
|
||||||
|
fetchModels: claudeFetchModels,
|
||||||
|
fetchModes: async () => [createMode("default")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||||
|
"sonnet-4",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.refresh({ cwd: projectCwd, providers: ["codex"] });
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading");
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
models: [createModel("claude", "sonnet-4")],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
expect(claudeFetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh treats an empty providers list as a full refresh", async () => {
|
||||||
|
const codexFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const claudeFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet-4")])
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: codexFetchModels,
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
createMockProvider({
|
||||||
|
provider: "claude",
|
||||||
|
fetchModels: claudeFetchModels,
|
||||||
|
fetchModes: async () => [createMode("default")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.refresh({ cwd: projectCwd, providers: [] });
|
||||||
|
|
||||||
|
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
label: "codex",
|
||||||
|
description: "codex test provider",
|
||||||
|
defaultModeId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "loading",
|
||||||
|
label: "claude",
|
||||||
|
description: "claude test provider",
|
||||||
|
defaultModeId: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||||
|
"sonnet-4.5",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
expect(claudeFetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh ignores provider filters that are not in the registry", async () => {
|
||||||
|
const codexFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: codexFetchModels,
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.refresh({ cwd: projectCwd, providers: ["zai"] });
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
models: [createModel("codex", "gpt-5.1")],
|
||||||
|
});
|
||||||
|
expect(codexFetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
test("refresh during an in-flight refresh is a no-op", async () => {
|
||||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||||
const fetchModes = deferred<AgentMode[]>();
|
const fetchModes = deferred<AgentMode[]>();
|
||||||
@@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
const changes: ProviderSnapshotEntry[][] = [];
|
const changes: ProviderSnapshotEntry[][] = [];
|
||||||
manager.on("change", (entries) => changes.push(entries));
|
manager.on("change", (entries) => changes.push(entries));
|
||||||
|
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
|
|
||||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||||
{
|
{
|
||||||
@@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
|
|
||||||
expect(changes).toHaveLength(1);
|
expect(changes).toHaveLength(1);
|
||||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||||
@@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => fetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs + 1;
|
||||||
|
|
||||||
|
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
models: [createModel("codex", "gpt-5.1")],
|
||||||
|
modes: [createMode("auto")],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => {
|
||||||
|
let now = 2_000;
|
||||||
|
const staleRefreshModels = deferred<AgentModelDefinition[]>();
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockImplementationOnce(async () => staleRefreshModels.promise);
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => fetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs + 1;
|
||||||
|
|
||||||
|
const firstStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
const secondStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||||
|
expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => {
|
||||||
|
let now = 3_000;
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async () => [createModel("codex", "gpt-5.1")],
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs - 1;
|
||||||
|
|
||||||
|
const freshSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => {
|
||||||
|
let now = 4_000;
|
||||||
|
const unavailableFetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValue([createModel("codex", "gpt-5.2")]);
|
||||||
|
const unavailableIsAvailable = vi
|
||||||
|
.fn<() => Promise<boolean>>()
|
||||||
|
.mockResolvedValueOnce(false)
|
||||||
|
.mockResolvedValueOnce(true);
|
||||||
|
const errorFetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockRejectedValueOnce(new Error("model lookup failed"))
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
isAvailable: unavailableIsAvailable,
|
||||||
|
fetchModels: async (cwd) => unavailableFetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
createMockProvider({
|
||||||
|
provider: "claude",
|
||||||
|
fetchModels: async (cwd) => errorFetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("default")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe(
|
||||||
|
"unavailable",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs + 1;
|
||||||
|
|
||||||
|
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable");
|
||||||
|
expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||||
|
"sonnet",
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot respects an injected TTL", async () => {
|
||||||
|
let now = 5_000;
|
||||||
|
const customTtlMs = 100;
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => fetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs: customTtlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
now += customTtlMs - 1;
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
now += 2;
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
||||||
const codexModels = deferred<AgentModelDefinition[]>();
|
const codexModels = deferred<AgentModelDefinition[]>();
|
||||||
const { registry, handles } = createRegistry([
|
const { registry, handles } = createRegistry([
|
||||||
|
|||||||
@@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"
|
|||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
|
||||||
const DEFAULT_CWD_KEY = "__default__";
|
const DEFAULT_CWD_KEY = "__default__";
|
||||||
|
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
|
||||||
|
|
||||||
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
|
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
|
||||||
|
type ProviderSnapshotManagerOptions = {
|
||||||
|
ttlMs?: number;
|
||||||
|
now?: () => number;
|
||||||
|
};
|
||||||
|
type ProviderSnapshotRefreshOptions = {
|
||||||
|
cwd?: string;
|
||||||
|
providers?: AgentProvider[];
|
||||||
|
};
|
||||||
|
|
||||||
export class ProviderSnapshotManager {
|
export class ProviderSnapshotManager {
|
||||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||||
|
private readonly lastCheckedAts = new Map<string, number>();
|
||||||
private readonly warmUps = new Map<string, Promise<void>>();
|
private readonly warmUps = new Map<string, Promise<void>>();
|
||||||
private readonly events = new EventEmitter();
|
private readonly events = new EventEmitter();
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
|
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
options: ProviderSnapshotManagerOptions = {},
|
||||||
|
) {
|
||||||
|
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
||||||
|
this.now = options.now ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const cwdKey = normalizeCwdKey(cwd);
|
||||||
@@ -29,17 +45,24 @@ export class ProviderSnapshotManager {
|
|||||||
void this.warmUp(cwd);
|
void this.warmUp(cwd);
|
||||||
return entriesToArray(loadingEntries);
|
return entriesToArray(loadingEntries);
|
||||||
}
|
}
|
||||||
|
if (this.shouldRevalidate(cwdKey)) {
|
||||||
|
void this.warmUp(cwd);
|
||||||
|
}
|
||||||
return entriesToArray(entries);
|
return entriesToArray(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(cwd?: string): void {
|
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
|
||||||
|
const { cwd } = options;
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const cwdKey = normalizeCwdKey(cwd);
|
||||||
if (this.warmUps.has(cwdKey)) {
|
const inFlight = this.warmUps.get(cwdKey);
|
||||||
|
if (inFlight) {
|
||||||
|
await inFlight;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.resetSnapshotToLoading(cwdKey);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
|
this.resetSnapshotToLoading(cwdKey, providers);
|
||||||
this.emitChange(cwdKey);
|
this.emitChange(cwdKey);
|
||||||
void this.warmUp(cwd);
|
await this.warmUp(cwd, providers);
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||||
@@ -56,6 +79,7 @@ export class ProviderSnapshotManager {
|
|||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this.events.removeAllListeners();
|
this.events.removeAllListeners();
|
||||||
this.snapshots.clear();
|
this.snapshots.clear();
|
||||||
|
this.lastCheckedAts.clear();
|
||||||
this.warmUps.clear();
|
this.warmUps.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,16 +98,21 @@ export class ProviderSnapshotManager {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async warmUp(cwd?: string): Promise<void> {
|
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const cwdKey = normalizeCwdKey(cwd);
|
||||||
const inFlight = this.warmUps.get(cwdKey);
|
const inFlight = this.warmUps.get(cwdKey);
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
const providersToRefresh = providers ?? this.getProviderIds();
|
||||||
|
|
||||||
const warmUpPromise = Promise.allSettled(
|
const warmUpPromise = Promise.allSettled(
|
||||||
this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
||||||
).then(() => undefined);
|
).then(() => {
|
||||||
|
if (!providers) {
|
||||||
|
this.lastCheckedAts.set(cwdKey, this.now());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.warmUps.set(cwdKey, warmUpPromise);
|
this.warmUps.set(cwdKey, warmUpPromise);
|
||||||
|
|
||||||
@@ -107,13 +136,6 @@ export class ProviderSnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||||
snapshot.set(provider, {
|
|
||||||
provider,
|
|
||||||
status: "loading",
|
|
||||||
label: definition.label,
|
|
||||||
description: definition.description,
|
|
||||||
defaultModeId: definition.defaultModeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = definition.createClient(this.logger);
|
const client = definition.createClient(this.logger);
|
||||||
@@ -174,6 +196,17 @@ export class ProviderSnapshotManager {
|
|||||||
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldRevalidate(cwdKey: string): boolean {
|
||||||
|
if (this.warmUps.has(cwdKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
|
||||||
|
if (lastCheckedAt === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.now() - lastCheckedAt > this.ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||||
const existing = this.snapshots.get(cwdKey);
|
const existing = this.snapshots.get(cwdKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -185,11 +218,31 @@ export class ProviderSnapshotManager {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
private resetSnapshotToLoading(
|
||||||
|
cwdKey: string,
|
||||||
|
providers?: AgentProvider[],
|
||||||
|
): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||||
snapshot.clear();
|
const loadingEntries = this.createLoadingEntries();
|
||||||
for (const [provider, entry] of this.createLoadingEntries()) {
|
|
||||||
snapshot.set(provider, entry);
|
if (!providers) {
|
||||||
|
snapshot.clear();
|
||||||
|
for (const [provider, entry] of loadingEntries) {
|
||||||
|
snapshot.set(provider, entry);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const loadingEntry = loadingEntries.get(provider);
|
||||||
|
if (!loadingEntry) continue;
|
||||||
|
const existing = snapshot.get(provider);
|
||||||
|
snapshot.set(provider, {
|
||||||
|
...loadingEntry,
|
||||||
|
models: existing?.models,
|
||||||
|
modes: existing?.modes,
|
||||||
|
fetchedAt: existing?.fetchedAt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
@@ -197,6 +250,15 @@ export class ProviderSnapshotManager {
|
|||||||
private getProviderIds(): AgentProvider[] {
|
private getProviderIds(): AgentProvider[] {
|
||||||
return Object.keys(this.providerRegistry) as AgentProvider[];
|
return Object.keys(this.providerRegistry) as AgentProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined {
|
||||||
|
if (!providers || providers.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerIds = new Set(this.getProviderIds());
|
||||||
|
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCwdKey(cwd?: string): string {
|
function normalizeCwdKey(cwd?: string): string {
|
||||||
|
|||||||
@@ -2146,6 +2146,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (this.claudeSessionId) {
|
if (this.claudeSessionId) {
|
||||||
base.resume = this.claudeSessionId;
|
base.resume = this.claudeSessionId;
|
||||||
}
|
}
|
||||||
|
if (this.runtimeSettings?.disallowedTools?.length) {
|
||||||
|
base.disallowedTools = [
|
||||||
|
...(base.disallowedTools ?? []),
|
||||||
|
...this.runtimeSettings.disallowedTools,
|
||||||
|
];
|
||||||
|
}
|
||||||
return this.applyRuntimeSettings(base);
|
return this.applyRuntimeSettings(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
|
|
||||||
|
const mockState = vi.hoisted(() => ({
|
||||||
|
superConstructorOptions: [] as unknown[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./acp-agent.js", () => ({
|
||||||
|
ACPAgentClient: class ACPAgentClient {
|
||||||
|
readonly provider: string;
|
||||||
|
|
||||||
|
constructor(options: unknown) {
|
||||||
|
this.provider = "acp";
|
||||||
|
mockState.superConstructorOptions.push(options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||||
|
|
||||||
|
describe("GenericACPAgentClient", () => {
|
||||||
|
test("passes the custom command only as defaultCommand", () => {
|
||||||
|
new GenericACPAgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
command: ["hermes", "acp"],
|
||||||
|
env: {
|
||||||
|
HERMES_LOG: "info",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockState.superConstructorOptions).toEqual([
|
||||||
|
{
|
||||||
|
provider: "acp",
|
||||||
|
logger: expect.any(Object),
|
||||||
|
runtimeSettings: {
|
||||||
|
env: {
|
||||||
|
HERMES_LOG: "info",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultCommand: ["hermes", "acp"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,10 +21,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
|||||||
provider: "acp",
|
provider: "acp",
|
||||||
logger: options.logger,
|
logger: options.logger,
|
||||||
runtimeSettings: {
|
runtimeSettings: {
|
||||||
command: {
|
|
||||||
mode: "replace",
|
|
||||||
argv: options.command,
|
|
||||||
},
|
|
||||||
env: options.env,
|
env: options.env,
|
||||||
},
|
},
|
||||||
defaultCommand: options.command as [string, ...string[]],
|
defaultCommand: options.command as [string, ...string[]],
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
|
ToolCallDetail,
|
||||||
ToolCallTimelineItem,
|
ToolCallTimelineItem,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
@@ -1164,6 +1165,150 @@ function readNonEmptyString(value: unknown): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapOpenCodeTodosToTimelineItems(
|
||||||
|
todos: Array<{ content?: string | null; status?: string | null }>,
|
||||||
|
): Extract<AgentTimelineItem, { type: "todo" }> {
|
||||||
|
return {
|
||||||
|
type: "todo",
|
||||||
|
items: todos.flatMap((todo) => {
|
||||||
|
const text = readNonEmptyString(todo.content);
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
completed: todo.status === "completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompactionTimelineItem(
|
||||||
|
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
|
||||||
|
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
|
||||||
|
): Extract<AgentTimelineItem, { type: "compaction" }> {
|
||||||
|
return {
|
||||||
|
type: "compaction",
|
||||||
|
status,
|
||||||
|
...(trigger ? { trigger } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const;
|
||||||
|
const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const;
|
||||||
|
const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const;
|
||||||
|
const PERMISSION_TITLE_BY_NAME: Record<string, string> = {
|
||||||
|
external_directory: "Access external directory",
|
||||||
|
bash: "Run shell command",
|
||||||
|
read: "Read files",
|
||||||
|
read_file: "Read files",
|
||||||
|
write: "Write files",
|
||||||
|
write_file: "Write files",
|
||||||
|
create_file: "Write files",
|
||||||
|
edit: "Edit files",
|
||||||
|
apply_patch: "Edit files",
|
||||||
|
apply_diff: "Edit files",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toHumanReadablePermissionTitle(permission: string): string {
|
||||||
|
const mapped = PERMISSION_TITLE_BY_NAME[permission];
|
||||||
|
if (mapped) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = permission
|
||||||
|
.split(/[\s_-]+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
||||||
|
.join(" ");
|
||||||
|
return normalized.length > 0 ? normalized : "Permission request";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFirstStringFromRecord(
|
||||||
|
record: Record<string, unknown> | null,
|
||||||
|
keys: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = readNonEmptyString(record[key]);
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPermissionField(
|
||||||
|
metadata: Record<string, unknown> | null,
|
||||||
|
keys: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
const direct = readFirstStringFromRecord(metadata, keys);
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedInput = readOpenCodeRecord(metadata?.input);
|
||||||
|
return readFirstStringFromRecord(nestedInput, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodePermissionInput(params: {
|
||||||
|
patterns: string[];
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
tool: Record<string, unknown> | null;
|
||||||
|
command: string | null;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...(params.patterns.length > 0 ? { patterns: params.patterns } : {}),
|
||||||
|
...(params.metadata ? { metadata: params.metadata } : {}),
|
||||||
|
...(params.tool ? { tool: params.tool } : {}),
|
||||||
|
...(params.command ? { command: params.command } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodePermissionDetail(params: {
|
||||||
|
permission: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
command: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
}): ToolCallDetail {
|
||||||
|
if (params.command) {
|
||||||
|
return {
|
||||||
|
type: "shell",
|
||||||
|
command: params.command,
|
||||||
|
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "unknown",
|
||||||
|
input: {
|
||||||
|
permission: params.permission,
|
||||||
|
...params.input,
|
||||||
|
},
|
||||||
|
output: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodePermissionDescription(params: {
|
||||||
|
reason: string | null;
|
||||||
|
patterns: string[];
|
||||||
|
}): string | undefined {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (params.reason) {
|
||||||
|
parts.push(params.reason);
|
||||||
|
}
|
||||||
|
if (params.patterns.length > 0) {
|
||||||
|
parts.push(`Scope: ${params.patterns.join(", ")}`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join(" - ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function translateOpenCodeEvent(
|
export function translateOpenCodeEvent(
|
||||||
event: OpenCodeEvent,
|
event: OpenCodeEvent,
|
||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
@@ -1263,6 +1408,12 @@ export function translateOpenCodeEvent(
|
|||||||
item: parsedToolPart.data,
|
item: parsedToolPart.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (part.type === "compaction") {
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"),
|
||||||
|
});
|
||||||
} else if (part.type === "step-finish") {
|
} else if (part.type === "step-finish") {
|
||||||
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
||||||
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
|
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
|
||||||
@@ -1320,6 +1471,31 @@ export function translateOpenCodeEvent(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metadata = readOpenCodeRecord(event.properties.metadata);
|
||||||
|
const tool = readOpenCodeRecord(event.properties.tool);
|
||||||
|
const patterns = Array.isArray(event.properties.patterns)
|
||||||
|
? event.properties.patterns.filter((value): value is string => typeof value === "string")
|
||||||
|
: [];
|
||||||
|
const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS);
|
||||||
|
const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS);
|
||||||
|
const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS);
|
||||||
|
const input = buildOpenCodePermissionInput({
|
||||||
|
patterns,
|
||||||
|
metadata,
|
||||||
|
tool,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
const detail = buildOpenCodePermissionDetail({
|
||||||
|
permission: event.properties.permission,
|
||||||
|
input,
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
const description = buildOpenCodePermissionDescription({
|
||||||
|
reason,
|
||||||
|
patterns,
|
||||||
|
});
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
type: "permission_requested",
|
type: "permission_requested",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -1328,9 +1504,10 @@ export function translateOpenCodeEvent(
|
|||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
name: event.properties.permission,
|
name: event.properties.permission,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
title: event.properties.permission,
|
title: toHumanReadablePermissionTitle(event.properties.permission),
|
||||||
description: event.properties.patterns?.join(", "),
|
...(description ? { description } : {}),
|
||||||
input: event.properties.metadata,
|
input,
|
||||||
|
detail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -1383,6 +1560,32 @@ export function translateOpenCodeEvent(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "todo.updated": {
|
||||||
|
if (event.properties.sessionID !== state.sessionId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: mapOpenCodeTodosToTimelineItems(event.properties.todos),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.compacted": {
|
||||||
|
if (event.properties.sessionID !== state.sessionId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: createCompactionTimelineItem("completed"),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "session.idle": {
|
case "session.idle": {
|
||||||
if (event.properties.sessionID === state.sessionId) {
|
if (event.properties.sessionID === state.sessionId) {
|
||||||
state.streamedPartKeys.clear();
|
state.streamedPartKeys.clear();
|
||||||
|
|||||||
@@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("humanizes permission requests and includes shell detail when command metadata exists", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const result = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
permission: "external_directory",
|
||||||
|
patterns: ["/home/user/secrets/*"],
|
||||||
|
metadata: {
|
||||||
|
command: "ls /home/user/secrets",
|
||||||
|
reason: "Need to inspect generated files",
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: "opencode",
|
||||||
|
request: {
|
||||||
|
id: "perm-1",
|
||||||
|
provider: "opencode",
|
||||||
|
name: "external_directory",
|
||||||
|
kind: "tool",
|
||||||
|
title: "Access external directory",
|
||||||
|
description: "Need to inspect generated files - Scope: /home/user/secrets/*",
|
||||||
|
input: {
|
||||||
|
patterns: ["/home/user/secrets/*"],
|
||||||
|
metadata: {
|
||||||
|
command: "ls /home/user/secrets",
|
||||||
|
reason: "Need to inspect generated files",
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
command: "ls /home/user/secrets",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "ls /home/user/secrets",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to unknown permission detail when command metadata is absent", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const result = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-2",
|
||||||
|
sessionID: "session-1",
|
||||||
|
permission: "external_directory",
|
||||||
|
patterns: ["/tmp/outside/*"],
|
||||||
|
metadata: {
|
||||||
|
reason: "Need to access temporary checkout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: "opencode",
|
||||||
|
request: {
|
||||||
|
id: "perm-2",
|
||||||
|
provider: "opencode",
|
||||||
|
name: "external_directory",
|
||||||
|
kind: "tool",
|
||||||
|
title: "Access external directory",
|
||||||
|
description: "Need to access temporary checkout - Scope: /tmp/outside/*",
|
||||||
|
input: {
|
||||||
|
patterns: ["/tmp/outside/*"],
|
||||||
|
metadata: {
|
||||||
|
reason: "Need to access temporary checkout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
type: "unknown",
|
||||||
|
input: {
|
||||||
|
permission: "external_directory",
|
||||||
|
patterns: ["/tmp/outside/*"],
|
||||||
|
metadata: {
|
||||||
|
reason: "Need to access temporary checkout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits usage_updated after step-finish parts", () => {
|
it("emits usage_updated after step-finish parts", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
|
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
|
||||||
@@ -310,6 +418,96 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits normalized todo timeline items from todo.updated", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const events = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "todo.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
todos: [
|
||||||
|
{ content: "Outline", status: "pending", priority: "high" },
|
||||||
|
{ content: "Ship", status: "completed", priority: "medium" },
|
||||||
|
{ content: " ", status: "completed", priority: "low" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: {
|
||||||
|
type: "todo",
|
||||||
|
items: [
|
||||||
|
{ text: "Outline", completed: false },
|
||||||
|
{ text: "Ship", completed: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits compaction loading timeline items from compaction parts", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const events = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "compaction-part-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-compaction-1",
|
||||||
|
type: "compaction",
|
||||||
|
auto: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: {
|
||||||
|
type: "compaction",
|
||||||
|
status: "loading",
|
||||||
|
trigger: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits compaction completed timeline items from session.compacted", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const events = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.compacted",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: {
|
||||||
|
type: "compaction",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits reasoning from message.part.delta events", () => {
|
it("emits reasoning from message.part.delta events", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
|
|
||||||
@@ -510,6 +708,99 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits turn_completed from session.status idle", () => {
|
||||||
|
const state = createState();
|
||||||
|
state.streamedPartKeys.add("text:part-1");
|
||||||
|
state.partTypes.set("part-1", "text");
|
||||||
|
|
||||||
|
const result = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
status: { type: "idle" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "opencode",
|
||||||
|
usage: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(state.streamedPartKeys.size).toBe(0);
|
||||||
|
expect(state.partTypes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits turn_failed from fatal session.status retry", () => {
|
||||||
|
const state = createState();
|
||||||
|
state.streamedPartKeys.add("text:part-1");
|
||||||
|
state.partTypes.set("part-1", "text");
|
||||||
|
|
||||||
|
const result = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
status: {
|
||||||
|
type: "retry",
|
||||||
|
attempt: 2,
|
||||||
|
message: "Invalid API key",
|
||||||
|
next: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: "opencode",
|
||||||
|
error: "Invalid API key",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(state.streamedPartKeys.size).toBe(0);
|
||||||
|
expect(state.partTypes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores transient session.status updates", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const busy = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
status: { type: "busy" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retry = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
status: {
|
||||||
|
type: "retry",
|
||||||
|
attempt: 1,
|
||||||
|
message: "rate limited",
|
||||||
|
next: Date.now() + 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(busy).toEqual([]);
|
||||||
|
expect(retry).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits structured assistant output when schema mode completes without text parts", () => {
|
it("emits structured assistant output when schema mode completes without text parts", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type pino from "pino";
|
|
||||||
|
|
||||||
import type { AgentManager } from "./agent/agent-manager.js";
|
import type { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
import type {
|
||||||
|
AgentPersistenceHandle,
|
||||||
|
AgentProvider,
|
||||||
|
AgentSessionConfig,
|
||||||
|
} from "./agent/agent-sdk-types.js";
|
||||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import { isValidAgentProvider } from "./agent/provider-manifest.js";
|
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||||
|
|
||||||
type LoggerLike = {
|
type LoggerLike = {
|
||||||
child(bindings: Record<string, unknown>): LoggerLike;
|
child(bindings: Record<string, unknown>): LoggerLike;
|
||||||
@@ -11,6 +13,8 @@ type LoggerLike = {
|
|||||||
warn(...args: any[]): void;
|
warn(...args: any[]): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_AGENT_PROVIDER = "claude";
|
||||||
|
|
||||||
function getLogger(logger: LoggerLike): LoggerLike {
|
function getLogger(logger: LoggerLike): LoggerLike {
|
||||||
return logger.child({ module: "persistence" });
|
return logger.child({ module: "persistence" });
|
||||||
}
|
}
|
||||||
@@ -23,6 +27,18 @@ type BuildSessionConfigOptions = {
|
|||||||
logger?: LoggerLike;
|
logger?: LoggerLike;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RegisteredProviders = ReturnType<typeof buildProviderRegistry> | Iterable<AgentProvider>;
|
||||||
|
|
||||||
|
function isProviderRegistry(
|
||||||
|
registeredProviders: RegisteredProviders,
|
||||||
|
): registeredProviders is ReturnType<typeof buildProviderRegistry> {
|
||||||
|
return (
|
||||||
|
typeof registeredProviders === "object" &&
|
||||||
|
registeredProviders !== null &&
|
||||||
|
!(Symbol.iterator in registeredProviders)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach AgentStorage persistence to an AgentManager instance so every
|
* Attach AgentStorage persistence to an AgentManager instance so every
|
||||||
* agent_state snapshot is flushed to disk.
|
* agent_state snapshot is flushed to disk.
|
||||||
@@ -90,30 +106,6 @@ export function buildSessionConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAgentPersistenceHandle(
|
|
||||||
logger: pino.Logger,
|
|
||||||
handle: StoredAgentRecord["persistence"],
|
|
||||||
) {
|
|
||||||
if (!handle) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const provider = handle.provider;
|
|
||||||
if (!isValidAgentProvider(provider)) {
|
|
||||||
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!handle.sessionId) {
|
|
||||||
logger.warn("Ignoring persistence handle missing sessionId");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
sessionId: handle.sessionId,
|
|
||||||
nativeHandle: handle.nativeHandle,
|
|
||||||
metadata: handle.metadata,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractTimestamps(record: StoredAgentRecord): {
|
export function extractTimestamps(record: StoredAgentRecord): {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -127,3 +119,58 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
|||||||
labels: record.labels,
|
labels: record.labels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRegisteredProvider(registeredProviders: RegisteredProviders, value: string): boolean {
|
||||||
|
if (isProviderRegistry(registeredProviders)) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(registeredProviders, value);
|
||||||
|
}
|
||||||
|
return new Set(registeredProviders).has(value as AgentProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegisteredProvider(
|
||||||
|
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
||||||
|
value: string,
|
||||||
|
): boolean {
|
||||||
|
return hasRegisteredProvider(providerRegistry, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceAgentProvider(
|
||||||
|
logger: LoggerLike,
|
||||||
|
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
||||||
|
value: string,
|
||||||
|
agentId?: string,
|
||||||
|
): AgentProvider {
|
||||||
|
if (isRegisteredProvider(providerRegistry, value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
{ value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER },
|
||||||
|
`Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`,
|
||||||
|
);
|
||||||
|
return DEFAULT_AGENT_PROVIDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAgentPersistenceHandle(
|
||||||
|
logger: LoggerLike,
|
||||||
|
registeredProviders: RegisteredProviders,
|
||||||
|
handle: StoredAgentRecord["persistence"],
|
||||||
|
): AgentPersistenceHandle | null {
|
||||||
|
if (!handle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const provider = handle.provider;
|
||||||
|
if (!hasRegisteredProvider(registeredProviders, provider)) {
|
||||||
|
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!handle.sessionId) {
|
||||||
|
logger.warn("Ignoring persistence handle missing sessionId");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
sessionId: handle.sessionId,
|
||||||
|
nativeHandle: handle.nativeHandle,
|
||||||
|
metadata: handle.metadata,
|
||||||
|
} satisfies AgentPersistenceHandle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -265,4 +265,62 @@ describe("ScheduleService", () => {
|
|||||||
expect(inspected.runs).toHaveLength(1);
|
expect(inspected.runs).toHaveLength(1);
|
||||||
expect(inspected.runs[0]?.status).toBe("succeeded");
|
expect(inspected.runs[0]?.status).toBe("succeeded");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects archived target agents before loading them", async () => {
|
||||||
|
const manager = new AgentManager({ logger: createTestLogger() });
|
||||||
|
const service = new ScheduleService({
|
||||||
|
paseoHome: tempDir,
|
||||||
|
logger: createTestLogger(),
|
||||||
|
agentManager: manager,
|
||||||
|
agentStorage,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentStorage.upsert({
|
||||||
|
id: "archived-agent",
|
||||||
|
provider: "claude",
|
||||||
|
cwd: tempDir,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
lastActivityAt: now.toISOString(),
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: "Archived Agent",
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "closed",
|
||||||
|
lastModeId: "default",
|
||||||
|
config: {
|
||||||
|
modeId: "default",
|
||||||
|
},
|
||||||
|
runtimeInfo: null,
|
||||||
|
features: [],
|
||||||
|
persistence: null,
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
internal: false,
|
||||||
|
archivedAt: "2026-01-02T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(service as any).executeSchedule({
|
||||||
|
id: "schedule-1",
|
||||||
|
name: null,
|
||||||
|
prompt: "Check archived agent",
|
||||||
|
cadence: { type: "every", everyMs: 60_000 },
|
||||||
|
target: {
|
||||||
|
type: "agent",
|
||||||
|
agentId: "archived-agent",
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
nextRunAt: now.toISOString(),
|
||||||
|
lastRunAt: null,
|
||||||
|
pausedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
maxRuns: null,
|
||||||
|
runs: [],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Agent archived-agent is archived");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { AgentManager } from "../agent/agent-manager.js";
|
import { AgentManager } from "../agent/agent-manager.js";
|
||||||
import type { ManagedAgent } from "../agent/agent-manager.js";
|
|
||||||
import type { AgentStorage } from "../agent/agent-storage.js";
|
import type { AgentStorage } from "../agent/agent-storage.js";
|
||||||
import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||||
import { curateAgentActivity } from "../agent/activity-curator.js";
|
import { curateAgentActivity } from "../agent/activity-curator.js";
|
||||||
import {
|
import { ensureAgentLoaded } from "../agent/agent-loading.js";
|
||||||
buildConfigOverrides,
|
|
||||||
buildSessionConfig,
|
|
||||||
extractTimestamps,
|
|
||||||
} from "../persistence-hooks.js";
|
|
||||||
import { ScheduleStore } from "./store.js";
|
import { ScheduleStore } from "./store.js";
|
||||||
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -21,7 +16,6 @@ import type {
|
|||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
const SCHEDULE_TICK_INTERVAL_MS = 1000;
|
const SCHEDULE_TICK_INTERVAL_MS = 1000;
|
||||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
|
||||||
|
|
||||||
function trimOptionalName(value: string | null | undefined): string | null {
|
function trimOptionalName(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -386,7 +380,16 @@ export class ScheduleService {
|
|||||||
|
|
||||||
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
|
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
|
||||||
if (schedule.target.type === "agent") {
|
if (schedule.target.type === "agent") {
|
||||||
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
|
const record = await this.agentStorage.get(schedule.target.agentId);
|
||||||
|
if (record?.archivedAt) {
|
||||||
|
throw new Error(`Agent ${schedule.target.agentId} is archived`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = await ensureAgentLoaded(schedule.target.agentId, {
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
if (this.agentManager.hasInFlightRun(agent.id)) {
|
if (this.agentManager.hasInFlightRun(agent.id)) {
|
||||||
throw new Error(`Agent ${agent.id} already has an active run`);
|
throw new Error(`Agent ${agent.id} already has an active run`);
|
||||||
}
|
}
|
||||||
@@ -435,64 +438,4 @@ export class ScheduleService {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
|
|
||||||
const existing = this.agentManager.getAgent(agentId);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = pendingAgentInitializations.get(agentId);
|
|
||||||
if (inflight) {
|
|
||||||
return inflight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initPromise = (async () => {
|
|
||||||
const record = await this.agentStorage.get(agentId);
|
|
||||||
if (!record) {
|
|
||||||
throw new Error(`Agent not found: ${agentId}`);
|
|
||||||
}
|
|
||||||
if (record.archivedAt) {
|
|
||||||
throw new Error(`Agent ${agentId} is archived`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot: ManagedAgent;
|
|
||||||
if (record.persistence?.provider && record.persistence?.sessionId) {
|
|
||||||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
|
||||||
{
|
|
||||||
provider: record.persistence.provider as AgentSessionConfig["provider"],
|
|
||||||
sessionId: record.persistence.sessionId,
|
|
||||||
nativeHandle: record.persistence.nativeHandle,
|
|
||||||
metadata: record.persistence.metadata,
|
|
||||||
},
|
|
||||||
buildConfigOverrides(record),
|
|
||||||
agentId,
|
|
||||||
extractTimestamps(record),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const config = buildSessionConfig(record, {
|
|
||||||
validProviders: this.agentManager.getRegisteredProviderIds(),
|
|
||||||
logger: this.logger,
|
|
||||||
});
|
|
||||||
if (!config) {
|
|
||||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
|
||||||
}
|
|
||||||
snapshot = await this.agentManager.createAgent(config, agentId, {
|
|
||||||
labels: record.labels,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.agentManager.hydrateTimelineFromProvider(agentId);
|
|
||||||
return this.agentManager.getAgent(agentId) ?? snapshot;
|
|
||||||
})();
|
|
||||||
|
|
||||||
pendingAgentInitializations.set(agentId, initPromise);
|
|
||||||
try {
|
|
||||||
return await initPromise;
|
|
||||||
} finally {
|
|
||||||
if (pendingAgentInitializations.get(agentId) === initPromise) {
|
|
||||||
pendingAgentInitializations.delete(agentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ import {
|
|||||||
createVoiceTurnController,
|
createVoiceTurnController,
|
||||||
type VoiceTurnController,
|
type VoiceTurnController,
|
||||||
} from "./voice/voice-turn-controller.js";
|
} from "./voice/voice-turn-controller.js";
|
||||||
|
import {
|
||||||
|
buildConfigOverrides,
|
||||||
|
extractTimestamps,
|
||||||
|
toAgentPersistenceHandle,
|
||||||
|
} from "./persistence-hooks.js";
|
||||||
|
import { ensureAgentLoaded } from "./agent/agent-loading.js";
|
||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||||
@@ -86,7 +92,12 @@ import type {
|
|||||||
ManagedAgent,
|
ManagedAgent,
|
||||||
} from "./agent/agent-manager.js";
|
} from "./agent/agent-manager.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from "./agent/agent-projections.js";
|
import {
|
||||||
|
buildStoredAgentPayload,
|
||||||
|
resolveEffectiveThinkingOptionId,
|
||||||
|
resolveStoredAgentPayloadUpdatedAt,
|
||||||
|
toAgentPayload,
|
||||||
|
} from "./agent/agent-projections.js";
|
||||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js";
|
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js";
|
||||||
import {
|
import {
|
||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
@@ -104,19 +115,18 @@ import {
|
|||||||
generateStructuredAgentResponseWithFallback,
|
generateStructuredAgentResponseWithFallback,
|
||||||
} from "./agent/agent-response-loop.js";
|
} from "./agent/agent-response-loop.js";
|
||||||
import type {
|
import type {
|
||||||
|
AgentPersistenceHandle,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
|
AgentProvider,
|
||||||
AgentPromptContentBlock,
|
AgentPromptContentBlock,
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
AgentRunOptions,
|
AgentRunOptions,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentProvider,
|
|
||||||
AgentPersistenceHandle,
|
|
||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
|
||||||
import {
|
import {
|
||||||
checkoutLiteFromGitSnapshot,
|
checkoutLiteFromGitSnapshot,
|
||||||
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
||||||
@@ -135,7 +145,6 @@ import {
|
|||||||
type ProjectRegistry,
|
type ProjectRegistry,
|
||||||
type WorkspaceRegistry,
|
type WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import { AgentLoadingService } from "./agent-loading-service.js";
|
|
||||||
import {
|
import {
|
||||||
buildVoiceModeSystemPrompt,
|
buildVoiceModeSystemPrompt,
|
||||||
stripVoiceModeSystemPrompt,
|
stripVoiceModeSystemPrompt,
|
||||||
@@ -195,7 +204,6 @@ import {
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
|
||||||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
||||||
|
|
||||||
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
|
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
|
||||||
@@ -464,7 +472,6 @@ export type SessionOptions = {
|
|||||||
scheduleService: ScheduleService;
|
scheduleService: ScheduleService;
|
||||||
loopService: LoopService;
|
loopService: LoopService;
|
||||||
checkoutDiffManager: CheckoutDiffManager;
|
checkoutDiffManager: CheckoutDiffManager;
|
||||||
agentLoadingService?: AgentLoadingService;
|
|
||||||
createAgentMcpTransport?: AgentMcpTransportFactory;
|
createAgentMcpTransport?: AgentMcpTransportFactory;
|
||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
daemonConfigStore: DaemonConfigStore;
|
daemonConfigStore: DaemonConfigStore;
|
||||||
@@ -570,54 +577,6 @@ function convertPCMToWavBuffer(
|
|||||||
return wavBuffer;
|
return wavBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRegisteredProvider(
|
|
||||||
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
|
||||||
value: string,
|
|
||||||
): boolean {
|
|
||||||
return Object.prototype.hasOwnProperty.call(providerRegistry, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function coerceAgentProvider(
|
|
||||||
logger: pino.Logger,
|
|
||||||
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
|
||||||
value: string,
|
|
||||||
agentId?: string,
|
|
||||||
): AgentProvider {
|
|
||||||
if (isRegisteredProvider(providerRegistry, value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
logger.warn(
|
|
||||||
{ value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER },
|
|
||||||
`Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`,
|
|
||||||
);
|
|
||||||
return DEFAULT_AGENT_PROVIDER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAgentPersistenceHandle(
|
|
||||||
logger: pino.Logger,
|
|
||||||
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
|
||||||
handle: StoredAgentRecord["persistence"],
|
|
||||||
): AgentPersistenceHandle | null {
|
|
||||||
if (!handle) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const provider = handle.provider;
|
|
||||||
if (!isRegisteredProvider(providerRegistry, provider)) {
|
|
||||||
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!handle.sessionId) {
|
|
||||||
logger.warn("Ignoring persistence handle missing sessionId");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
sessionId: handle.sessionId,
|
|
||||||
nativeHandle: handle.nativeHandle,
|
|
||||||
metadata: handle.metadata,
|
|
||||||
} satisfies AgentPersistenceHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session represents a single connected client session.
|
* Session represents a single connected client session.
|
||||||
* It owns all state management, orchestration logic, and message processing.
|
* It owns all state management, orchestration logic, and message processing.
|
||||||
@@ -673,7 +632,6 @@ export class Session {
|
|||||||
private readonly scheduleService: ScheduleService;
|
private readonly scheduleService: ScheduleService;
|
||||||
private readonly loopService: LoopService;
|
private readonly loopService: LoopService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||||
private readonly agentLoadingService: AgentLoadingService;
|
|
||||||
private readonly workspaceGitService: WorkspaceGitService;
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
private readonly daemonConfigStore: DaemonConfigStore;
|
private readonly daemonConfigStore: DaemonConfigStore;
|
||||||
private readonly mcpBaseUrl: string | null;
|
private readonly mcpBaseUrl: string | null;
|
||||||
@@ -752,7 +710,6 @@ export class Session {
|
|||||||
scheduleService,
|
scheduleService,
|
||||||
loopService,
|
loopService,
|
||||||
checkoutDiffManager,
|
checkoutDiffManager,
|
||||||
agentLoadingService,
|
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
daemonConfigStore,
|
daemonConfigStore,
|
||||||
mcpBaseUrl,
|
mcpBaseUrl,
|
||||||
@@ -794,13 +751,6 @@ export class Session {
|
|||||||
this.scheduleService = scheduleService;
|
this.scheduleService = scheduleService;
|
||||||
this.loopService = loopService;
|
this.loopService = loopService;
|
||||||
this.checkoutDiffManager = checkoutDiffManager;
|
this.checkoutDiffManager = checkoutDiffManager;
|
||||||
this.agentLoadingService =
|
|
||||||
agentLoadingService ??
|
|
||||||
new AgentLoadingService({
|
|
||||||
agentManager: this.agentManager,
|
|
||||||
agentStorage: this.agentStorage,
|
|
||||||
logger: this.sessionLogger,
|
|
||||||
});
|
|
||||||
this.workspaceGitService = workspaceGitService;
|
this.workspaceGitService = workspaceGitService;
|
||||||
this.daemonConfigStore = daemonConfigStore;
|
this.daemonConfigStore = daemonConfigStore;
|
||||||
this.mcpBaseUrl = mcpBaseUrl ?? null;
|
this.mcpBaseUrl = mcpBaseUrl ?? null;
|
||||||
@@ -1184,9 +1134,7 @@ export class Session {
|
|||||||
const storedRecord = await this.agentStorage.get(agent.id);
|
const storedRecord = await this.agentStorage.get(agent.id);
|
||||||
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null;
|
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null;
|
||||||
const payload = toAgentPayload(agent, { title });
|
const payload = toAgentPayload(agent, { title });
|
||||||
const storedUpdatedAt = storedRecord
|
const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null;
|
||||||
? this.resolveStoredAgentPayloadUpdatedAt(storedRecord)
|
|
||||||
: null;
|
|
||||||
if (storedUpdatedAt) {
|
if (storedUpdatedAt) {
|
||||||
const liveUpdatedAt = Date.parse(payload.updatedAt);
|
const liveUpdatedAt = Date.parse(payload.updatedAt);
|
||||||
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
|
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
|
||||||
@@ -1199,90 +1147,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
|
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
|
||||||
const defaultCapabilities = {
|
return buildStoredAgentPayload(record, this.providerRegistry, this.sessionLogger);
|
||||||
supportsStreaming: false,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: false,
|
|
||||||
supportsMcpServers: false,
|
|
||||||
supportsReasoningStream: false,
|
|
||||||
supportsToolInvocations: true,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const createdAt = new Date(record.createdAt);
|
|
||||||
const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record));
|
|
||||||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
|
||||||
|
|
||||||
const provider = coerceAgentProvider(
|
|
||||||
this.sessionLogger,
|
|
||||||
this.providerRegistry,
|
|
||||||
record.provider,
|
|
||||||
record.id,
|
|
||||||
);
|
|
||||||
const runtimeInfo = record.runtimeInfo
|
|
||||||
? {
|
|
||||||
provider: coerceAgentProvider(
|
|
||||||
this.sessionLogger,
|
|
||||||
this.providerRegistry,
|
|
||||||
record.runtimeInfo.provider,
|
|
||||||
record.id,
|
|
||||||
),
|
|
||||||
sessionId: record.runtimeInfo.sessionId,
|
|
||||||
model: record.runtimeInfo.model ?? null,
|
|
||||||
thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null,
|
|
||||||
modeId: record.runtimeInfo.modeId ?? null,
|
|
||||||
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
return {
|
|
||||||
id: record.id,
|
|
||||||
provider,
|
|
||||||
cwd: record.cwd,
|
|
||||||
model: record.config?.model ?? null,
|
|
||||||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
|
||||||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
|
||||||
runtimeInfo,
|
|
||||||
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
|
|
||||||
}),
|
|
||||||
...(runtimeInfo ? { runtimeInfo } : {}),
|
|
||||||
createdAt: createdAt.toISOString(),
|
|
||||||
updatedAt: updatedAt.toISOString(),
|
|
||||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
|
||||||
status: record.lastStatus,
|
|
||||||
capabilities: defaultCapabilities,
|
|
||||||
currentModeId: record.lastModeId ?? null,
|
|
||||||
availableModes: [],
|
|
||||||
pendingPermissions: [],
|
|
||||||
persistence: toAgentPersistenceHandle(
|
|
||||||
this.sessionLogger,
|
|
||||||
this.providerRegistry,
|
|
||||||
record.persistence,
|
|
||||||
),
|
|
||||||
lastUsage: undefined,
|
|
||||||
lastError: record.lastError ?? undefined,
|
|
||||||
title: record.title ?? record.config?.title ?? null,
|
|
||||||
requiresAttention: record.requiresAttention ?? false,
|
|
||||||
attentionReason: record.attentionReason ?? null,
|
|
||||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
|
||||||
archivedAt: record.archivedAt ?? null,
|
|
||||||
labels: record.labels,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
|
|
||||||
const timestamps = [record.updatedAt, record.lastActivityAt]
|
|
||||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
||||||
.map((value) => ({
|
|
||||||
raw: value,
|
|
||||||
parsed: Date.parse(value),
|
|
||||||
}))
|
|
||||||
.filter((value) => !Number.isNaN(value.parsed));
|
|
||||||
|
|
||||||
timestamps.sort((a, b) => b.parsed - a.parsed);
|
|
||||||
return timestamps[0].raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
|
|
||||||
return this.agentLoadingService.ensureAgentLoaded({ agentId });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isProviderVisibleToClient(provider: string): boolean {
|
private isProviderVisibleToClient(provider: string): boolean {
|
||||||
@@ -2633,7 +2498,11 @@ export class Session {
|
|||||||
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start");
|
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start");
|
||||||
const existing = await this.ensureAgentLoaded(agentId);
|
const existing = await ensureAgentLoaded(agentId, {
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ agentId, elapsedMs: Date.now() - startedAt },
|
{ agentId, elapsedMs: Date.now() - startedAt },
|
||||||
"enableVoiceModeForAgent.ensureAgentLoaded.done",
|
"enableVoiceModeForAgent.ensureAgentLoaded.done",
|
||||||
@@ -2857,7 +2726,11 @@ export class Session {
|
|||||||
await this.unarchiveAgentState(agentId);
|
await this.unarchiveAgentState(agentId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ensureAgentLoaded(agentId);
|
await ensureAgentLoaded(agentId, {
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
|
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
|
||||||
return {
|
return {
|
||||||
@@ -3057,11 +2930,9 @@ export class Session {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.unarchiveAgentByHandle(handle);
|
await this.unarchiveAgentByHandle(handle);
|
||||||
const snapshot = await this.agentLoadingService.resumeAgent({
|
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides);
|
||||||
handle,
|
|
||||||
overrides,
|
|
||||||
});
|
|
||||||
await this.unarchiveAgentState(snapshot.id);
|
await this.unarchiveAgentState(snapshot.id);
|
||||||
|
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
|
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
@@ -3099,10 +2970,32 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.unarchiveAgentState(agentId);
|
await this.unarchiveAgentState(agentId);
|
||||||
if (this.agentManager.getAgent(agentId)) {
|
let snapshot: ManagedAgent;
|
||||||
|
const existing = this.agentManager.getAgent(agentId);
|
||||||
|
if (existing) {
|
||||||
await this.interruptAgentIfRunning(agentId);
|
await this.interruptAgentIfRunning(agentId);
|
||||||
|
snapshot = await this.agentManager.reloadAgentSession(agentId);
|
||||||
|
} else {
|
||||||
|
const record = await this.agentStorage.get(agentId);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Agent not found: ${agentId}`);
|
||||||
|
}
|
||||||
|
const handle = toAgentPersistenceHandle(
|
||||||
|
this.sessionLogger,
|
||||||
|
this.providerRegistry,
|
||||||
|
record.persistence,
|
||||||
|
);
|
||||||
|
if (!handle) {
|
||||||
|
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
|
||||||
|
}
|
||||||
|
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||||||
|
handle,
|
||||||
|
buildConfigOverrides(record),
|
||||||
|
agentId,
|
||||||
|
extractTimestamps(record),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const snapshot = await this.agentLoadingService.refreshAgent({ agentId });
|
await this.agentManager.hydrateTimelineFromProvider(agentId);
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
const timelineSize = this.agentManager.getTimeline(agentId).length;
|
const timelineSize = this.agentManager.getTimeline(agentId).length;
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
@@ -3352,7 +3245,10 @@ export class Session {
|
|||||||
private async handleRefreshProvidersSnapshotRequest(
|
private async handleRefreshProvidersSnapshotRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
await this.providerSnapshotManager?.refresh({
|
||||||
|
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||||||
|
providers: msg.providers,
|
||||||
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "refresh_providers_snapshot_response",
|
type: "refresh_providers_snapshot_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -6699,7 +6595,11 @@ export class Session {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.ensureAgentLoaded(msg.agentId);
|
const snapshot = await ensureAgentLoaded(msg.agentId, {
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||||
|
|
||||||
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
|
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||||||
@@ -6853,7 +6753,11 @@ export class Session {
|
|||||||
const agentId = resolved.agentId;
|
const agentId = resolved.agentId;
|
||||||
await this.unarchiveAgentState(agentId);
|
await this.unarchiveAgentState(agentId);
|
||||||
|
|
||||||
await this.ensureAgentLoaded(agentId);
|
await ensureAgentLoaded(agentId, {
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
|
|
||||||
this.sessionLogger.trace(
|
this.sessionLogger.trace(
|
||||||
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
|
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
|
||||||
|
|||||||
@@ -207,6 +207,44 @@ describe("shared messages stream parsing", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses permission request detail compatibly", () => {
|
||||||
|
const parsed = AgentStreamMessageSchema.parse({
|
||||||
|
type: "agent_stream",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent_live",
|
||||||
|
timestamp: "2026-02-08T20:10:00.000Z",
|
||||||
|
event: {
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: "opencode",
|
||||||
|
request: {
|
||||||
|
id: "perm-shell-1",
|
||||||
|
provider: "opencode",
|
||||||
|
name: "external_directory",
|
||||||
|
kind: "tool",
|
||||||
|
title: "Access external directory",
|
||||||
|
input: {
|
||||||
|
command: "ls /tmp/outside",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "ls /tmp/outside",
|
||||||
|
cwd: "/home/dev/project",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.payload.event.type).toBe("permission_requested");
|
||||||
|
if (parsed.payload.event.type === "permission_requested") {
|
||||||
|
expect(parsed.payload.event.request.detail).toEqual({
|
||||||
|
type: "shell",
|
||||||
|
command: "ls /tmp/outside",
|
||||||
|
cwd: "/home/dev/project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects removed initialize_agent_request inbound payload", () => {
|
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||||
const parsed = SessionInboundMessageSchema.safeParse({
|
const parsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "initialize_agent_request",
|
type: "initialize_agent_request",
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
|||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
input: z.record(z.unknown()).optional(),
|
input: z.record(z.unknown()).optional(),
|
||||||
|
detail: z.lazy(() => ToolCallDetailPayloadSchema).optional(),
|
||||||
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||||
actions: z.array(AgentPermissionActionSchema).optional(),
|
actions: z.array(AgentPermissionActionSchema).optional(),
|
||||||
metadata: z.record(z.unknown()).optional(),
|
metadata: z.record(z.unknown()).optional(),
|
||||||
@@ -883,6 +884,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({
|
|||||||
export const RefreshProvidersSnapshotRequestMessageSchema = z.object({
|
export const RefreshProvidersSnapshotRequestMessageSchema = z.object({
|
||||||
type: z.literal("refresh_providers_snapshot_request"),
|
type: z.literal("refresh_providers_snapshot_request"),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
|
providers: z.array(AgentProviderSchema).optional(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user